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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2019-10-22 14:31:16 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2019-10-22 14:31:16 +0300
commit905c1110b08f93a19661cf42a276c7ea90d0a0ff (patch)
tree756d138db422392c00471ab06acdff92c5a9b69c /app
parent50d93f8d1686950fc58dda4823c4835fd0d8c14b (diff)
Add latest changes from gitlab-org/gitlab@12-4-stable-ee
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/ci_favicons/canary/favicon_status_preparing.icobin0 -> 34494 bytes
-rw-r--r--app/assets/images/ci_favicons/favicon_status_preparing.pngbin0 -> 11341 bytes
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/mixins/add_stage_mixin.js11
-rw-r--r--app/assets/javascripts/api.js22
-rw-r--r--app/assets/javascripts/autosave.js4
-rw-r--r--app/assets/javascripts/badges/components/badge_settings.vue4
-rw-r--r--app/assets/javascripts/behaviors/markdown/editor_extensions.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/audio.js53
-rw-r--r--app/assets/javascripts/behaviors/preview_markdown.js46
-rw-r--r--app/assets/javascripts/behaviors/requires_input.js2
-rw-r--r--app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js37
-rw-r--r--app/assets/javascripts/blob/blob_file_dropzone.js10
-rw-r--r--app/assets/javascripts/blob/file_template_mediator.js49
-rw-r--r--app/assets/javascripts/blob/template_selector.js13
-rw-r--r--app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js1
-rw-r--r--app/assets/javascripts/blob/template_selectors/dockerfile_selector.js1
-rw-r--r--app/assets/javascripts/blob/template_selectors/gitignore_selector.js1
-rw-r--r--app/assets/javascripts/blob/template_selectors/license_selector.js1
-rw-r--r--app/assets/javascripts/blob/template_selectors/type_selector.js1
-rw-r--r--app/assets/javascripts/blob/viewer/index.js6
-rw-r--r--app/assets/javascripts/boards/components/board_blank_state.vue30
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue20
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue1
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue218
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue7
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.vue12
-rw-r--r--app/assets/javascripts/boards/components/issue_time_estimate.vue2
-rw-r--r--app/assets/javascripts/boards/constants.js11
-rw-r--r--app/assets/javascripts/boards/index.js19
-rw-r--r--app/assets/javascripts/boards/mixins/issue_card_inner.js5
-rw-r--r--app/assets/javascripts/boards/models/list.js92
-rw-r--r--app/assets/javascripts/boards/services/board_service.js10
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js148
-rw-r--r--app/assets/javascripts/build_artifacts.js10
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js9
-rw-r--r--app/assets/javascripts/clusters/components/application_row.vue9
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue43
-rw-r--r--app/assets/javascripts/clusters/components/knative_domain_editor.vue2
-rw-r--r--app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue13
-rw-r--r--app/assets/javascripts/clusters/constants.js7
-rw-r--r--app/assets/javascripts/clusters/services/application_state_machine.js25
-rw-r--r--app/assets/javascripts/clusters/stores/clusters_store.js13
-rw-r--r--app/assets/javascripts/commit/image_file.js22
-rw-r--r--app/assets/javascripts/commons/vue.js4
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue13
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue17
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue393
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue63
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/role_name_dropdown.vue53
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/subnet_dropdown.vue0
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/vpc_dropdown.vue0
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/constants.js7
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/index.js17
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js84
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/actions.js42
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/actions.js14
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/getters.js (renamed from app/assets/javascripts/create_cluster/eks_cluster/components/security_group_dropdown.vue)0
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js13
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types.js3
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutations.js16
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/state.js5
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/index.js32
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js10
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js34
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/state.js21
-rw-r--r--app/assets/javascripts/create_item_dropdown.js1
-rw-r--r--app/assets/javascripts/create_label.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue2
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js8
-rw-r--r--app/assets/javascripts/diff_notes/services/resolve.js1
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue2
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_view.vue11
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_view.vue13
-rw-r--r--app/assets/javascripts/environments/components/stop_environment_modal.vue4
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue53
-rw-r--r--app/assets/javascripts/error_tracking/utils.js23
-rw-r--r--app/assets/javascripts/event_tracking/issue_sidebar.js2
-rw-r--r--app/assets/javascripts/event_tracking/notes.js2
-rw-r--r--app/assets/javascripts/filterable_list.js1
-rw-r--r--app/assets/javascripts/flash.js11
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js1
-rw-r--r--app/assets/javascripts/gl_dropdown.js140
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue6
-rw-r--r--app/assets/javascripts/groups/components/item_actions.vue4
-rw-r--r--app/assets/javascripts/groups_select.js175
-rw-r--r--app/assets/javascripts/header.js5
-rw-r--r--app/assets/javascripts/ide/.eslintrc.yml3
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue7
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list.vue4
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue4
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_item.vue5
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue4
-rw-r--r--app/assets/javascripts/ide/components/external_link.vue2
-rw-r--r--app/assets/javascripts/ide/components/file_row_extra.vue12
-rw-r--r--app/assets/javascripts/ide/components/ide_tree_list.vue7
-rw-r--r--app/assets/javascripts/ide/components/jobs/stage.vue2
-rw-r--r--app/assets/javascripts/ide/components/mr_file_icon.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/button.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue5
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue24
-rw-r--r--app/assets/javascripts/ide/components/repo_file_status_icon.vue2
-rw-r--r--app/assets/javascripts/ide/lib/files.js4
-rw-r--r--app/assets/javascripts/ide/stores/actions.js71
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js9
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js84
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js14
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js4
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js114
-rw-r--r--app/assets/javascripts/ide/stores/mutations/file.js18
-rw-r--r--app/assets/javascripts/ide/stores/state.js1
-rw-r--r--app/assets/javascripts/ide/stores/utils.js70
-rw-r--r--app/assets/javascripts/image_diff/helpers/badge_helper.js4
-rw-r--r--app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js4
-rw-r--r--app/assets/javascripts/import_projects/components/import_projects_table.vue45
-rw-r--r--app/assets/javascripts/import_projects/index.js2
-rw-r--r--app/assets/javascripts/import_projects/store/actions.js22
-rw-r--r--app/assets/javascripts/import_projects/store/getters.js5
-rw-r--r--app/assets/javascripts/import_projects/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/import_projects/store/mutations.js4
-rw-r--r--app/assets/javascripts/import_projects/store/state.js1
-rw-r--r--app/assets/javascripts/integrations/integration_settings_form.js1
-rw-r--r--app/assets/javascripts/issuable_bulk_update_actions.js4
-rw-r--r--app/assets/javascripts/issuable_sidebar/components/sidebar_app.vue23
-rw-r--r--app/assets/javascripts/issuable_sidebar/sidebar_bundle.js27
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue36
-rw-r--r--app/assets/javascripts/issue_show/index.js5
-rw-r--r--app/assets/javascripts/issue_show/services/index.js9
-rw-r--r--app/assets/javascripts/issue_show/stores/index.js12
-rw-r--r--app/assets/javascripts/issue_show/utils/update_description.js38
-rw-r--r--app/assets/javascripts/jobs/components/commit_block.vue2
-rw-r--r--app/assets/javascripts/jobs/components/environments_block.vue151
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue13
-rw-r--r--app/assets/javascripts/jobs/components/job_container_item.vue4
-rw-r--r--app/assets/javascripts/jobs/components/job_log.vue52
-rw-r--r--app/assets/javascripts/jobs/components/job_log_json.vue10
-rw-r--r--app/assets/javascripts/jobs/components/log/collapsible_section.vue51
-rw-r--r--app/assets/javascripts/jobs/components/log/duration_badge.vue2
-rw-r--r--app/assets/javascripts/jobs/components/log/line.vue12
-rw-r--r--app/assets/javascripts/jobs/components/log/line_header.vue12
-rw-r--r--app/assets/javascripts/jobs/components/log/line_number.vue2
-rw-r--r--app/assets/javascripts/jobs/components/log/log.vue29
-rw-r--r--app/assets/javascripts/jobs/store/mutations.js10
-rw-r--r--app/assets/javascripts/jobs/store/state.js1
-rw-r--r--app/assets/javascripts/jobs/store/utils.js196
-rw-r--r--app/assets/javascripts/labels_select.js41
-rw-r--r--app/assets/javascripts/lib/utils/axios_utils.js23
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js12
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js24
-rw-r--r--app/assets/javascripts/lib/utils/notify.js14
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js11
-rw-r--r--app/assets/javascripts/lib/utils/set.js9
-rw-r--r--app/assets/javascripts/lib/utils/suppress_ajax_errors_during_navigation.js16
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js8
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js8
-rw-r--r--app/assets/javascripts/line_highlighter.js10
-rw-r--r--app/assets/javascripts/main.js3
-rw-r--r--app/assets/javascripts/merge_request.js6
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue45
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue175
-rw-r--r--app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue151
-rw-r--r--app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue77
-rw-r--r--app/assets/javascripts/monitoring/components/embed.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/graph_group.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/panel_type.vue16
-rw-r--r--app/assets/javascripts/monitoring/constants.js10
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle.js1
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js12
-rw-r--r--app/assets/javascripts/monitoring/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js18
-rw-r--r--app/assets/javascripts/monitoring/stores/state.js1
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js19
-rw-r--r--app/assets/javascripts/monitoring/utils.js98
-rw-r--r--app/assets/javascripts/namespace_select.js9
-rw-r--r--app/assets/javascripts/network/branch_graph.js8
-rw-r--r--app/assets/javascripts/new_branch_form.js8
-rw-r--r--app/assets/javascripts/notebook/cells/code.vue1
-rw-r--r--app/assets/javascripts/notebook/cells/code/index.vue12
-rw-r--r--app/assets/javascripts/notebook/cells/output/index.vue5
-rw-r--r--app/assets/javascripts/notes.js25
-rw-r--r--app/assets/javascripts/notes/components/discussion_actions.vue1
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter.vue8
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue10
-rw-r--r--app/assets/javascripts/notes/components/note_actions/reply_button.vue6
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue6
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue34
-rw-r--r--app/assets/javascripts/notes/constants.js3
-rw-r--r--app/assets/javascripts/notes/index.js4
-rw-r--r--app/assets/javascripts/notes/stores/actions.js106
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js7
-rw-r--r--app/assets/javascripts/pager.js4
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue4
-rw-r--r--app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue113
-rw-r--r--app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue77
-rw-r--r--app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue70
-rw-r--r--app/assets/javascripts/pages/admin/users/index.js75
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/todos.js6
-rw-r--r--app/assets/javascripts/pages/groups/registry/repositories/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/shared/group_details.js7
-rw-r--r--app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue4
-rw-r--r--app/assets/javascripts/pages/projects/clusters/new/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/commit/pipelines/index.js1
-rw-r--r--app/assets/javascripts/pages/projects/commit/show/index.js1
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js6
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js45
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js14
-rw-r--r--app/assets/javascripts/pages/projects/issues/form.js4
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js7
-rw-r--r--app/assets/javascripts/pages/projects/issues/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue4
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js4
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js7
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/network/network.js4
-rw-r--r--app/assets/javascripts/pages/projects/project.js43
-rw-r--r--app/assets/javascripts/pages/projects/releases/edit/index.js9
-rw-r--r--app/assets/javascripts/pages/projects/releases/index/index.js2
-rw-r--r--app/assets/javascripts/pages/registrations/new/index.js9
-rw-r--r--app/assets/javascripts/pages/registrations/welcome/index.js7
-rw-r--r--app/assets/javascripts/pages/search/show/search.js1
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue18
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue8
-rw-r--r--app/assets/javascripts/performance_bar/components/request_selector.vue29
-rw-r--r--app/assets/javascripts/performance_bar/components/request_warning.vue41
-rw-r--r--app/assets/javascripts/performance_bar/index.js4
-rw-r--r--app/assets/javascripts/performance_bar/stores/performance_bar_store.js8
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue169
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue65
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue52
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_url.vue2
-rw-r--r--app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js60
-rw-r--r--app/assets/javascripts/pipelines/mixins/stage_column_mixin.js9
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js6
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_mediator.js2
-rw-r--r--app/assets/javascripts/pipelines/stores/pipeline_store.js190
-rw-r--r--app/assets/javascripts/privacy_policy_update_callout.js2
-rw-r--r--app/assets/javascripts/profile/account/components/update_username.vue4
-rw-r--r--app/assets/javascripts/project_find_file.js9
-rw-r--r--app/assets/javascripts/project_select.js181
-rw-r--r--app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue14
-rw-r--r--app/assets/javascripts/ref_select_dropdown.js1
-rw-r--r--app/assets/javascripts/registry/components/app.vue112
-rw-r--r--app/assets/javascripts/registry/components/collapsible_container.vue43
-rw-r--r--app/assets/javascripts/registry/components/group_empty_state.vue46
-rw-r--r--app/assets/javascripts/registry/components/project_empty_state.vue133
-rw-r--r--app/assets/javascripts/registry/components/table_registry.vue94
-rw-r--r--app/assets/javascripts/registry/index.js25
-rw-r--r--app/assets/javascripts/registry/stores/actions.js2
-rw-r--r--app/assets/javascripts/registry/stores/getters.js1
-rw-r--r--app/assets/javascripts/registry/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/registry/stores/mutations.js5
-rw-r--r--app/assets/javascripts/registry/stores/state.js1
-rw-r--r--app/assets/javascripts/releases/components/milestone_list.vue45
-rw-r--r--app/assets/javascripts/releases/detail/components/app.vue156
-rw-r--r--app/assets/javascripts/releases/detail/index.js19
-rw-r--r--app/assets/javascripts/releases/detail/store/actions.js62
-rw-r--r--app/assets/javascripts/releases/detail/store/index.js14
-rw-r--r--app/assets/javascripts/releases/detail/store/mutation_types.js12
-rw-r--r--app/assets/javascripts/releases/detail/store/mutations.js42
-rw-r--r--app/assets/javascripts/releases/detail/store/state.js15
-rw-r--r--app/assets/javascripts/releases/list/components/app.vue (renamed from app/assets/javascripts/releases/components/app.vue)0
-rw-r--r--app/assets/javascripts/releases/list/components/release_block.vue (renamed from app/assets/javascripts/releases/components/release_block.vue)120
-rw-r--r--app/assets/javascripts/releases/list/index.js (renamed from app/assets/javascripts/releases/index.js)0
-rw-r--r--app/assets/javascripts/releases/list/store/actions.js (renamed from app/assets/javascripts/releases/store/actions.js)0
-rw-r--r--app/assets/javascripts/releases/list/store/index.js (renamed from app/assets/javascripts/releases/store/index.js)0
-rw-r--r--app/assets/javascripts/releases/list/store/mutation_types.js (renamed from app/assets/javascripts/releases/store/mutation_types.js)0
-rw-r--r--app/assets/javascripts/releases/list/store/mutations.js (renamed from app/assets/javascripts/releases/store/mutations.js)0
-rw-r--r--app/assets/javascripts/releases/list/store/state.js (renamed from app/assets/javascripts/releases/store/state.js)0
-rw-r--r--app/assets/javascripts/reports/components/modal.vue4
-rw-r--r--app/assets/javascripts/reports/components/report_section.vue2
-rw-r--r--app/assets/javascripts/reports/store/utils.js2
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue6
-rw-r--r--app/assets/javascripts/repository/queries/pathLastCommit.query.graphql1
-rw-r--r--app/assets/javascripts/right_sidebar.js4
-rw-r--r--app/assets/javascripts/search_autocomplete.js6
-rw-r--r--app/assets/javascripts/serverless/components/url.vue2
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_title.vue13
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue7
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue26
-rw-r--r--app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue11
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue18
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/todo.vue6
-rw-r--r--app/assets/javascripts/sidebar/lib/sidebar_move_issue.js1
-rw-r--r--app/assets/javascripts/single_file_diff.js11
-rw-r--r--app/assets/javascripts/snippet/snippet_embed.js41
-rw-r--r--app/assets/javascripts/templates/issuable_template_selector.js58
-rw-r--r--app/assets/javascripts/templates/issuable_template_selectors.js3
-rw-r--r--app/assets/javascripts/test_utils/index.js2
-rw-r--r--app/assets/javascripts/tracking.js103
-rw-r--r--app/assets/javascripts/tree.js4
-rw-r--r--app/assets/javascripts/user_popovers.js23
-rw-r--r--app/assets/javascripts/users_select.js39
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/artifacts_list.vue40
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue36
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue88
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js74
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/getters.js16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/index.js16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutation_types.js5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutations.js22
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/state.js8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/changed_file_icon.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/clipboard_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/deprecated_modal.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue118
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_modal.vue117
-rw-r--r--app/assets/javascripts/vue_shared/components/icon.vue52
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js21
-rw-r--r--app/assets/javascripts/vue_shared/components/recaptcha_modal.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/toggle_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue10
-rw-r--r--app/assets/javascripts/vue_shared/directives/track_event.js20
-rw-r--r--app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js7
-rw-r--r--app/assets/javascripts/vue_shared/mixins/gl_feature_flags_mixin.js8
-rw-r--r--app/assets/javascripts/vue_shared/plugins/global_toast.js3
-rw-r--r--app/assets/javascripts/vue_shared/vue_resource_interceptor.js34
-rw-r--r--app/assets/javascripts/zen_mode.js8
-rw-r--r--app/assets/stylesheets/components/release_block.scss3
-rw-r--r--app/assets/stylesheets/framework.scss2
-rw-r--r--app/assets/stylesheets/framework/animations.scss15
-rw-r--r--app/assets/stylesheets/framework/blank.scss49
-rw-r--r--app/assets/stylesheets/framework/card.scss8
-rw-r--r--app/assets/stylesheets/framework/common.scss29
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss6
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss3
-rw-r--r--app/assets/stylesheets/framework/files.scss7
-rw-r--r--app/assets/stylesheets/framework/flash.scss18
-rw-r--r--app/assets/stylesheets/framework/header.scss1
-rw-r--r--app/assets/stylesheets/framework/icons.scss11
-rw-r--r--app/assets/stylesheets/framework/job_log.scss9
-rw-r--r--app/assets/stylesheets/framework/selects.scss2
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss4
-rw-r--r--app/assets/stylesheets/framework/typography.scss145
-rw-r--r--app/assets/stylesheets/framework/variables.scss8
-rw-r--r--app/assets/stylesheets/pages/boards.scss6
-rw-r--r--app/assets/stylesheets/pages/builds.scss24
-rw-r--r--app/assets/stylesheets/pages/editor.scss65
-rw-r--r--app/assets/stylesheets/pages/experimental_separate_sign_up.scss51
-rw-r--r--app/assets/stylesheets/pages/help.scss1
-rw-r--r--app/assets/stylesheets/pages/login.scss6
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss2
-rw-r--r--app/assets/stylesheets/pages/notes.scss17
-rw-r--r--app/assets/stylesheets/pages/projects.scss15
-rw-r--r--app/assets/stylesheets/pages/prometheus.scss60
-rw-r--r--app/assets/stylesheets/pages/status.scss5
-rw-r--r--app/assets/stylesheets/pages/tags.scss3
-rw-r--r--app/controllers/admin/application_settings_controller.rb11
-rw-r--r--app/controllers/admin/dashboard_controller.rb5
-rw-r--r--app/controllers/admin/sessions_controller.rb33
-rw-r--r--app/controllers/admin/users_controller.rb16
-rw-r--r--app/controllers/application_controller.rb31
-rw-r--r--app/controllers/boards/application_controller.rb2
-rw-r--r--app/controllers/boards/lists_controller.rb17
-rw-r--r--app/controllers/clusters/clusters_controller.rb1
-rw-r--r--app/controllers/concerns/cycle_analytics_params.rb25
-rw-r--r--app/controllers/concerns/enforces_admin_authentication.rb12
-rw-r--r--app/controllers/concerns/invisible_captcha.rb4
-rw-r--r--app/controllers/concerns/metrics_dashboard.rb63
-rw-r--r--app/controllers/concerns/milestone_actions.rb2
-rw-r--r--app/controllers/concerns/render_service_results.rb29
-rw-r--r--app/controllers/concerns/renders_assignees.rb7
-rw-r--r--app/controllers/concerns/sessionless_authentication.rb10
-rw-r--r--app/controllers/concerns/uploads_actions.rb28
-rw-r--r--app/controllers/dashboard/todos_controller.rb4
-rw-r--r--app/controllers/explore/snippets_controller.rb2
-rw-r--r--app/controllers/groups/boards_controller.rb3
-rw-r--r--app/controllers/groups/milestones_controller.rb2
-rw-r--r--app/controllers/groups/registry/repositories_controller.rb39
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb23
-rw-r--r--app/controllers/groups/uploads_controller.rb2
-rw-r--r--app/controllers/groups_controller.rb23
-rw-r--r--app/controllers/health_controller.rb36
-rw-r--r--app/controllers/help_controller.rb4
-rw-r--r--app/controllers/import/bitbucket_controller.rb8
-rw-r--r--app/controllers/import/github_controller.rb21
-rw-r--r--app/controllers/notification_settings_controller.rb4
-rw-r--r--app/controllers/oauth/applications_controller.rb1
-rw-r--r--app/controllers/oauth/authorizations_controller.rb1
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb5
-rw-r--r--app/controllers/profiles/groups_controller.rb2
-rw-r--r--app/controllers/profiles/notifications_controller.rb11
-rw-r--r--app/controllers/profiles_controller.rb1
-rw-r--r--app/controllers/projects/artifacts_controller.rb38
-rw-r--r--app/controllers/projects/boards_controller.rb3
-rw-r--r--app/controllers/projects/branches_controller.rb19
-rw-r--r--app/controllers/projects/commits_controller.rb4
-rw-r--r--app/controllers/projects/cycle_analytics/events_controller.rb10
-rw-r--r--app/controllers/projects/cycle_analytics_controller.rb8
-rw-r--r--app/controllers/projects/deploy_keys_controller.rb14
-rw-r--r--app/controllers/projects/deployments_controller.rb4
-rw-r--r--app/controllers/projects/environments/prometheus_api_controller.rb21
-rw-r--r--app/controllers/projects/environments_controller.rb52
-rw-r--r--app/controllers/projects/git_http_client_controller.rb2
-rw-r--r--app/controllers/projects/git_http_controller.rb16
-rw-r--r--app/controllers/projects/grafana_api_controller.rb25
-rw-r--r--app/controllers/projects/issues_controller.rb4
-rw-r--r--app/controllers/projects/jobs_controller.rb41
-rw-r--r--app/controllers/projects/lfs_api_controller.rb17
-rw-r--r--app/controllers/projects/merge_requests/application_controller.rb6
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb43
-rw-r--r--app/controllers/projects/merge_requests_controller.rb12
-rw-r--r--app/controllers/projects/pipelines_controller.rb25
-rw-r--r--app/controllers/projects/protected_branches_controller.rb12
-rw-r--r--app/controllers/projects/registry/repositories_controller.rb2
-rw-r--r--app/controllers/projects/registry/tags_controller.rb38
-rw-r--r--app/controllers/projects/releases_controller.rb3
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb10
-rw-r--r--app/controllers/projects/settings/operations_controller.rb9
-rw-r--r--app/controllers/projects/templates_controller.rb8
-rw-r--r--app/controllers/projects/uploads_controller.rb2
-rw-r--r--app/controllers/projects_controller.rb1
-rw-r--r--app/controllers/registrations_controller.rb68
-rw-r--r--app/controllers/sessions_controller.rb10
-rw-r--r--app/controllers/uploads_controller.rb9
-rw-r--r--app/finders/artifacts_finder.rb24
-rw-r--r--app/finders/clusters/kubernetes_namespace_finder.rb12
-rw-r--r--app/finders/issuable_finder.rb9
-rw-r--r--app/finders/snippets_finder.rb39
-rw-r--r--app/finders/todos_finder.rb36
-rw-r--r--app/finders/user_finder.rb6
-rw-r--r--app/graphql/mutations/base_mutation.rb10
-rw-r--r--app/graphql/mutations/concerns/mutations/resolves_group.rb15
-rw-r--r--app/graphql/resolvers/issues_resolver.rb2
-rw-r--r--app/graphql/resolvers/last_commit_resolver.rb17
-rw-r--r--app/graphql/resolvers/todo_resolver.rb90
-rw-r--r--app/graphql/types/commit_type.rb2
-rw-r--r--app/graphql/types/extended_issue_type.rb14
-rw-r--r--app/graphql/types/issuable_sort_enum.rb10
-rw-r--r--app/graphql/types/issue_sort_enum.rb10
-rw-r--r--app/graphql/types/issue_type.rb4
-rw-r--r--app/graphql/types/merge_request_type.rb17
-rw-r--r--app/graphql/types/project_type.rb2
-rw-r--r--app/graphql/types/query_type.rb5
-rw-r--r--app/graphql/types/todo_action_enum.rb13
-rw-r--r--app/graphql/types/todo_state_enum.rb8
-rw-r--r--app/graphql/types/todo_target_enum.rb9
-rw-r--r--app/graphql/types/todo_type.rb53
-rw-r--r--app/graphql/types/tree/tree_type.rb6
-rw-r--r--app/graphql/types/user_type.rb3
-rw-r--r--app/helpers/application_helper.rb5
-rw-r--r--app/helpers/application_settings_helper.rb13
-rw-r--r--app/helpers/avatars_helper.rb23
-rw-r--r--app/helpers/blob_helper.rb12
-rw-r--r--app/helpers/button_helper.rb2
-rw-r--r--app/helpers/ci_status_helper.rb17
-rw-r--r--app/helpers/diff_helper.rb15
-rw-r--r--app/helpers/environment_helper.rb33
-rw-r--r--app/helpers/export_helper.rb17
-rw-r--r--app/helpers/gitlab_routing_helper.rb8
-rw-r--r--app/helpers/groups_helper.rb15
-rw-r--r--app/helpers/issuables_helper.rb14
-rw-r--r--app/helpers/nav_helper.rb7
-rw-r--r--app/helpers/projects_helper.rb16
-rw-r--r--app/helpers/releases_helper.rb24
-rw-r--r--app/helpers/search_helper.rb19
-rw-r--r--app/helpers/sorting_helper.rb20
-rw-r--r--app/helpers/submodule_helper.rb2
-rw-r--r--app/helpers/tags_helper.rb10
-rw-r--r--app/helpers/todos_helper.rb6
-rw-r--r--app/mailers/emails/issues.rb4
-rw-r--r--app/mailers/emails/members.rb16
-rw-r--r--app/mailers/emails/merge_requests.rb2
-rw-r--r--app/mailers/emails/notes.rb2
-rw-r--r--app/mailers/emails/pages_domains.rb8
-rw-r--r--app/mailers/emails/pipelines.rb4
-rw-r--r--app/mailers/emails/projects.rb10
-rw-r--r--app/mailers/emails/releases.rb28
-rw-r--r--app/mailers/emails/remote_mirrors.rb3
-rw-r--r--app/mailers/notify.rb15
-rw-r--r--app/models/analytics/cycle_analytics/project_stage.rb1
-rw-r--r--app/models/application_setting.rb10
-rw-r--r--app/models/application_setting_implementation.rb47
-rw-r--r--app/models/audit_event.rb9
-rw-r--r--app/models/aws/role.rb17
-rw-r--r--app/models/blob.rb7
-rw-r--r--app/models/blob_viewer/audio.rb12
-rw-r--r--app/models/blob_viewer/image.rb2
-rw-r--r--app/models/blob_viewer/video.rb4
-rw-r--r--app/models/board.rb5
-rw-r--r--app/models/ci/artifact_blob.rb2
-rw-r--r--app/models/ci/build.rb19
-rw-r--r--app/models/ci/build_trace.rb41
-rw-r--r--app/models/ci/build_trace_section.rb3
-rw-r--r--app/models/ci/group.rb19
-rw-r--r--app/models/ci/job_artifact.rb6
-rw-r--r--app/models/ci/legacy_stage.rb14
-rw-r--r--app/models/ci/persistent_ref.rb44
-rw-r--r--app/models/ci/pipeline.rb93
-rw-r--r--app/models/ci/pipeline_enums.rb1
-rw-r--r--app/models/ci/pipeline_schedule.rb2
-rw-r--r--app/models/ci/sources/pipeline.rb25
-rw-r--r--app/models/ci/stage.rb9
-rw-r--r--app/models/ci/trigger.rb2
-rw-r--r--app/models/clusters/applications/cert_manager.rb2
-rw-r--r--app/models/clusters/applications/helm.rb9
-rw-r--r--app/models/clusters/applications/ingress.rb2
-rw-r--r--app/models/clusters/applications/jupyter.rb2
-rw-r--r--app/models/clusters/applications/knative.rb8
-rw-r--r--app/models/clusters/applications/prometheus.rb7
-rw-r--r--app/models/clusters/applications/runner.rb2
-rw-r--r--app/models/clusters/cluster.rb43
-rw-r--r--app/models/clusters/clusters_hierarchy.rb18
-rw-r--r--app/models/clusters/concerns/application_core.rb4
-rw-r--r--app/models/clusters/concerns/application_status.rb19
-rw-r--r--app/models/clusters/concerns/application_version.rb4
-rw-r--r--app/models/clusters/concerns/provider_status.rb52
-rw-r--r--app/models/clusters/kubernetes_namespace.rb2
-rw-r--r--app/models/clusters/platforms/kubernetes.rb20
-rw-r--r--app/models/clusters/providers/aws.rb47
-rw-r--r--app/models/clusters/providers/gcp.rb56
-rw-r--r--app/models/commit.rb47
-rw-r--r--app/models/commit_collection.rb39
-rw-r--r--app/models/commit_status.rb8
-rw-r--r--app/models/commit_with_pipeline.rb38
-rw-r--r--app/models/concerns/analytics/cycle_analytics/stage.rb12
-rw-r--r--app/models/concerns/atomic_internal_id.rb77
-rw-r--r--app/models/concerns/avatarable.rb2
-rw-r--r--app/models/concerns/checksummable.rb11
-rw-r--r--app/models/concerns/ci/contextable.rb1
-rw-r--r--app/models/concerns/ci/pipeline_delegator.rb3
-rw-r--r--app/models/concerns/deployable.rb31
-rw-r--r--app/models/concerns/deployment_platform.rb6
-rw-r--r--app/models/concerns/group_api_compatibility.rb22
-rw-r--r--app/models/concerns/has_status.rb22
-rw-r--r--app/models/concerns/issuable.rb54
-rw-r--r--app/models/concerns/issuable_states.rb22
-rw-r--r--app/models/concerns/mentionable.rb2
-rw-r--r--app/models/concerns/milestoneish.rb6
-rw-r--r--app/models/concerns/noteable.rb2
-rw-r--r--app/models/concerns/notification_branch_selection.rb2
-rw-r--r--app/models/concerns/prometheus_adapter.rb2
-rw-r--r--app/models/concerns/relative_positioning.rb2
-rw-r--r--app/models/concerns/routable.rb11
-rw-r--r--app/models/concerns/spammable.rb5
-rw-r--r--app/models/concerns/stepable.rb8
-rw-r--r--app/models/concerns/versioned_description.rb31
-rw-r--r--app/models/concerns/worker_attributes.rb48
-rw-r--r--app/models/container_repository.rb7
-rw-r--r--app/models/cycle_analytics/project_level.rb1
-rw-r--r--app/models/deployment.rb10
-rw-r--r--app/models/description_version.rb22
-rw-r--r--app/models/diff_note.rb4
-rw-r--r--app/models/diff_viewer/image.rb2
-rw-r--r--app/models/environment.rb7
-rw-r--r--app/models/event.rb9
-rw-r--r--app/models/event_collection.rb43
-rw-r--r--app/models/evidence.rb27
-rw-r--r--app/models/global_milestone.rb2
-rw-r--r--app/models/gpg_signature.rb2
-rw-r--r--app/models/grafana_integration.rb20
-rw-r--r--app/models/group.rb15
-rw-r--r--app/models/hooks/web_hook.rb2
-rw-r--r--app/models/internal_id.rb10
-rw-r--r--app/models/issue.rb13
-rw-r--r--app/models/lfs_object.rb12
-rw-r--r--app/models/list.rb36
-rw-r--r--app/models/merge_request.rb40
-rw-r--r--app/models/merge_request_diff.rb31
-rw-r--r--app/models/merge_request_diff_file.rb1
-rw-r--r--app/models/milestone.rb3
-rw-r--r--app/models/namespace.rb31
-rw-r--r--app/models/note.rb24
-rw-r--r--app/models/notification_setting.rb5
-rw-r--r--app/models/pages/lookup_path.rb11
-rw-r--r--app/models/pages/virtual_domain.rb7
-rw-r--r--app/models/pages_domain.rb16
-rw-r--r--app/models/project.rb99
-rw-r--r--app/models/project_pages_metadatum.rb9
-rw-r--r--app/models/project_services/data_fields.rb2
-rw-r--r--app/models/project_services/hipchat_service.rb4
-rw-r--r--app/models/project_services/irker_service.rb4
-rw-r--r--app/models/project_services/issue_tracker_service.rb13
-rw-r--r--app/models/project_services/jira_service.rb24
-rw-r--r--app/models/project_services/packagist_service.rb2
-rw-r--r--app/models/project_services/slash_commands_service.rb7
-rw-r--r--app/models/project_wiki.rb2
-rw-r--r--app/models/protected_branch.rb5
-rw-r--r--app/models/push_event.rb4
-rw-r--r--app/models/release.rb16
-rw-r--r--app/models/repository.rb43
-rw-r--r--app/models/repository_language.rb2
-rw-r--r--app/models/service.rb11
-rw-r--r--app/models/snippet.rb29
-rw-r--r--app/models/storage/hashed_project.rb4
-rw-r--r--app/models/storage/legacy_project.rb6
-rw-r--r--app/models/suggestion.rb1
-rw-r--r--app/models/system_note_metadata.rb1
-rw-r--r--app/models/todo.rb11
-rw-r--r--app/models/upload.rb54
-rw-r--r--app/models/user.rb70
-rw-r--r--app/models/wiki_page.rb6
-rw-r--r--app/policies/base_policy.rb12
-rw-r--r--app/policies/board_policy.rb4
-rw-r--r--app/policies/clusters/instance_policy.rb2
-rw-r--r--app/policies/deploy_keys_project_policy.rb10
-rw-r--r--app/policies/deployment_policy.rb14
-rw-r--r--app/policies/global_policy.rb7
-rw-r--r--app/policies/group_policy.rb15
-rw-r--r--app/policies/milestone_policy.rb2
-rw-r--r--app/policies/project_policy.rb3
-rw-r--r--app/policies/todo_policy.rb10
-rw-r--r--app/presenters/ci/build_runner_presenter.rb17
-rw-r--r--app/presenters/ci/pipeline_presenter.rb58
-rw-r--r--app/presenters/commit_presenter.rb19
-rw-r--r--app/presenters/issue_presenter.rb4
-rw-r--r--app/presenters/project_presenter.rb3
-rw-r--r--app/presenters/projects/settings/deploy_keys_presenter.rb2
-rw-r--r--app/presenters/todo_presenter.rb7
-rw-r--r--app/serializers/build_details_entity.rb24
-rw-r--r--app/serializers/build_trace_entity.rb17
-rw-r--r--app/serializers/build_trace_serializer.rb5
-rw-r--r--app/serializers/commit_entity.rb4
-rw-r--r--app/serializers/container_repository_entity.rb2
-rw-r--r--app/serializers/deploy_key_entity.rb3
-rw-r--r--app/serializers/diff_file_metadata_entity.rb10
-rw-r--r--app/serializers/diffs_entity.rb2
-rw-r--r--app/serializers/diffs_metadata_entity.rb6
-rw-r--r--app/serializers/diffs_metadata_serializer.rb5
-rw-r--r--app/serializers/evidences/evidence_entity.rb7
-rw-r--r--app/serializers/evidences/evidence_serializer.rb7
-rw-r--r--app/serializers/evidences/issue_entity.rb14
-rw-r--r--app/serializers/evidences/milestone_entity.rb14
-rw-r--r--app/serializers/evidences/project_entity.rb10
-rw-r--r--app/serializers/evidences/release_entity.rb13
-rw-r--r--app/serializers/evidences/release_serializer.rb7
-rw-r--r--app/serializers/paginated_diff_entity.rb50
-rw-r--r--app/serializers/paginated_diff_serializer.rb5
-rw-r--r--app/serializers/pipeline_details_entity.rb5
-rw-r--r--app/serializers/pipeline_serializer.rb6
-rw-r--r--app/serializers/projects/serverless/service_entity.rb2
-rw-r--r--app/serializers/test_report_entity.rb15
-rw-r--r--app/serializers/test_report_serializer.rb5
-rw-r--r--app/serializers/test_suite_entity.rb16
-rw-r--r--app/serializers/triggered_pipeline_entity.rb65
-rw-r--r--app/services/application_settings/update_service.rb29
-rw-r--r--app/services/boards/issues/list_service.rb6
-rw-r--r--app/services/boards/lists/create_service.rb6
-rw-r--r--app/services/boards/lists/list_service.rb2
-rw-r--r--app/services/boards/lists/update_service.rb16
-rw-r--r--app/services/bulk_push_event_payload_service.rb19
-rw-r--r--app/services/ci/pipeline_trigger_service.rb29
-rw-r--r--app/services/ci/process_pipeline_service.rb7
-rw-r--r--app/services/ci/retry_build_service.rb11
-rw-r--r--app/services/clusters/gcp/finalize_creation_service.rb8
-rw-r--r--app/services/clusters/gcp/provision_service.rb7
-rw-r--r--app/services/concerns/git/change_params.rb17
-rw-r--r--app/services/deployments/after_create_service.rb60
-rw-r--r--app/services/deployments/create_service.rb39
-rw-r--r--app/services/deployments/update_service.rb16
-rw-r--r--app/services/event_create_service.rb24
-rw-r--r--app/services/git/base_hooks_service.rb28
-rw-r--r--app/services/git/branch_hooks_service.rb16
-rw-r--r--app/services/git/branch_push_service.rb20
-rw-r--r--app/services/git/process_ref_changes_service.rb75
-rw-r--r--app/services/git/tag_hooks_service.rb6
-rw-r--r--app/services/git/tag_push_service.rb4
-rw-r--r--app/services/grafana/proxy_service.rb83
-rw-r--r--app/services/groups/create_service.rb7
-rw-r--r--app/services/groups/transfer_service.rb8
-rw-r--r--app/services/groups/update_service.rb16
-rw-r--r--app/services/import_export_clean_up_service.rb14
-rw-r--r--app/services/issuable/clone/content_rewriter.rb4
-rw-r--r--app/services/issues/close_service.rb4
-rw-r--r--app/services/issues/update_service.rb2
-rw-r--r--app/services/issues/zoom_link_service.rb22
-rw-r--r--app/services/merge_requests/post_merge_service.rb4
-rw-r--r--app/services/merge_requests/refresh_service.rb26
-rw-r--r--app/services/merge_requests/update_service.rb3
-rw-r--r--app/services/note_summary.rb4
-rw-r--r--app/services/notes/quick_actions_service.rb2
-rw-r--r--app/services/notes/update_service.rb2
-rw-r--r--app/services/notification_recipient_service.rb24
-rw-r--r--app/services/notification_service.rb9
-rw-r--r--app/services/projects/after_import_service.rb9
-rw-r--r--app/services/projects/container_repository/cleanup_tags_service.rb2
-rw-r--r--app/services/projects/container_repository/delete_tags_service.rb66
-rw-r--r--app/services/projects/create_from_template_service.rb9
-rw-r--r--app/services/projects/create_service.rb6
-rw-r--r--app/services/projects/destroy_service.rb4
-rw-r--r--app/services/projects/fork_service.rb1
-rw-r--r--app/services/projects/hashed_storage/base_repository_service.rb10
-rw-r--r--app/services/projects/hashed_storage/migrate_repository_service.rb1
-rw-r--r--app/services/projects/hashed_storage/rollback_repository_service.rb1
-rw-r--r--app/services/projects/import_export/export_service.rb38
-rw-r--r--app/services/projects/operations/update_service.rb12
-rw-r--r--app/services/projects/update_pages_service.rb1
-rw-r--r--app/services/search/snippet_service.rb12
-rw-r--r--app/services/spam_service.rb3
-rw-r--r--app/services/system_note_service.rb405
-rw-r--r--app/services/system_notes/base_service.rb30
-rw-r--r--app/services/system_notes/commit_service.rb112
-rw-r--r--app/services/system_notes/issuables_service.rb312
-rw-r--r--app/services/system_notes/zoom_service.rb13
-rw-r--r--app/services/update_deployment_service.rb57
-rw-r--r--app/uploaders/avatar_uploader.rb2
-rw-r--r--app/uploaders/gitlab_uploader.rb11
-rw-r--r--app/uploaders/object_storage.rb9
-rw-r--r--app/validators/named_ecdsa_key_validator.rb4
-rw-r--r--app/views/admin/application_settings/_ci_cd.html.haml2
-rw-r--r--app/views/admin/application_settings/_influx.html.haml2
-rw-r--r--app/views/admin/application_settings/_pages.html.haml2
-rw-r--r--app/views/admin/application_settings/_performance.html.haml10
-rw-r--r--app/views/admin/application_settings/_protected_paths.html.haml31
-rw-r--r--app/views/admin/application_settings/_repository_storage.html.haml27
-rw-r--r--app/views/admin/application_settings/_snowplow.html.haml2
-rw-r--r--app/views/admin/application_settings/_visibility_and_access.html.haml5
-rw-r--r--app/views/admin/application_settings/network.html.haml13
-rw-r--r--app/views/admin/applications/show.html.haml4
-rw-r--r--app/views/admin/dashboard/index.html.haml5
-rw-r--r--app/views/admin/impersonation_tokens/index.html.haml2
-rw-r--r--app/views/admin/labels/destroy.js.haml4
-rw-r--r--app/views/admin/labels/index.html.haml4
-rw-r--r--app/views/admin/projects/index.html.haml32
-rw-r--r--app/views/admin/runners/index.html.haml7
-rw-r--r--app/views/admin/sessions/_new_base.html.haml7
-rw-r--r--app/views/admin/sessions/_signin_box.html.haml11
-rw-r--r--app/views/admin/sessions/_tabs_normal.html.haml3
-rw-r--r--app/views/admin/sessions/new.html.haml15
-rw-r--r--app/views/admin/system_info/show.html.haml44
-rw-r--r--app/views/admin/users/_head.html.haml4
-rw-r--r--app/views/admin/users/_modals.html.haml30
-rw-r--r--app/views/admin/users/_user.html.haml37
-rw-r--r--app/views/admin/users/_user_activation_effects.html.haml6
-rw-r--r--app/views/admin/users/_user_block_effects.html.haml11
-rw-r--r--app/views/admin/users/_user_deactivation_effects.html.haml18
-rw-r--r--app/views/admin/users/_user_detail.html.haml2
-rw-r--r--app/views/admin/users/index.html.haml9
-rw-r--r--app/views/admin/users/show.html.haml48
-rw-r--r--app/views/ci/runner/_how_to_setup_runner.html.haml4
-rw-r--r--app/views/ci/runner/_how_to_setup_runner_automatically.html.haml22
-rw-r--r--app/views/ci/variables/_variable_row.html.haml2
-rw-r--r--app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml2
-rw-r--r--app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml4
-rw-r--r--app/views/clusters/clusters/eks/_index.html.haml3
-rw-r--r--app/views/clusters/clusters/gcp/_form.html.haml12
-rw-r--r--app/views/clusters/clusters/gcp/_gcp_not_configured.html.haml3
-rw-r--r--app/views/clusters/clusters/gcp/_signin_with_google_button.html.haml4
-rw-r--r--app/views/clusters/clusters/new.html.haml32
-rw-r--r--app/views/clusters/clusters/show.html.haml5
-rw-r--r--app/views/dashboard/projects/_blank_state_admin_welcome.html.haml61
-rw-r--r--app/views/dashboard/projects/_blank_state_welcome.html.haml72
-rw-r--r--app/views/dashboard/projects/_zero_authorized_projects.html.haml21
-rw-r--r--app/views/dashboard/snippets/index.html.haml4
-rw-r--r--app/views/devise/registrations/new.html.haml5
-rw-r--r--app/views/devise/sessions/new.html.haml3
-rw-r--r--app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml31
-rw-r--r--app/views/devise/shared/_sign_in_link.html.haml2
-rw-r--r--app/views/devise/shared/_signin_box.html.haml5
-rw-r--r--app/views/doorkeeper/applications/show.html.haml4
-rw-r--r--app/views/events/event/_push.html.haml12
-rw-r--r--app/views/groups/_activities.html.haml2
-rw-r--r--app/views/groups/_create_chat_team.html.haml2
-rw-r--r--app/views/groups/edit.html.haml4
-rw-r--r--app/views/groups/new.html.haml4
-rw-r--r--app/views/groups/projects.html.haml41
-rw-r--r--app/views/groups/registry/repositories/index.html.haml12
-rw-r--r--app/views/groups/runners/_group_runners.html.haml4
-rw-r--r--app/views/groups/settings/_advanced.html.haml24
-rw-r--r--app/views/groups/settings/ci_cd/_form.html.haml13
-rw-r--r--app/views/groups/settings/ci_cd/show.html.haml15
-rw-r--r--app/views/groups/show.html.haml2
-rw-r--r--app/views/groups/sidebar/_packages.html.haml16
-rw-r--r--app/views/help/instance_configuration/_ssh_info.html.haml27
-rw-r--r--app/views/help/show.html.haml2
-rw-r--r--app/views/help/ui.html.haml3
-rw-r--r--app/views/import/bitbucket/status.html.haml11
-rw-r--r--app/views/import/bitbucket_server/status.html.haml2
-rw-r--r--app/views/layouts/_flash.html.haml3
-rw-r--r--app/views/layouts/_head.html.haml12
-rw-r--r--app/views/layouts/_page.html.haml2
-rw-r--r--app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml26
-rw-r--r--app/views/layouts/header/_current_user_dropdown.html.haml18
-rw-r--r--app/views/layouts/header/_default.html.haml8
-rw-r--r--app/views/layouts/header/_help_dropdown.html.haml3
-rw-r--r--app/views/layouts/nav/_dashboard.html.haml77
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml10
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_profile.html.haml4
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml8
-rw-r--r--app/views/notify/new_release_email.html.haml18
-rw-r--r--app/views/notify/new_release_email.text.erb12
-rw-r--r--app/views/notify/pipeline_failed_email.html.haml2
-rw-r--r--app/views/notify/pipeline_failed_email.text.erb2
-rw-r--r--app/views/notify/pipeline_success_email.html.haml2
-rw-r--r--app/views/notify/pipeline_success_email.text.erb2
-rw-r--r--app/views/profiles/emails/index.html.haml8
-rw-r--r--app/views/profiles/notifications/_group_settings.html.haml2
-rw-r--r--app/views/profiles/passwords/edit.html.haml8
-rw-r--r--app/views/profiles/show.html.haml1
-rw-r--r--app/views/projects/_export.html.haml8
-rw-r--r--app/views/projects/_new_project_push_tip.html.haml2
-rw-r--r--app/views/projects/artifacts/_artifact.html.haml61
-rw-r--r--app/views/projects/artifacts/_table.html.haml16
-rw-r--r--app/views/projects/artifacts/index.html.haml10
-rw-r--r--app/views/projects/blob/_editor.html.haml8
-rw-r--r--app/views/projects/blob/_template_selectors.html.haml17
-rw-r--r--app/views/projects/blob/edit.html.haml1
-rw-r--r--app/views/projects/blob/new.html.haml1
-rw-r--r--app/views/projects/blob/show.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_audio.html.haml2
-rw-r--r--app/views/projects/branches/_branch.html.haml3
-rw-r--r--app/views/projects/buttons/_clone.html.haml4
-rw-r--r--app/views/projects/commit/_commit_box.html.haml2
-rw-r--r--app/views/projects/commits/_commit.html.haml7
-rw-r--r--app/views/projects/cycle_analytics/_overview.html.haml2
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml2
-rw-r--r--app/views/projects/deploy_tokens/_new_deploy_token.html.haml4
-rw-r--r--app/views/projects/deployments/_deployment.html.haml38
-rw-r--r--app/views/projects/deployments/_rollback.haml2
-rw-r--r--app/views/projects/diffs/_diffs.html.haml2
-rw-r--r--app/views/projects/diffs/_stats.html.haml4
-rw-r--r--app/views/projects/environments/show.html.haml9
-rw-r--r--app/views/projects/find_file/show.html.haml8
-rw-r--r--app/views/projects/issues/_issue.html.haml5
-rw-r--r--app/views/projects/issues/import_csv/_button.html.haml2
-rw-r--r--app/views/projects/issues/index.html.haml3
-rw-r--r--app/views/projects/merge_requests/_how_to_merge.html.haml6
-rw-r--r--app/views/projects/merge_requests/show.html.haml1
-rw-r--r--app/views/projects/mirrors/_mirror_repos.html.haml6
-rw-r--r--app/views/projects/mirrors/_ssh_host_keys.html.haml4
-rw-r--r--app/views/projects/notes/_more_actions_dropdown.html.haml2
-rw-r--r--app/views/projects/pages/_access.html.haml6
-rw-r--r--app/views/projects/pages/_destroy.haml10
-rw-r--r--app/views/projects/pages/_https_only.html.haml5
-rw-r--r--app/views/projects/pages/_list.html.haml19
-rw-r--r--app/views/projects/pages/_no_domains.html.haml5
-rw-r--r--app/views/projects/pages/_use.html.haml10
-rw-r--r--app/views/projects/pages/show.html.haml11
-rw-r--r--app/views/projects/pipelines/_info.html.haml10
-rw-r--r--app/views/projects/protected_branches/shared/_branches_list.html.haml17
-rw-r--r--app/views/projects/protected_branches/shared/_create_protected_branch.html.haml19
-rw-r--r--app/views/projects/protected_branches/shared/_protected_branch.html.haml21
-rw-r--r--app/views/projects/registry/repositories/index.html.haml5
-rw-r--r--app/views/projects/releases/edit.html.haml3
-rw-r--r--app/views/projects/runners/_specific_runners.html.haml25
-rw-r--r--app/views/projects/settings/ci_cd/_autodevops_form.html.haml4
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml13
-rw-r--r--app/views/projects/tags/_tag.html.haml12
-rw-r--r--app/views/projects/tags/new.html.haml2
-rw-r--r--app/views/projects/tree/_tree_header.html.haml13
-rw-r--r--app/views/projects/triggers/_trigger.html.haml2
-rw-r--r--app/views/registrations/welcome.html.haml17
-rw-r--r--app/views/search/_form.html.haml1
-rw-r--r--app/views/search/results/_empty.html.haml3
-rw-r--r--app/views/search/show.html.haml3
-rw-r--r--app/views/shared/_allow_request_access.html.haml4
-rw-r--r--app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml2
-rw-r--r--app/views/shared/_clone_panel.html.haml2
-rw-r--r--app/views/shared/_confirm_fork_modal.html.haml12
-rw-r--r--app/views/shared/_confirm_modal.html.haml5
-rw-r--r--app/views/shared/_event_filter.html.haml4
-rw-r--r--app/views/shared/_group_form.html.haml4
-rw-r--r--app/views/shared/_personal_access_tokens_created_container.html.haml2
-rw-r--r--app/views/shared/_remote_mirror_update_button.html.haml2
-rw-r--r--app/views/shared/_user_dropdown_contributing_link.html.haml5
-rw-r--r--app/views/shared/boards/_switcher.html.haml2
-rw-r--r--app/views/shared/deploy_keys/_form.html.haml2
-rw-r--r--app/views/shared/empty_states/_issues.html.haml3
-rw-r--r--app/views/shared/form_elements/_apply_template_warning.html.haml14
-rw-r--r--app/views/shared/issuable/_assignees.html.haml2
-rw-r--r--app/views/shared/issuable/_board_create_list_dropdown.html.haml2
-rw-r--r--app/views/shared/issuable/_feed_buttons.html.haml6
-rw-r--r--app/views/shared/issuable/_form.html.haml1
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml7
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml330
-rw-r--r--app/views/shared/issuable/_sidebar_assignees.html.haml2
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml4
-rw-r--r--app/views/shared/projects/_project.html.haml10
-rw-r--r--app/views/shared/snippets/_header.html.haml2
-rw-r--r--app/workers/admin_email_worker.rb2
-rw-r--r--app/workers/all_queues.yml3
-rw-r--r--app/workers/authorized_projects_worker.rb2
-rw-r--r--app/workers/auto_merge_process_worker.rb1
-rw-r--r--app/workers/background_migration_worker.rb2
-rw-r--r--app/workers/build_hooks_worker.rb1
-rw-r--r--app/workers/build_queue_worker.rb1
-rw-r--r--app/workers/chat_notification_worker.rb2
-rw-r--r--app/workers/ci/archive_traces_cron_worker.rb2
-rw-r--r--app/workers/ci/build_prepare_worker.rb1
-rw-r--r--app/workers/ci/build_schedule_worker.rb1
-rw-r--r--app/workers/cleanup_container_repository_worker.rb1
-rw-r--r--app/workers/concerns/application_worker.rb1
-rw-r--r--app/workers/concerns/auto_devops_queue.rb1
-rw-r--r--app/workers/concerns/chaos_queue.rb1
-rw-r--r--app/workers/concerns/cluster_queue.rb1
-rw-r--r--app/workers/concerns/gitlab/github_import/object_importer.rb2
-rw-r--r--app/workers/concerns/gitlab/github_import/queue.rb1
-rw-r--r--app/workers/concerns/object_pool_queue.rb1
-rw-r--r--app/workers/concerns/pipeline_background_queue.rb1
-rw-r--r--app/workers/concerns/pipeline_queue.rb1
-rw-r--r--app/workers/concerns/repository_check_queue.rb2
-rw-r--r--app/workers/concerns/todos_destroyer_queue.rb1
-rw-r--r--app/workers/create_evidence_worker.rb14
-rw-r--r--app/workers/create_gpg_signature_worker.rb2
-rw-r--r--app/workers/create_note_diff_file_worker.rb2
-rw-r--r--app/workers/create_pipeline_worker.rb1
-rw-r--r--app/workers/delete_container_repository_worker.rb1
-rw-r--r--app/workers/delete_diff_files_worker.rb2
-rw-r--r--app/workers/delete_merged_branches_worker.rb2
-rw-r--r--app/workers/delete_stored_files_worker.rb2
-rw-r--r--app/workers/delete_user_worker.rb2
-rw-r--r--app/workers/deployments/finished_worker.rb1
-rw-r--r--app/workers/deployments/success_worker.rb3
-rw-r--r--app/workers/detect_repository_languages_worker.rb1
-rw-r--r--app/workers/email_receiver_worker.rb2
-rw-r--r--app/workers/emails_on_push_worker.rb2
-rw-r--r--app/workers/expire_build_artifacts_worker.rb2
-rw-r--r--app/workers/expire_build_instance_artifacts_worker.rb2
-rw-r--r--app/workers/git_garbage_collect_worker.rb1
-rw-r--r--app/workers/gitlab/github_import/advance_stage_worker.rb1
-rw-r--r--app/workers/gitlab/github_import/stage/finish_import_worker.rb4
-rw-r--r--app/workers/gitlab/github_import/stage/import_repository_worker.rb4
-rw-r--r--app/workers/gitlab_shell_worker.rb2
-rw-r--r--app/workers/gitlab_usage_ping_worker.rb2
-rw-r--r--app/workers/group_destroy_worker.rb2
-rw-r--r--app/workers/hashed_storage/base_worker.rb3
-rw-r--r--app/workers/hashed_storage/migrator_worker.rb1
-rw-r--r--app/workers/hashed_storage/rollbacker_worker.rb1
-rw-r--r--app/workers/import_export_project_cleanup_worker.rb2
-rw-r--r--app/workers/import_issues_csv_worker.rb4
-rw-r--r--app/workers/invalid_gpg_signature_update_worker.rb2
-rw-r--r--app/workers/irker_worker.rb2
-rw-r--r--app/workers/issue_due_scheduler_worker.rb2
-rw-r--r--app/workers/mail_scheduler/issue_due_worker.rb2
-rw-r--r--app/workers/mail_scheduler/notification_service_worker.rb2
-rw-r--r--app/workers/merge_worker.rb2
-rw-r--r--app/workers/migrate_external_diffs_worker.rb4
-rw-r--r--app/workers/namespaceless_project_destroy_worker.rb4
-rw-r--r--app/workers/namespaces/prune_aggregation_schedules_worker.rb2
-rw-r--r--app/workers/namespaces/root_statistics_worker.rb1
-rw-r--r--app/workers/namespaces/schedule_aggregation_worker.rb1
-rw-r--r--app/workers/new_issue_worker.rb2
-rw-r--r--app/workers/new_merge_request_worker.rb2
-rw-r--r--app/workers/new_note_worker.rb2
-rw-r--r--app/workers/new_release_worker.rb15
-rw-r--r--app/workers/object_storage/background_move_worker.rb3
-rw-r--r--app/workers/object_storage/migrate_uploads_worker.rb4
-rw-r--r--app/workers/pages_domain_removal_cron_worker.rb2
-rw-r--r--app/workers/pages_domain_ssl_renewal_cron_worker.rb2
-rw-r--r--app/workers/pages_domain_ssl_renewal_worker.rb2
-rw-r--r--app/workers/pages_domain_verification_cron_worker.rb2
-rw-r--r--app/workers/pages_domain_verification_worker.rb2
-rw-r--r--app/workers/pages_worker.rb1
-rw-r--r--app/workers/pipeline_process_worker.rb1
-rw-r--r--app/workers/pipeline_schedule_worker.rb2
-rw-r--r--app/workers/plugin_worker.rb1
-rw-r--r--app/workers/post_receive.rb83
-rw-r--r--app/workers/process_commit_worker.rb2
-rw-r--r--app/workers/project_cache_worker.rb2
-rw-r--r--app/workers/project_daily_statistics_worker.rb2
-rw-r--r--app/workers/project_destroy_worker.rb2
-rw-r--r--app/workers/project_export_worker.rb1
-rw-r--r--app/workers/project_service_worker.rb1
-rw-r--r--app/workers/propagate_service_template_worker.rb2
-rw-r--r--app/workers/prune_old_events_worker.rb6
-rw-r--r--app/workers/prune_web_hook_logs_worker.rb2
-rw-r--r--app/workers/reactive_caching_worker.rb2
-rw-r--r--app/workers/rebase_worker.rb2
-rw-r--r--app/workers/remote_mirror_notification_worker.rb2
-rw-r--r--app/workers/remove_expired_group_links_worker.rb2
-rw-r--r--app/workers/remove_expired_members_worker.rb2
-rw-r--r--app/workers/remove_unreferenced_lfs_objects_worker.rb2
-rw-r--r--app/workers/repository_archive_cache_worker.rb2
-rw-r--r--app/workers/repository_check/dispatch_worker.rb2
-rw-r--r--app/workers/repository_cleanup_worker.rb1
-rw-r--r--app/workers/repository_fork_worker.rb2
-rw-r--r--app/workers/repository_import_worker.rb6
-rw-r--r--app/workers/repository_remove_remote_worker.rb2
-rw-r--r--app/workers/repository_update_remote_mirror_worker.rb1
-rw-r--r--app/workers/requests_profiles_worker.rb2
-rw-r--r--app/workers/run_pipeline_schedule_worker.rb1
-rw-r--r--app/workers/schedule_migrate_external_diffs_worker.rb4
-rw-r--r--app/workers/stuck_ci_jobs_worker.rb2
-rw-r--r--app/workers/stuck_import_jobs_worker.rb2
-rw-r--r--app/workers/stuck_merge_jobs_worker.rb4
-rw-r--r--app/workers/system_hook_push_worker.rb2
-rw-r--r--app/workers/trending_projects_worker.rb2
-rw-r--r--app/workers/update_external_pull_requests_worker.rb2
-rw-r--r--app/workers/update_head_pipeline_for_merge_request_worker.rb1
-rw-r--r--app/workers/update_merge_requests_worker.rb2
-rw-r--r--app/workers/update_project_statistics_worker.rb3
-rw-r--r--app/workers/upload_checksum_worker.rb2
-rw-r--r--app/workers/web_hook_worker.rb1
1005 files changed, 12844 insertions, 4473 deletions
diff --git a/app/assets/images/ci_favicons/canary/favicon_status_preparing.ico b/app/assets/images/ci_favicons/canary/favicon_status_preparing.ico
new file mode 100644
index 00000000000..6cdf3ae2e36
--- /dev/null
+++ b/app/assets/images/ci_favicons/canary/favicon_status_preparing.ico
Binary files differ
diff --git a/app/assets/images/ci_favicons/favicon_status_preparing.png b/app/assets/images/ci_favicons/favicon_status_preparing.png
new file mode 100644
index 00000000000..f81baa0ece3
--- /dev/null
+++ b/app/assets/images/ci_favicons/favicon_status_preparing.png
Binary files differ
diff --git a/app/assets/javascripts/analytics/cycle_analytics/mixins/add_stage_mixin.js b/app/assets/javascripts/analytics/cycle_analytics/mixins/add_stage_mixin.js
deleted file mode 100644
index 6a40f1cbc5e..00000000000
--- a/app/assets/javascripts/analytics/cycle_analytics/mixins/add_stage_mixin.js
+++ /dev/null
@@ -1,11 +0,0 @@
-export default {
- data() {
- return {
- isCustomStageForm: false,
- };
- },
- methods: {
- showAddStageForm: () => {},
- hideAddStageForm: () => {},
- },
-};
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 992c5e5e330..908dc730aa4 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -36,6 +36,7 @@ const Api = {
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches',
releasesPath: '/api/:version/projects/:id/releases',
+ releasePath: '/api/:version/projects/:id/releases/:tag_name',
mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines',
adminStatisticsPath: 'api/:version/application/statistics',
@@ -74,6 +75,11 @@ const Api = {
});
},
+ groupLabels(namespace) {
+ const url = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespace);
+ return axios.get(url).then(({ data }) => data);
+ },
+
// Return namespaces list. Filtered by query
namespaces(query, callback) {
const url = Api.buildUrl(Api.namespacesPath);
@@ -386,6 +392,22 @@ const Api = {
return axios.get(url);
},
+ release(projectPath, tagName) {
+ const url = Api.buildUrl(this.releasePath)
+ .replace(':id', encodeURIComponent(projectPath))
+ .replace(':tag_name', encodeURIComponent(tagName));
+
+ return axios.get(url);
+ },
+
+ updateRelease(projectPath, tagName, release) {
+ const url = Api.buildUrl(this.releasePath)
+ .replace(':id', encodeURIComponent(projectPath))
+ .replace(':tag_name', encodeURIComponent(tagName));
+
+ return axios.put(url, release);
+ },
+
adminStatistics() {
const url = Api.buildUrl(this.adminStatisticsPath);
return axios.get(url);
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js
index e8c59fab609..7652b67ae1e 100644
--- a/app/assets/javascripts/autosave.js
+++ b/app/assets/javascripts/autosave.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-param-reassign, prefer-template, no-void, consistent-return */
+/* eslint-disable no-param-reassign, no-void, consistent-return */
import AccessorUtilities from './lib/utils/accessor';
@@ -10,7 +10,7 @@ export default class Autosave {
if (key.join != null) {
key = key.join('/');
}
- this.key = 'autosave/' + key;
+ this.key = `autosave/${key}`;
this.field.data('autosave', this);
this.restore();
this.field.on('input', () => this.save());
diff --git a/app/assets/javascripts/badges/components/badge_settings.vue b/app/assets/javascripts/badges/components/badge_settings.vue
index 75a522efe7e..531f84ad272 100644
--- a/app/assets/javascripts/badges/components/badge_settings.vue
+++ b/app/assets/javascripts/badges/components/badge_settings.vue
@@ -2,7 +2,7 @@
import { mapState, mapActions } from 'vuex';
import createFlash from '~/flash';
import { s__ } from '~/locale';
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import Badge from './badge.vue';
import BadgeForm from './badge_form.vue';
import BadgeList from './badge_list.vue';
@@ -13,7 +13,7 @@ export default {
Badge,
BadgeForm,
BadgeList,
- GlModal,
+ GlModal: DeprecatedModal2,
},
computed: {
...mapState(['badgeInModal', 'isEditing']),
diff --git a/app/assets/javascripts/behaviors/markdown/editor_extensions.js b/app/assets/javascripts/behaviors/markdown/editor_extensions.js
index 47e5fc65c48..8bd2145db1c 100644
--- a/app/assets/javascripts/behaviors/markdown/editor_extensions.js
+++ b/app/assets/javascripts/behaviors/markdown/editor_extensions.js
@@ -21,6 +21,7 @@ import Reference from './nodes/reference';
import TableOfContents from './nodes/table_of_contents';
import Video from './nodes/video';
+import Audio from './nodes/audio';
import BulletList from './nodes/bullet_list';
import OrderedList from './nodes/ordered_list';
@@ -78,6 +79,7 @@ export default [
new TableOfContents(),
new Video(),
+ new Audio(),
new BulletList(),
new OrderedList(),
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/audio.js b/app/assets/javascripts/behaviors/markdown/nodes/audio.js
new file mode 100644
index 00000000000..48ac408cf24
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/audio.js
@@ -0,0 +1,53 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::AudioLinkFilter
+export default class Audio extends Node {
+ get name() {
+ return 'audio';
+ }
+
+ get schema() {
+ return {
+ attrs: {
+ src: {},
+ alt: {
+ default: null,
+ },
+ },
+ group: 'block',
+ draggable: true,
+ parseDOM: [
+ {
+ tag: '.audio-container',
+ skip: true,
+ },
+ {
+ tag: '.audio-container p',
+ priority: 51,
+ ignore: true,
+ },
+ {
+ tag: 'audio[src]',
+ getAttrs: el => ({ src: el.getAttribute('src'), alt: el.dataset.title }),
+ },
+ ],
+ toDOM: node => [
+ 'audio',
+ {
+ src: node.attrs.src,
+ controls: true,
+ 'data-setup': '{}',
+ 'data-title': node.attrs.alt,
+ },
+ ],
+ };
+ }
+
+ toMarkdown(state, node) {
+ defaultMarkdownSerializer.nodes.image(state, node);
+ state.closeBlock(node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js
index 1909830e9ed..a07942d87cb 100644
--- a/app/assets/javascripts/behaviors/preview_markdown.js
+++ b/app/assets/javascripts/behaviors/preview_markdown.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, prefer-arrow-callback */
+/* eslint-disable func-names, no-var */
import $ from 'jquery';
import axios from '~/lib/utils/axios_utils';
@@ -45,26 +45,22 @@ MarkdownPreview.prototype.showPreview = function($form) {
this.hideReferencedUsers($form);
} else {
preview.addClass('md-preview-loading').text(__('Loading...'));
- this.fetchMarkdownPreview(
- mdText,
- url,
- function(response) {
- var body;
- if (response.body.length > 0) {
- ({ body } = response);
- } else {
- body = this.emptyMessage;
- }
-
- preview.removeClass('md-preview-loading').html(body);
- preview.renderGFM();
- this.renderReferencedUsers(response.references.users, $form);
-
- if (response.references.commands) {
- this.renderReferencedCommands(response.references.commands, $form);
- }
- }.bind(this),
- );
+ this.fetchMarkdownPreview(mdText, url, response => {
+ var body;
+ if (response.body.length > 0) {
+ ({ body } = response);
+ } else {
+ body = this.emptyMessage;
+ }
+
+ preview.removeClass('md-preview-loading').html(body);
+ preview.renderGFM();
+ this.renderReferencedUsers(response.references.users, $form);
+
+ if (response.references.commands) {
+ this.renderReferencedCommands(response.references.commands, $form);
+ }
+ });
}
};
@@ -132,12 +128,12 @@ const markdownToolbar = $('.md-header-toolbar');
$.fn.setupMarkdownPreview = function() {
var $form = $(this);
- $form.find('textarea.markdown-area').on('input', function() {
+ $form.find('textarea.markdown-area').on('input', () => {
markdownPreview.hideReferencedUsers($form);
});
};
-$(document).on('markdown-preview:show', function(e, $form) {
+$(document).on('markdown-preview:show', (e, $form) => {
if (!$form) {
return;
}
@@ -162,7 +158,7 @@ $(document).on('markdown-preview:show', function(e, $form) {
markdownPreview.showPreview($form);
});
-$(document).on('markdown-preview:hide', function(e, $form) {
+$(document).on('markdown-preview:hide', (e, $form) => {
if (!$form) {
return;
}
@@ -191,7 +187,7 @@ $(document).on('markdown-preview:hide', function(e, $form) {
markdownPreview.hideReferencedCommands($form);
});
-$(document).on('markdown-preview:toggle', function(e, keyboardEvent) {
+$(document).on('markdown-preview:toggle', (e, keyboardEvent) => {
var $target;
$target = $(keyboardEvent.target);
if ($target.is('textarea.markdown-area')) {
diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js
index d8056e48d4e..7cf18d1fd83 100644
--- a/app/assets/javascripts/behaviors/requires_input.js
+++ b/app/assets/javascripts/behaviors/requires_input.js
@@ -26,7 +26,7 @@ $.fn.requiresInput = function requiresInput() {
const values = _.map($(fieldSelector, $form), field => field.value);
// Disable the button if any required fields are empty
- if (values.length && _.any(values, _.isEmpty)) {
+ if (values.length && _.some(values, _.isEmpty)) {
$button.disable();
} else {
$button.enable();
diff --git a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js
index 75777b910ca..87c8568802e 100644
--- a/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js
+++ b/app/assets/javascripts/blob/balsamiq/balsamiq_viewer.js
@@ -1,5 +1,7 @@
import sqljs from 'sql.js';
import { template as _template } from 'underscore';
+import axios from '~/lib/utils/axios_utils';
+import { successCodes } from '~/lib/utils/http_status';
const PREVIEW_TEMPLATE = _template(`
<div class="card">
@@ -16,30 +18,25 @@ class BalsamiqViewer {
}
loadFile(endpoint) {
- return new Promise((resolve, reject) => {
- const xhr = new XMLHttpRequest();
-
- xhr.open('GET', endpoint, true);
- xhr.responseType = 'arraybuffer';
- xhr.onload = loadEvent => this.fileLoaded(loadEvent, resolve, reject);
- xhr.onerror = reject;
-
- xhr.send();
- });
- }
-
- fileLoaded(loadEvent, resolve, reject) {
- if (loadEvent.target.status !== 200) return reject();
-
- this.renderFile(loadEvent);
-
- return resolve();
+ return axios
+ .get(endpoint, {
+ responseType: 'arraybuffer',
+ validateStatus(status) {
+ return status !== successCodes.OK;
+ },
+ })
+ .then(({ data }) => {
+ this.renderFile(data);
+ })
+ .catch(e => {
+ throw new Error(e);
+ });
}
- renderFile(loadEvent) {
+ renderFile(fileBuffer) {
const container = document.createElement('ul');
- this.initDatabase(loadEvent.target.response);
+ this.initDatabase(fileBuffer);
const previews = this.getPreviews();
previews.forEach(preview => {
diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js
index 9f0680cc6a7..8acf0827c44 100644
--- a/app/assets/javascripts/blob/blob_file_dropzone.js
+++ b/app/assets/javascripts/blob/blob_file_dropzone.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, prefer-arrow-callback */
+/* eslint-disable func-names */
import $ from 'jquery';
import Dropzone from 'dropzone';
@@ -43,18 +43,18 @@ export default class BlobFileDropzone {
previewsContainer: '.dropzone-previews',
headers: csrf.headers,
init() {
- this.on('addedfile', function() {
+ this.on('addedfile', () => {
toggleLoading(submitButton, submitButtonLoadingIcon, false);
dropzoneMessage.addClass(HIDDEN_CLASS);
$('.dropzone-alerts')
.html('')
.hide();
});
- this.on('removedfile', function() {
+ this.on('removedfile', () => {
toggleLoading(submitButton, submitButtonLoadingIcon, false);
dropzoneMessage.removeClass(HIDDEN_CLASS);
});
- this.on('success', function(header, response) {
+ this.on('success', (header, response) => {
$('#modal-upload-blob').modal('hide');
visitUrl(response.filePath);
});
@@ -62,7 +62,7 @@ export default class BlobFileDropzone {
dropzoneMessage.addClass(HIDDEN_CLASS);
this.removeFile(file);
});
- this.on('sending', function(file, xhr, formData) {
+ this.on('sending', (file, xhr, formData) => {
formData.append('branch_name', form.find('.js-branch-name').val());
formData.append('create_merge_request', form.find('.js-create-merge-request').val());
formData.append('commit_message', form.find('.js-commit-message').val());
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js
index 106fe2e0cef..b371f6be268 100644
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ b/app/assets/javascripts/blob/file_template_mediator.js
@@ -7,6 +7,8 @@ import BlobCiYamlSelector from './template_selectors/ci_yaml_selector';
import DockerfileSelector from './template_selectors/dockerfile_selector';
import GitignoreSelector from './template_selectors/gitignore_selector';
import LicenseSelector from './template_selectors/license_selector';
+import toast from '~/vue_shared/plugins/global_toast';
+import { __ } from '~/locale';
export default class FileTemplateMediator {
constructor({ editor, currentAction, projectId }) {
@@ -19,6 +21,7 @@ export default class FileTemplateMediator {
this.initDomElements();
this.initDropdowns();
this.initPageEvents();
+ this.cacheFileContents();
}
initTemplateSelectors() {
@@ -40,6 +43,7 @@ export default class FileTemplateMediator {
return {
name: cfg.name,
key: cfg.key,
+ id: cfg.key,
};
}),
});
@@ -58,6 +62,7 @@ export default class FileTemplateMediator {
this.$fileContent = $fileEditor.find('#file-content');
this.$commitForm = $fileEditor.find('form');
this.$navLinks = $fileEditor.find('.nav-links');
+ this.$templateTypes = this.$templateSelectors.find('.template-type-selector');
}
initDropdowns() {
@@ -113,7 +118,11 @@ export default class FileTemplateMediator {
}
});
- this.typeSelector.setToggleText(item.name);
+ this.setFilename(item.name);
+
+ if (this.editor.getValue() !== '') {
+ this.setTypeSelectorToggleText(item.name);
+ }
this.cacheToggleText();
}
@@ -123,15 +132,24 @@ export default class FileTemplateMediator {
}
selectTemplateFile(selector, query, data) {
+ const self = this;
+
selector.renderLoading();
- // in case undo menu is already there
- this.destroyUndoMenu();
+
this.fetchFileTemplate(selector.config.type, query, data)
.then(file => {
- this.showUndoMenu();
this.setEditorContent(file);
- this.setFilename(selector.config.name);
selector.renderLoaded();
+ this.typeSelector.setToggleText(selector.config.name);
+ toast(__(`${query} template applied`), {
+ action: {
+ text: __('Undo'),
+ onClick: (e, toastObj) => {
+ self.restoreFromCache();
+ toastObj.goAway(0);
+ },
+ },
+ });
})
.catch(err => new Flash(`An error occurred while fetching the template: ${err}`));
}
@@ -173,22 +191,6 @@ export default class FileTemplateMediator {
return this.templateSelectors.find(selector => selector.config.key === key);
}
- showUndoMenu() {
- this.$undoMenu.removeClass('hidden');
-
- this.$undoBtn.on('click', () => {
- this.restoreFromCache();
- this.destroyUndoMenu();
- });
- }
-
- destroyUndoMenu() {
- this.cacheFileContents();
- this.cacheToggleText();
- this.$undoMenu.addClass('hidden');
- this.$undoBtn.off('click');
- }
-
hideTemplateSelectorMenu() {
this.$templatesMenu.hide();
}
@@ -210,6 +212,7 @@ export default class FileTemplateMediator {
this.setEditorContent(this.cachedContent);
this.setFilename(this.cachedFilename);
this.setTemplateSelectorToggleText();
+ this.setTypeSelectorToggleText(__('Select a template type'));
}
getTemplateSelectorToggleText() {
@@ -228,6 +231,10 @@ export default class FileTemplateMediator {
return this.typeSelector.getToggleText();
}
+ setTypeSelectorToggleText(text) {
+ this.typeSelector.setToggleText(text);
+ }
+
getFilename() {
return this.$filenameInput.val();
}
diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js
index 9e69c7d7164..b0de4dc8628 100644
--- a/app/assets/javascripts/blob/template_selector.js
+++ b/app/assets/javascripts/blob/template_selector.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this */
import $ from 'jquery';
+import '~/gl_dropdown';
export default class TemplateSelector {
constructor({ dropdown, data, pattern, wrapper, editor, $input } = {}) {
@@ -26,11 +27,16 @@ export default class TemplateSelector {
search: {
fields: ['name'],
},
- clicked: options => this.fetchFileTemplate(options),
+ clicked: options => this.onDropdownClicked(options),
text: item => item.name,
});
}
+ // Subclasses can override this method to conditionally prevent fetching file templates
+ onDropdownClicked(options) {
+ this.fetchFileTemplate(options);
+ }
+
initAutosizeUpdateEvent() {
this.autosizeUpdateEvent = document.createEvent('Event');
this.autosizeUpdateEvent.initEvent('autosize:update', true, false);
@@ -77,9 +83,14 @@ export default class TemplateSelector {
if (this.editor instanceof $) {
this.editor.get(0).dispatchEvent(this.autosizeUpdateEvent);
+ this.editor.trigger('input');
}
}
+ getEditorContent() {
+ return this.editor.getValue();
+ }
+
startLoadingSpinner() {
this.$dropdownIcon.addClass('fa-spinner fa-spin').removeClass('fa-chevron-down');
}
diff --git a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js
index 43f7aead8b9..d819452df68 100644
--- a/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/ci_yaml_selector.js
@@ -19,7 +19,6 @@ export default class BlobCiYamlSelector extends FileTemplateSelector {
data: this.$dropdown.data('data'),
filterable: true,
selectable: true,
- toggleLabel: item => item.name,
search: {
fields: ['name'],
},
diff --git a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
index 659d57e6a6f..7d5e98889d3 100644
--- a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
@@ -20,7 +20,6 @@ export default class DockerfileSelector extends FileTemplateSelector {
data: this.$dropdown.data('data'),
filterable: true,
selectable: true,
- toggleLabel: item => item.name,
search: {
fields: ['name'],
},
diff --git a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js
index a8067ec5c84..39a8937641d 100644
--- a/app/assets/javascripts/blob/template_selectors/gitignore_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/gitignore_selector.js
@@ -18,7 +18,6 @@ export default class BlobGitignoreSelector extends FileTemplateSelector {
data: this.$dropdown.data('data'),
filterable: true,
selectable: true,
- toggleLabel: item => item.name,
search: {
fields: ['name'],
},
diff --git a/app/assets/javascripts/blob/template_selectors/license_selector.js b/app/assets/javascripts/blob/template_selectors/license_selector.js
index d01ab9257d6..f4041835a7d 100644
--- a/app/assets/javascripts/blob/template_selectors/license_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/license_selector.js
@@ -18,7 +18,6 @@ export default class BlobLicenseSelector extends FileTemplateSelector {
data: this.$dropdown.data('data'),
filterable: true,
selectable: true,
- toggleLabel: item => item.name,
search: {
fields: ['name'],
},
diff --git a/app/assets/javascripts/blob/template_selectors/type_selector.js b/app/assets/javascripts/blob/template_selectors/type_selector.js
index db3c144cbe3..cb4e1aaa9ac 100644
--- a/app/assets/javascripts/blob/template_selectors/type_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/type_selector.js
@@ -16,7 +16,6 @@ export default class FileTemplateTypeSelector extends FileTemplateSelector {
data: this.config.dropdownData,
filterable: false,
selectable: true,
- toggleLabel: item => item.name,
clicked: options => this.mediator.selectTemplateTypeOptions(options),
text: item => item.name,
});
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index 9ea455069f3..07e4dde41d9 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -107,18 +107,18 @@ export default class BlobViewer {
toggleCopyButtonState() {
if (!this.copySourceBtn) return;
if (this.simpleViewer.getAttribute('data-loaded')) {
- this.copySourceBtn.setAttribute('title', __('Copy source to clipboard'));
+ this.copySourceBtn.setAttribute('title', __('Copy file contents'));
this.copySourceBtn.classList.remove('disabled');
} else if (this.activeViewer === this.simpleViewer) {
this.copySourceBtn.setAttribute(
'title',
- __('Wait for the source to load to copy it to the clipboard'),
+ __('Wait for the file to load to copy its contents'),
);
this.copySourceBtn.classList.add('disabled');
} else {
this.copySourceBtn.setAttribute(
'title',
- __('Switch to the source to copy it to the clipboard'),
+ __('Switch to the source to copy the file contents'),
);
this.copySourceBtn.classList.add('disabled');
}
diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue
index 9f26337d153..9a1da810ad0 100644
--- a/app/assets/javascripts/boards/components/board_blank_state.vue
+++ b/app/assets/javascripts/boards/components/board_blank_state.vue
@@ -29,25 +29,25 @@ export default {
});
});
+ const loadListIssues = listObj => {
+ const list = boardsStore.findList('title', listObj.title);
+
+ if (!list) {
+ return null;
+ }
+
+ list.id = listObj.id;
+ list.label.id = listObj.label.id;
+ return list.getIssues().catch(() => {
+ // TODO: handle request error
+ });
+ };
+
// Save the labels
boardsStore
.generateDefaultLists()
.then(res => res.data)
- .then(data => {
- data.forEach(listObj => {
- const list = boardsStore.findList('title', listObj.title);
-
- if (!list) {
- return;
- }
-
- list.id = listObj.id;
- list.label.id = listObj.label.id;
- list.getIssues().catch(() => {
- // TODO: handle request error
- });
- });
- })
+ .then(data => Promise.all(data.map(loadListIssues)))
.catch(() => {
boardsStore.removeList(undefined, 'label');
Cookies.remove('issue_board_welcome_hidden', {
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index faf722f61af..12d68256598 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -42,12 +42,19 @@ export default {
return {
showDetail: false,
detailIssue: boardsStore.detail,
+ multiSelect: boardsStore.multiSelect,
};
},
computed: {
issueDetailVisible() {
return this.detailIssue.issue && this.detailIssue.issue.id === this.issue.id;
},
+ multiSelectVisible() {
+ return this.multiSelect.list.findIndex(issue => issue.id === this.issue.id) > -1;
+ },
+ canMultiSelect() {
+ return gon.features && gon.features.multiSelectBoard;
+ },
},
methods: {
mouseDown() {
@@ -58,14 +65,20 @@ export default {
},
showIssue(e) {
if (e.target.classList.contains('js-no-trigger')) return;
-
if (this.showDetail) {
this.showDetail = false;
+ // If CMD or CTRL is clicked
+ const isMultiSelect = this.canMultiSelect && (e.ctrlKey || e.metaKey);
+
if (boardsStore.detail.issue && boardsStore.detail.issue.id === this.issue.id) {
- eventHub.$emit('clearDetailIssue');
+ eventHub.$emit('clearDetailIssue', isMultiSelect);
+
+ if (isMultiSelect) {
+ eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
+ }
} else {
- eventHub.$emit('newDetailIssue', this.issue);
+ eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
boardsStore.setListDetail(this.list);
}
}
@@ -77,6 +90,7 @@ export default {
<template>
<li
:class="{
+ 'multi-select': multiSelectVisible,
'user-can-drag': !disabled && issue.id,
'is-disabled': disabled || !issue.id,
'is-active': issueDetailVisible,
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index ebf48cee2ae..34560560756 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -194,6 +194,7 @@ export default {
ref="name"
v-model="board.name"
class="form-control"
+ data-qa-selector="board_name_field"
type="text"
:placeholder="__('Enter board name')"
@keyup.enter="submit"
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index de41698ca04..1273fcc6a91 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -1,12 +1,22 @@
<script>
-/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
-import Sortable from 'sortablejs';
+import { Sortable, MultiDrag } from 'sortablejs';
import { GlLoadingIcon } from '@gitlab/ui';
+import _ from 'underscore';
import boardNewIssue from './board_new_issue.vue';
import boardCard from './board_card.vue';
import eventHub from '../eventhub';
import boardsStore from '../stores/boards_store';
-import { getBoardSortableDefaultOptions, sortableStart } from '../mixins/sortable_default_options';
+import { sprintf, __ } from '~/locale';
+import createFlash from '~/flash';
+import {
+ getBoardSortableDefaultOptions,
+ sortableStart,
+ sortableEnd,
+} from '../mixins/sortable_default_options';
+
+if (gon.features && gon.features.multiSelectBoard) {
+ Sortable.mount(new MultiDrag());
+}
export default {
name: 'BoardList',
@@ -54,6 +64,14 @@ export default {
showIssueForm: false,
};
},
+ computed: {
+ paginatedIssueText() {
+ return sprintf(__('Showing %{pageSize} of %{total} issues'), {
+ pageSize: this.list.issues.length,
+ total: this.list.issuesSize,
+ });
+ },
+ },
watch: {
filters: {
handler() {
@@ -87,11 +105,20 @@ export default {
eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
},
mounted() {
+ const multiSelectOpts = {};
+ if (gon.features && gon.features.multiSelectBoard) {
+ multiSelectOpts.multiDrag = true;
+ multiSelectOpts.selectedClass = 'js-multi-select';
+ multiSelectOpts.animation = 500;
+ }
+
const options = getBoardSortableDefaultOptions({
scroll: true,
disabled: this.disabled,
filter: '.board-list-count, .is-disabled',
dataIdAttr: 'data-issue-id',
+ removeCloneOnHide: false,
+ ...multiSelectOpts,
group: {
name: 'issues',
/**
@@ -145,25 +172,66 @@ export default {
card.showDetail = false;
const { list } = card;
+
const issue = list.findIssue(Number(e.item.dataset.issueId));
+
boardsStore.startMoving(list, issue);
sortableStart();
},
onAdd: e => {
- boardsStore.moveIssueToList(
- boardsStore.moving.list,
- this.list,
- boardsStore.moving.issue,
- e.newIndex,
- );
+ const { items = [], newIndicies = [] } = e;
+ if (items.length) {
+ // Not using e.newIndex here instead taking a min of all
+ // the newIndicies. Basically we have to find that during
+ // a drop what is the index we're going to start putting
+ // all the dropped elements from.
+ const newIndex = Math.min(...newIndicies.map(obj => obj.index).filter(i => i !== -1));
+ const issues = items.map(item =>
+ boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
+ );
- this.$nextTick(() => {
- e.item.remove();
- });
+ boardsStore.moveMultipleIssuesToList({
+ listFrom: boardsStore.moving.list,
+ listTo: this.list,
+ issues,
+ newIndex,
+ });
+ } else {
+ boardsStore.moveIssueToList(
+ boardsStore.moving.list,
+ this.list,
+ boardsStore.moving.issue,
+ e.newIndex,
+ );
+ this.$nextTick(() => {
+ e.item.remove();
+ });
+ }
},
onUpdate: e => {
const sortedArray = this.sortable.toArray().filter(id => id !== '-1');
+
+ const { items = [], newIndicies = [], oldIndicies = [] } = e;
+ if (items.length) {
+ const newIndex = Math.min(...newIndicies.map(obj => obj.index));
+ const issues = items.map(item =>
+ boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
+ );
+ boardsStore.moveMultipleIssuesInList({
+ list: this.list,
+ issues,
+ oldIndicies: oldIndicies.map(obj => obj.index),
+ newIndex,
+ idArray: sortedArray,
+ });
+ e.items.forEach(el => {
+ Sortable.utils.deselect(el);
+ });
+ boardsStore.clearMultiSelect();
+ return;
+ }
+
boardsStore.moveIssueInList(
this.list,
boardsStore.moving.issue,
@@ -172,9 +240,133 @@ export default {
sortedArray,
);
},
+ onEnd: e => {
+ const { items = [], clones = [], to } = e;
+
+ // This is not a multi select operation
+ if (!items.length && !clones.length) {
+ sortableEnd();
+ return;
+ }
+
+ let toList;
+ if (to) {
+ const containerEl = to.closest('.js-board-list');
+ toList = boardsStore.findList('id', Number(containerEl.dataset.board));
+ }
+
+ /**
+ * onEnd is called irrespective if the cards were moved in the
+ * same list or the other list. Don't remove items if it's same list.
+ */
+ const isSameList = toList && toList.id === this.list.id;
+
+ if (toList && !isSameList && boardsStore.shouldRemoveIssue(this.list, toList)) {
+ const issues = items.map(item => this.list.findIssue(Number(item.dataset.issueId)));
+
+ if (_.compact(issues).length && !boardsStore.issuesAreContiguous(this.list, issues)) {
+ const indexes = [];
+ const ids = this.list.issues.map(i => i.id);
+ issues.forEach(issue => {
+ const index = ids.indexOf(issue.id);
+ if (index > -1) {
+ indexes.push(index);
+ }
+ });
+
+ // Descending sort because splice would cause index discrepancy otherwise
+ const sortedIndexes = indexes.sort((a, b) => (a < b ? 1 : -1));
+
+ sortedIndexes.forEach(i => {
+ /**
+ * **setTimeout and splice each element one-by-one in a loop
+ * is intended.**
+ *
+ * The problem here is all the indexes are in the list but are
+ * non-contiguous. Due to that, when we splice all the indexes,
+ * at once, Vue -- during a re-render -- is unable to find reference
+ * nodes and the entire app crashes.
+ *
+ * If the indexes are contiguous, this piece of code is not
+ * executed. If it is, this is a possible regression. Only when
+ * issue indexes are far apart, this logic should ever kick in.
+ */
+ setTimeout(() => {
+ this.list.issues.splice(i, 1);
+ }, 0);
+ });
+ }
+ }
+
+ if (!toList) {
+ createFlash(__('Something went wrong while performing the action.'));
+ }
+
+ if (!isSameList) {
+ boardsStore.clearMultiSelect();
+
+ // Since Vue's list does not re-render the same keyed item, we'll
+ // remove `multi-select` class to express it's unselected
+ if (clones && clones.length) {
+ clones.forEach(el => el.classList.remove('multi-select'));
+ }
+
+ // Due to some bug which I am unable to figure out
+ // Sortable does not deselect some pending items from the
+ // source list.
+ // We'll just do it forcefully here.
+ Array.from(document.querySelectorAll('.js-multi-select') || []).forEach(item => {
+ Sortable.utils.deselect(item);
+ });
+
+ /**
+ * SortableJS leaves all the moving items "as is" on the DOM.
+ * Vue picks up and rehydrates the DOM, but we need to explicity
+ * remove the "trash" items from the DOM.
+ *
+ * This is in parity to the logic on single item move from a list/in
+ * a list. For reference, look at the implementation of onAdd method.
+ */
+ this.$nextTick(() => {
+ if (items && items.length) {
+ items.forEach(item => {
+ item.remove();
+ });
+ }
+ });
+ }
+ sortableEnd();
+ },
onMove(e) {
return !e.related.classList.contains('board-list-count');
},
+ onSelect(e) {
+ const {
+ item: { classList },
+ } = e;
+
+ if (
+ classList &&
+ classList.contains('js-multi-select') &&
+ !classList.contains('multi-select')
+ ) {
+ Sortable.utils.deselect(e.item);
+ }
+ },
+ onDeselect: e => {
+ const {
+ item: { dataset, classList },
+ } = e;
+
+ if (
+ classList &&
+ classList.contains('multi-select') &&
+ !classList.contains('js-multi-select')
+ ) {
+ const issue = this.list.findIssue(Number(dataset.issueId));
+ boardsStore.toggleMultiSelect(issue);
+ }
+ },
});
this.sortable = Sortable.create(this.$refs.list, options);
@@ -260,7 +452,7 @@ export default {
<li v-if="showCount" class="board-list-count text-center" data-issue-id="-1">
<gl-loading-icon v-show="list.loadingMore" label="Loading more issues" />
<span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span>
- <span v-else> Showing {{ list.issues.length }} of {{ list.issuesSize }} issues </span>
+ <span v-else>{{ paginatedIssueText }}</span>
</li>
</ul>
</div>
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index ebb2f5b23e4..334c162954e 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -305,13 +305,18 @@ export default {
<div v-if="canAdminBoard">
<gl-dropdown-divider />
- <gl-dropdown-item v-if="multipleIssueBoardsAvailable" @click.prevent="showPage('new')">
+ <gl-dropdown-item
+ v-if="multipleIssueBoardsAvailable"
+ data-qa-selector="create_new_board_button"
+ @click.prevent="showPage('new')"
+ >
{{ s__('IssueBoards|Create new board') }}
</gl-dropdown-item>
<gl-dropdown-item
v-if="showDelete"
class="text-danger"
+ data-qa-selector="delete_board_button"
@click.prevent="showPage('delete')"
>
{{ s__('IssueBoards|Delete board') }}
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue
index 7f554c99669..40d75d53f75 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.vue
+++ b/app/assets/javascripts/boards/components/issue_card_inner.vue
@@ -99,7 +99,10 @@ export default {
return !groupId ? referencePath.split('#')[0] : null;
},
orderedLabels() {
- return _.sortBy(this.issue.labels, 'title');
+ return _.chain(this.issue.labels)
+ .filter(this.isNonListLabel)
+ .sortBy('title')
+ .value();
},
helpLink() {
return boardsStore.scopedLabels.helpLink;
@@ -130,6 +133,9 @@ export default {
if (!label.id) return false;
return true;
},
+ isNonListLabel(label) {
+ return label.id && !(this.list.type === 'label' && this.list.title === label.title);
+ },
filterByLabel(label) {
if (!this.updateFilters) return;
const labelTitle = encodeURIComponent(label.title);
@@ -167,7 +173,7 @@ export default {
</h4>
</div>
<div v-if="showLabelFooter" class="board-card-labels prepend-top-4 d-flex flex-wrap">
- <template v-for="label in orderedLabels" v-if="showLabel(label)">
+ <template v-for="label in orderedLabels">
<issue-card-inner-scoped-label
v-if="showScopedLabel(label)"
:key="label.id"
@@ -212,7 +218,7 @@ export default {
<issue-due-date v-if="issue.dueDate" :date="issue.dueDate" />
<issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" />
<issue-card-weight
- v-if="issue.weight"
+ v-if="validIssueWeight"
:weight="issue.weight"
@click="filterByWeight(issue.weight)"
/>
diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue
index 3385aad5b11..5c33ba9461c 100644
--- a/app/assets/javascripts/boards/components/issue_time_estimate.vue
+++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue
@@ -34,7 +34,7 @@ export default {
<template>
<span>
<span ref="issueTimeEstimate" class="board-card-info card-number">
- <icon name="hourglass" css-classes="board-card-info-icon align-top" /><time
+ <icon name="hourglass" class="board-card-info-icon align-top" /><time
class="board-card-info-text"
>{{ timeEstimate }}</time
>
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
new file mode 100644
index 00000000000..3c66c7a0660
--- /dev/null
+++ b/app/assets/javascripts/boards/constants.js
@@ -0,0 +1,11 @@
+export const ListType = {
+ assignee: 'assignee',
+ milestone: 'milestone',
+ backlog: 'backlog',
+ closed: 'closed',
+ label: 'label',
+};
+
+export default {
+ ListType,
+};
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index 3bded4a3258..befca70eeae 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -22,7 +22,6 @@ import Board from 'ee_else_ce/boards/components/board';
import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar';
import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown';
import BoardAddIssuesModal from '~/boards/components/modal/index.vue';
-import '~/vue_shared/vue_resource_interceptor';
import {
NavigationType,
convertObjectPropsToCamelCase,
@@ -147,7 +146,7 @@ export default () => {
updateTokens() {
this.filterManager.updateTokens();
},
- updateDetailIssue(newIssue) {
+ updateDetailIssue(newIssue, multiSelect = false) {
const { sidebarInfoEndpoint } = newIssue;
if (sidebarInfoEndpoint && newIssue.subscribed === undefined) {
newIssue.setFetchingState('subscriptions', true);
@@ -186,9 +185,23 @@ export default () => {
});
}
+ if (multiSelect) {
+ boardsStore.toggleMultiSelect(newIssue);
+
+ if (boardsStore.detail.issue) {
+ boardsStore.clearDetailIssue();
+ return;
+ }
+
+ return;
+ }
+
boardsStore.setIssueDetail(newIssue);
},
- clearDetailIssue() {
+ clearDetailIssue(multiSelect = false) {
+ if (multiSelect) {
+ boardsStore.clearMultiSelect();
+ }
boardsStore.clearDetailIssue();
},
toggleSubscription(id) {
diff --git a/app/assets/javascripts/boards/mixins/issue_card_inner.js b/app/assets/javascripts/boards/mixins/issue_card_inner.js
index 8000237da6d..04e971b756d 100644
--- a/app/assets/javascripts/boards/mixins/issue_card_inner.js
+++ b/app/assets/javascripts/boards/mixins/issue_card_inner.js
@@ -1,4 +1,9 @@
export default {
+ computed: {
+ validIssueWeight() {
+ return false;
+ },
+ },
methods: {
filterByWeight() {},
},
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index b3e56a34c28..1e213c324eb 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -5,6 +5,7 @@ import ListLabel from './label';
import ListAssignee from './assignee';
import ListIssue from 'ee_else_ce/boards/models/issue';
import { urlParamsToObject } from '~/lib/utils/common_utils';
+import flash from '~/flash';
import boardsStore from '../stores/boards_store';
import ListMilestone from './milestone';
@@ -176,6 +177,53 @@ class List {
});
}
+ addMultipleIssues(issues, listFrom, newIndex) {
+ let moveBeforeId = null;
+ let moveAfterId = null;
+
+ const listHasIssues = issues.every(issue => this.findIssue(issue.id));
+
+ if (!listHasIssues) {
+ if (newIndex !== undefined) {
+ if (this.issues[newIndex - 1]) {
+ moveBeforeId = this.issues[newIndex - 1].id;
+ }
+
+ if (this.issues[newIndex]) {
+ moveAfterId = this.issues[newIndex].id;
+ }
+
+ this.issues.splice(newIndex, 0, ...issues);
+ } else {
+ this.issues.push(...issues);
+ }
+
+ if (this.label) {
+ issues.forEach(issue => issue.addLabel(this.label));
+ }
+
+ if (this.assignee) {
+ if (listFrom && listFrom.type === 'assignee') {
+ issues.forEach(issue => issue.removeAssignee(listFrom.assignee));
+ }
+ issues.forEach(issue => issue.addAssignee(this.assignee));
+ }
+
+ if (IS_EE && this.milestone) {
+ if (listFrom && listFrom.type === 'milestone') {
+ issues.forEach(issue => issue.removeMilestone(listFrom.milestone));
+ }
+ issues.forEach(issue => issue.addMilestone(this.milestone));
+ }
+
+ if (listFrom) {
+ this.issuesSize += issues.length;
+
+ this.updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId);
+ }
+ }
+ }
+
addIssue(issue, listFrom, newIndex) {
let moveBeforeId = null;
let moveAfterId = null;
@@ -230,6 +278,23 @@ class List {
});
}
+ moveMultipleIssues({ issues, oldIndicies, newIndex, moveBeforeId, moveAfterId }) {
+ oldIndicies.reverse().forEach(index => {
+ this.issues.splice(index, 1);
+ });
+ this.issues.splice(newIndex, 0, ...issues);
+
+ gl.boardService
+ .moveMultipleIssues({
+ ids: issues.map(issue => issue.id),
+ fromListId: null,
+ toListId: null,
+ moveBeforeId,
+ moveAfterId,
+ })
+ .catch(() => flash(__('Something went wrong while moving issues.')));
+ }
+
updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) {
gl.boardService
.moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId)
@@ -238,10 +303,37 @@ class List {
});
}
+ updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId) {
+ gl.boardService
+ .moveMultipleIssues({
+ ids: issues.map(issue => issue.id),
+ fromListId: listFrom.id,
+ toListId: this.id,
+ moveBeforeId,
+ moveAfterId,
+ })
+ .catch(() => flash(__('Something went wrong while moving issues.')));
+ }
+
findIssue(id) {
return this.issues.find(issue => issue.id === id);
}
+ removeMultipleIssues(removeIssues) {
+ const ids = removeIssues.map(issue => issue.id);
+
+ this.issues = this.issues.filter(issue => {
+ const matchesRemove = ids.includes(issue.id);
+
+ if (matchesRemove) {
+ this.issuesSize -= 1;
+ issue.removeLabel(this.label);
+ }
+
+ return !matchesRemove;
+ });
+ }
+
removeIssue(removeIssue) {
this.issues = this.issues.filter(issue => {
const matchesRemove = removeIssue.id === issue.id;
diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js
index 0d11db89511..03369febb4a 100644
--- a/app/assets/javascripts/boards/services/board_service.js
+++ b/app/assets/javascripts/boards/services/board_service.js
@@ -48,6 +48,16 @@ export default class BoardService {
return boardsStore.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId);
}
+ moveMultipleIssues({
+ ids,
+ fromListId = null,
+ toListId = null,
+ moveBeforeId = null,
+ moveAfterId = null,
+ }) {
+ return boardsStore.moveMultipleIssues({ ids, fromListId, toListId, moveBeforeId, moveAfterId });
+ }
+
newIssue(id, issue) {
return boardsStore.newIssue(id, issue);
}
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index 6da1cca9628..8b737d1dab0 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -11,6 +11,7 @@ import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import eventHub from '../eventhub';
+import { ListType } from '../constants';
const boardsStore = {
disabled: false,
@@ -39,6 +40,7 @@ const boardsStore = {
issue: {},
list: {},
},
+ multiSelect: { list: [] },
setEndpoints({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) {
const listsEndpointGenerate = `${listsEndpoint}/generate.json`;
@@ -51,7 +53,6 @@ const boardsStore = {
recentBoardsEndpoint: `${recentBoardsEndpoint}.json`,
};
},
-
create() {
this.state.lists = [];
this.filter.path = getUrlParamsArray().join('&');
@@ -134,6 +135,107 @@ const boardsStore = {
Object.assign(this.moving, { list, issue });
},
+ moveMultipleIssuesToList({ listFrom, listTo, issues, newIndex }) {
+ const issueTo = issues.map(issue => listTo.findIssue(issue.id));
+ const issueLists = _.flatten(issues.map(issue => issue.getLists()));
+ const listLabels = issueLists.map(list => list.label);
+
+ const hasMoveableIssues = _.compact(issueTo).length > 0;
+
+ if (!hasMoveableIssues) {
+ // Check if target list assignee is already present in this issue
+ if (
+ listTo.type === ListType.assignee &&
+ listFrom.type === ListType.assignee &&
+ issues.some(issue => issue.findAssignee(listTo.assignee))
+ ) {
+ const targetIssues = issues.map(issue => listTo.findIssue(issue.id));
+ targetIssues.forEach(targetIssue => targetIssue.removeAssignee(listFrom.assignee));
+ } else if (listTo.type === 'milestone') {
+ const currentMilestones = issues.map(issue => issue.milestone);
+ const currentLists = this.state.lists
+ .filter(list => list.type === 'milestone' && list.id !== listTo.id)
+ .filter(list =>
+ list.issues.some(listIssue => issues.some(issue => listIssue.id === issue.id)),
+ );
+
+ issues.forEach(issue => {
+ currentMilestones.forEach(milestone => {
+ issue.removeMilestone(milestone);
+ });
+ });
+
+ issues.forEach(issue => {
+ issue.addMilestone(listTo.milestone);
+ });
+
+ currentLists.forEach(currentList => {
+ issues.forEach(issue => {
+ currentList.removeIssue(issue);
+ });
+ });
+
+ listTo.addMultipleIssues(issues, listFrom, newIndex);
+ } else {
+ // Add to new lists issues if it doesn't already exist
+ listTo.addMultipleIssues(issues, listFrom, newIndex);
+ }
+ } else {
+ listTo.updateMultipleIssues(issues, listFrom);
+ issues.forEach(issue => {
+ issue.removeLabel(listFrom.label);
+ });
+ }
+
+ if (listTo.type === ListType.closed && listFrom.type !== ListType.backlog) {
+ issueLists.forEach(list => {
+ issues.forEach(issue => {
+ list.removeIssue(issue);
+ });
+ });
+
+ issues.forEach(issue => {
+ issue.removeLabels(listLabels);
+ });
+ } else if (listTo.type === ListType.backlog && listFrom.type === ListType.assignee) {
+ issues.forEach(issue => {
+ issue.removeAssignee(listFrom.assignee);
+ });
+ issueLists.forEach(list => {
+ issues.forEach(issue => {
+ list.removeIssue(issue);
+ });
+ });
+ } else if (listTo.type === ListType.backlog && listFrom.type === ListType.milestone) {
+ issues.forEach(issue => {
+ issue.removeMilestone(listFrom.milestone);
+ });
+ issueLists.forEach(list => {
+ issues.forEach(issue => {
+ list.removeIssue(issue);
+ });
+ });
+ } else if (
+ this.shouldRemoveIssue(listFrom, listTo) &&
+ this.issuesAreContiguous(listFrom, issues)
+ ) {
+ listFrom.removeMultipleIssues(issues);
+ }
+ },
+
+ issuesAreContiguous(list, issues) {
+ // When there's only 1 issue selected, we can return early.
+ if (issues.length === 1) return true;
+
+ // Create list of ids for issues involved.
+ const listIssueIds = list.issues.map(issue => issue.id);
+ const movedIssueIds = issues.map(issue => issue.id);
+
+ // Check if moved issue IDs is sub-array
+ // of source list issue IDs (i.e. contiguous selection).
+ return listIssueIds.join('|').includes(movedIssueIds.join('|'));
+ },
+
moveIssueToList(listFrom, listTo, issue, newIndex) {
const issueTo = listTo.findIssue(issue.id);
const issueLists = issue.getLists();
@@ -195,6 +297,17 @@ const boardsStore = {
list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId);
},
+ moveMultipleIssuesInList({ list, issues, oldIndicies, newIndex, idArray }) {
+ const beforeId = parseInt(idArray[newIndex - 1], 10) || null;
+ const afterId = parseInt(idArray[newIndex + issues.length], 10) || null;
+ list.moveMultipleIssues({
+ issues,
+ oldIndicies,
+ newIndex,
+ moveBeforeId: beforeId,
+ moveAfterId: afterId,
+ });
+ },
findList(key, val, type = 'label') {
const filteredList = this.state.lists.filter(list => {
const byType = type
@@ -260,6 +373,10 @@ const boardsStore = {
}`;
},
+ generateMultiDragPath(boardId) {
+ return `${gon.relative_url_root}/-/boards/${boardId ? `${boardId}` : ''}/issues/bulk_move`;
+ },
+
all() {
return axios.get(this.state.endpoints.listsEndpoint);
},
@@ -309,6 +426,16 @@ const boardsStore = {
});
},
+ moveMultipleIssues({ ids, fromListId, toListId, moveBeforeId, moveAfterId }) {
+ return axios.put(this.generateMultiDragPath(this.state.endpoints.boardId), {
+ from_list_id: fromListId,
+ to_list_id: toListId,
+ move_before_id: moveBeforeId,
+ move_after_id: moveAfterId,
+ ids,
+ });
+ },
+
newIssue(id, issue) {
return axios.post(this.generateIssuesPath(id), {
issue,
@@ -379,6 +506,25 @@ const boardsStore = {
setCurrentBoard(board) {
this.state.currentBoard = board;
},
+
+ toggleMultiSelect(issue) {
+ const selectedIssueIds = this.multiSelect.list.map(issue => issue.id);
+ const index = selectedIssueIds.indexOf(issue.id);
+
+ if (index === -1) {
+ this.multiSelect.list.push(issue);
+ return;
+ }
+
+ this.multiSelect.list = [
+ ...this.multiSelect.list.slice(0, index),
+ ...this.multiSelect.list.slice(index + 1),
+ ];
+ },
+
+ clearMultiSelect() {
+ this.multiSelect.list = [];
+ },
};
BoardsStoreEE.initEESpecific(boardsStore);
diff --git a/app/assets/javascripts/build_artifacts.js b/app/assets/javascripts/build_artifacts.js
index b2c88e8c14e..2955f0f014b 100644
--- a/app/assets/javascripts/build_artifacts.js
+++ b/app/assets/javascripts/build_artifacts.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, prefer-arrow-callback */
+/* eslint-disable func-names */
import $ from 'jquery';
import { visitUrl } from './lib/utils/url_utility';
@@ -12,11 +12,11 @@ export default class BuildArtifacts {
}
// eslint-disable-next-line class-methods-use-this
disablePropagation() {
- $('.top-block').on('click', '.download', function(e) {
- return e.stopPropagation();
+ $('.top-block').on('click', '.download', e => {
+ e.stopPropagation();
});
- return $('.tree-holder').on('click', 'tr[data-link] a', function(e) {
- return e.stopImmediatePropagation();
+ return $('.tree-holder').on('click', 'tr[data-link] a', e => {
+ e.stopImmediatePropagation();
});
}
// eslint-disable-next-line class-methods-use-this
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index d386960f3b6..7ea8901ecbb 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -41,6 +41,8 @@ export default class Clusters {
managePrometheusPath,
clusterEnvironmentsPath,
hasRbac,
+ providerType,
+ preInstalledKnative,
clusterType,
clusterStatus,
clusterStatusReason,
@@ -50,6 +52,7 @@ export default class Clusters {
environmentsHelpPath,
clustersHelpPath,
deployBoardsHelpPath,
+ cloudRunHelpPath,
clusterId,
} = document.querySelector('.js-edit-cluster-form').dataset;
@@ -65,10 +68,13 @@ export default class Clusters {
environmentsHelpPath,
clustersHelpPath,
deployBoardsHelpPath,
+ cloudRunHelpPath,
);
this.store.setManagePrometheusPath(managePrometheusPath);
this.store.updateStatus(clusterStatus);
this.store.updateStatusReason(clusterStatusReason);
+ this.store.updateProviderType(providerType);
+ this.store.updatePreInstalledKnative(preInstalledKnative);
this.store.updateRbac(hasRbac);
this.service = new ClustersService({
endpoint: statusPath,
@@ -153,6 +159,9 @@ export default class Clusters {
ingressHelpPath: this.state.ingressHelpPath,
managePrometheusPath: this.state.managePrometheusPath,
ingressDnsHelpPath: this.state.ingressDnsHelpPath,
+ cloudRunHelpPath: this.state.cloudRunHelpPath,
+ providerType: this.state.providerType,
+ preInstalledKnative: this.state.preInstalledKnative,
rbac: this.state.rbac,
},
});
diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue
index 64364092016..c6c8dc6352c 100644
--- a/app/assets/javascripts/clusters/components/application_row.vue
+++ b/app/assets/javascripts/clusters/components/application_row.vue
@@ -78,6 +78,10 @@ export default {
required: false,
default: false,
},
+ installedVia: {
+ type: String,
+ required: false,
+ },
version: {
type: String,
required: false,
@@ -311,6 +315,11 @@ export default {
>
<span v-else class="js-cluster-application-title">{{ title }}</span>
</strong>
+ <span
+ v-if="installedVia"
+ class="js-cluster-application-installed-via"
+ v-html="installedVia"
+ ></span>
<slot name="description"></slot>
<div v-if="hasError" class="cluster-application-error text-danger prepend-top-10">
<p class="js-cluster-application-general-error-message append-bottom-0">
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
index 27959898fb7..b95f97077f6 100644
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -16,7 +16,7 @@ import { s__, sprintf } from '../../locale';
import applicationRow from './application_row.vue';
import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
import KnativeDomainEditor from './knative_domain_editor.vue';
-import { CLUSTER_TYPE, APPLICATION_STATUS, INGRESS } from '../constants';
+import { CLUSTER_TYPE, PROVIDER_TYPE, APPLICATION_STATUS, INGRESS } from '../constants';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import eventHub from '~/clusters/event_hub';
@@ -54,11 +54,26 @@ export default {
required: false,
default: '',
},
+ cloudRunHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
managePrometheusPath: {
type: String,
required: false,
default: '',
},
+ providerType: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ preInstalledKnative: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
rbac: {
type: Boolean,
required: false,
@@ -156,6 +171,25 @@ export default {
knative() {
return this.applications.knative;
},
+ cloudRun() {
+ return this.providerType === PROVIDER_TYPE.GCP && this.preInstalledKnative;
+ },
+ installedVia() {
+ if (this.cloudRun) {
+ return sprintf(
+ _.escape(s__(`ClusterIntegration|installed via %{installed_via}`)),
+ {
+ installed_via: `<a href="${
+ this.cloudRunHelpPath
+ }" target="_blank" rel="noopener noreferrer">${_.escape(
+ s__('ClusterIntegration|Cloud Run'),
+ )}</a>`,
+ },
+ false,
+ );
+ }
+ return null;
+ },
},
created() {
this.helmInstallIllustration = helmInstallIllustration;
@@ -260,7 +294,7 @@ export default {
<span class="input-group-append">
<clipboard-button
:text="ingressExternalEndpoint"
- :title="s__('ClusterIntegration|Copy Ingress Endpoint to clipboard')"
+ :title="s__('ClusterIntegration|Copy Ingress Endpoint')"
class="input-group-text js-clipboard-btn"
/>
</span>
@@ -438,7 +472,7 @@ export default {
<span class="input-group-btn">
<clipboard-button
:text="jupyterHostname"
- :title="s__('ClusterIntegration|Copy Jupyter Hostname to clipboard')"
+ :title="s__('ClusterIntegration|Copy Jupyter Hostname')"
class="js-clipboard-btn"
/>
</span>
@@ -468,6 +502,7 @@ export default {
:installed="applications.knative.installed"
:install-failed="applications.knative.installFailed"
:install-application-request-params="{ hostname: applications.knative.hostname }"
+ :installed-via="installedVia"
:uninstallable="applications.knative.uninstallable"
:uninstall-successful="applications.knative.uninstallSuccessful"
:uninstall-failed="applications.knative.uninstallFailed"
@@ -499,7 +534,7 @@ export default {
</p>
<knative-domain-editor
- v-if="knative.installed || (helmInstalled && rbac)"
+ v-if="(knative.installed || (helmInstalled && rbac)) && !preInstalledKnative"
:knative="knative"
:ingress-dns-help-path="ingressDnsHelpPath"
@save="saveKnativeDomain"
diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue
index e26ef135bc5..25347b11b6c 100644
--- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue
+++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue
@@ -103,7 +103,7 @@ export default {
<span class="input-group-append">
<clipboard-button
:text="knativeExternalEndpoint"
- :title="s__('ClusterIntegration|Copy Knative Endpoint to clipboard')"
+ :title="s__('ClusterIntegration|Copy Knative Endpoint')"
class="input-group-text js-knative-endpoint-clipboard-btn"
/>
</span>
diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
index 4f60e543666..f1925c243f2 100644
--- a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
+++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
@@ -5,8 +5,14 @@ import trackUninstallButtonClickMixin from 'ee_else_ce/clusters/mixins/track_uni
import { HELM, INGRESS, CERT_MANAGER, PROMETHEUS, RUNNER, KNATIVE, JUPYTER } from '../constants';
const CUSTOM_APP_WARNING_TEXT = {
- [HELM]: s__(
- 'ClusterIntegration|The associated Tiller pod will be deleted and cannot be restored.',
+ [HELM]: sprintf(
+ s__(
+ 'ClusterIntegration|The associated Tiller pod, the %{gitlabManagedAppsNamespace} namespace, and all of its resources will be deleted and cannot be restored.',
+ ),
+ {
+ gitlabManagedAppsNamespace: '<code>gitlab-managed-apps</code>',
+ },
+ false,
),
[INGRESS]: s__(
'ClusterIntegration|The associated load balancer and IP will be deleted and cannot be restored.',
@@ -76,6 +82,7 @@ export default {
:modal-id="modalId"
:title="title"
@ok="confirmUninstall()"
- >{{ warningText }} {{ customAppWarningText }}</gl-modal
>
+ {{ warningText }} <span v-html="customAppWarningText"></span>
+ </gl-modal>
</template>
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
index 8fd752092c9..c6e4b7951cf 100644
--- a/app/assets/javascripts/clusters/constants.js
+++ b/app/assets/javascripts/clusters/constants.js
@@ -5,6 +5,11 @@ export const CLUSTER_TYPE = {
PROJECT: 'project_type',
};
+// These need to match the available providers in app/models/clusters/providers/
+export const PROVIDER_TYPE = {
+ GCP: 'gcp',
+};
+
// These need to match what is returned from the server
export const APPLICATION_STATUS = {
NO_STATUS: null,
@@ -19,6 +24,7 @@ export const APPLICATION_STATUS = {
UNINSTALLING: 'uninstalling',
UNINSTALL_ERRORED: 'uninstall_errored',
ERROR: 'errored',
+ PRE_INSTALLED: 'pre_installed',
};
/*
@@ -29,6 +35,7 @@ export const APPLICATION_INSTALLED_STATUSES = [
APPLICATION_STATUS.INSTALLED,
APPLICATION_STATUS.UPDATING,
APPLICATION_STATUS.UNINSTALLING,
+ APPLICATION_STATUS.PRE_INSTALLED,
];
// These are only used client-side
diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js
index 6e632519d8a..6bc4be7b93a 100644
--- a/app/assets/javascripts/clusters/services/application_state_machine.js
+++ b/app/assets/javascripts/clusters/services/application_state_machine.js
@@ -13,6 +13,7 @@ const {
UPDATE_ERRORED,
UNINSTALLING,
UNINSTALL_ERRORED,
+ PRE_INSTALLED,
} = APPLICATION_STATUS;
const applicationStateMachine = {
@@ -63,6 +64,9 @@ const applicationStateMachine = {
uninstallFailed: true,
},
},
+ [PRE_INSTALLED]: {
+ target: PRE_INSTALLED,
+ },
},
},
[NOT_INSTALLABLE]: {
@@ -123,6 +127,27 @@ const applicationStateMachine = {
},
},
},
+ [PRE_INSTALLED]: {
+ on: {
+ [UPDATE_EVENT]: {
+ target: UPDATING,
+ effects: {
+ updateFailed: false,
+ updateSuccessful: false,
+ },
+ },
+ [NOT_INSTALLABLE]: {
+ target: NOT_INSTALLABLE,
+ },
+ [UNINSTALL_EVENT]: {
+ target: UNINSTALLING,
+ effects: {
+ uninstallFailed: false,
+ uninstallSuccessful: false,
+ },
+ },
+ },
+ },
[UPDATING]: {
on: {
[UPDATED]: {
diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js
index 5cddb4cc098..6464461ea0c 100644
--- a/app/assets/javascripts/clusters/stores/clusters_store.js
+++ b/app/assets/javascripts/clusters/stores/clusters_store.js
@@ -35,7 +35,10 @@ export default class ClusterStore {
environmentsHelpPath: null,
clustersHelpPath: null,
deployBoardsHelpPath: null,
+ cloudRunHelpPath: null,
status: null,
+ providerType: null,
+ preInstalledKnative: false,
rbac: false,
statusReason: null,
applications: {
@@ -95,6 +98,7 @@ export default class ClusterStore {
environmentsHelpPath,
clustersHelpPath,
deployBoardsHelpPath,
+ cloudRunHelpPath,
) {
this.state.helpPath = helpPath;
this.state.ingressHelpPath = ingressHelpPath;
@@ -102,6 +106,7 @@ export default class ClusterStore {
this.state.environmentsHelpPath = environmentsHelpPath;
this.state.clustersHelpPath = clustersHelpPath;
this.state.deployBoardsHelpPath = deployBoardsHelpPath;
+ this.state.cloudRunHelpPath = cloudRunHelpPath;
}
setManagePrometheusPath(managePrometheusPath) {
@@ -112,6 +117,14 @@ export default class ClusterStore {
this.state.status = status;
}
+ updateProviderType(providerType) {
+ this.state.providerType = providerType;
+ }
+
+ updatePreInstalledKnative(preInstalledKnative) {
+ this.state.preInstalledKnative = parseBoolean(preInstalledKnative);
+ }
+
updateRbac(rbac) {
this.state.rbac = parseBoolean(rbac);
}
diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js
index 9454f760df8..6c04e0beb4d 100644
--- a/app/assets/javascripts/commit/image_file.js
+++ b/app/assets/javascripts/commit/image_file.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, prefer-arrow-callback, no-else-return, consistent-return, prefer-template, one-var, no-return-assign, no-unused-expressions, no-sequences */
+/* eslint-disable func-names, no-var, no-else-return, consistent-return, one-var, no-return-assign, no-unused-expressions, no-sequences */
import $ from 'jquery';
@@ -13,14 +13,14 @@ export default class ImageFile {
$('.two-up.view .frame.deleted img', this.file),
(function(_this) {
return function() {
- return _this.requestImageInfo($('.two-up.view .frame.added img', _this.file), function() {
+ return _this.requestImageInfo($('.two-up.view .frame.added img', _this.file), () => {
_this.initViewModes();
// Load two-up view after images are loaded
// so that we can display the correct width and height information
const $images = $('.two-up.view img', _this.file);
- $images.waitForImages(function() {
+ $images.waitForImages(() => {
_this.initView('two-up');
});
});
@@ -49,13 +49,13 @@ export default class ImageFile {
activateViewMode(viewMode) {
$('.view-modes-menu li', this.file)
.removeClass('active')
- .filter('.' + viewMode)
+ .filter(`.${viewMode}`)
.addClass('active');
- return $('.view:visible:not(.' + viewMode + ')', this.file).fadeOut(
+ return $(`.view:visible:not(.${viewMode})`, this.file).fadeOut(
200,
(function(_this) {
return function() {
- $('.view.' + viewMode, _this.file).fadeIn(200);
+ $(`.view.${viewMode}`, _this.file).fadeIn(200);
return _this.initView(viewMode);
};
})(this),
@@ -138,9 +138,9 @@ export default class ImageFile {
return $(this).width(availWidth / 2);
}
});
- return _this.requestImageInfo($('img', wrap), function(width, height) {
- $('.image-info .meta-width', wrap).text(width + 'px');
- $('.image-info .meta-height', wrap).text(height + 'px');
+ return _this.requestImageInfo($('img', wrap), (width, height) => {
+ $('.image-info .meta-width', wrap).text(`${width}px`);
+ $('.image-info .meta-height', wrap).text(`${height}px`);
return $('.image-info', wrap).removeClass('hide');
});
};
@@ -175,7 +175,7 @@ export default class ImageFile {
wrapPadding = parseInt($swipeWrap.css('right').replace('px', ''), 10);
- _this.initDraggable($swipeBar, wrapPadding, function(e, left) {
+ _this.initDraggable($swipeBar, wrapPadding, (e, left) => {
if (left > 0 && left < $swipeFrame.width() - wrapPadding * 2) {
$swipeWrap.width(maxWidth + 1 - left);
$swipeBar.css('left', left);
@@ -215,7 +215,7 @@ export default class ImageFile {
$frameAdded.css('opacity', 1);
framePadding = parseInt($frameAdded.css('right').replace('px', ''), 10);
- _this.initDraggable($dragger, framePadding, function(e, left) {
+ _this.initDraggable($dragger, framePadding, (e, left) => {
var opacity = left / dragTrackWidth;
if (opacity >= 0 && opacity <= 1) {
diff --git a/app/assets/javascripts/commons/vue.js b/app/assets/javascripts/commons/vue.js
index 798623b94fb..5b5a1507d38 100644
--- a/app/assets/javascripts/commons/vue.js
+++ b/app/assets/javascripts/commons/vue.js
@@ -1,6 +1,8 @@
import Vue from 'vue';
-import '../vue_shared/vue_resource_interceptor';
+import GlFeatureFlagsPlugin from '~/vue_shared/gl_feature_flags_plugin';
if (process.env.NODE_ENV !== 'production') {
Vue.config.productionTip = false;
}
+
+Vue.use(GlFeatureFlagsPlugin);
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue
index f9465da6fda..3c6da43c4c4 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue
@@ -3,6 +3,8 @@ import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_searc
import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue';
import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
+const findItem = (items, valueProp, value) => items.find(item => item[valueProp] === value);
+
export default {
components: {
DropdownButton,
@@ -26,7 +28,7 @@ export default {
default: '',
},
value: {
- type: Object,
+ type: [Object, String],
required: false,
default: () => null,
},
@@ -93,8 +95,8 @@ export default {
},
data() {
return {
+ selectedItem: findItem(this.items, this.value),
searchQuery: '',
- selectedItem: null,
};
},
computed: {
@@ -127,10 +129,15 @@ export default {
return (this.selectedItem && this.selectedItem[this.valueProperty]) || '';
},
},
+ watch: {
+ value(value) {
+ this.selectedItem = findItem(this.items, this.valueProperty, value);
+ },
+ },
methods: {
select(item) {
this.selectedItem = item;
- this.$emit('input', item);
+ this.$emit('input', item[this.valueProperty]);
},
},
};
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue
index ce2e4b883e4..22ee368b8e0 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue
@@ -7,8 +7,23 @@ export default {
ServiceCredentialsForm,
EksClusterConfigurationForm,
},
+ props: {
+ gitlabManagedClusterHelpPath: {
+ type: String,
+ required: true,
+ },
+ kubernetesIntegrationHelpPath: {
+ type: String,
+ required: true,
+ },
+ },
};
</script>
<template>
- <eks-cluster-configuration-form />
+ <div class="js-create-eks-cluster">
+ <eks-cluster-configuration-form
+ :gitlab-managed-cluster-help-path="gitlabManagedClusterHelpPath"
+ :kubernetes-integration-help-path="kubernetesIntegrationHelpPath"
+ />
+ </div>
</template>
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
index 6e74963dcb0..1188cf08850 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
@@ -1,25 +1,394 @@
<script>
-import RoleNameDropdown from './role_name_dropdown.vue';
-import SecurityGroupDropdown from './security_group_dropdown.vue';
-import SubnetDropdown from './subnet_dropdown.vue';
-import VPCDropdown from './vpc_dropdown.vue';
+import { createNamespacedHelpers, mapState, mapActions } from 'vuex';
+import { sprintf, s__ } from '~/locale';
+import _ from 'underscore';
+import { GlFormInput, GlFormCheckbox } from '@gitlab/ui';
+import ClusterFormDropdown from './cluster_form_dropdown.vue';
+import RegionDropdown from './region_dropdown.vue';
+import { KUBERNETES_VERSIONS } from '../constants';
+
+const { mapState: mapRolesState, mapActions: mapRolesActions } = createNamespacedHelpers('roles');
+const { mapState: mapRegionsState, mapActions: mapRegionsActions } = createNamespacedHelpers(
+ 'regions',
+);
+const { mapState: mapKeyPairsState, mapActions: mapKeyPairsActions } = createNamespacedHelpers(
+ 'keyPairs',
+);
+const { mapState: mapVpcsState, mapActions: mapVpcActions } = createNamespacedHelpers('vpcs');
+const { mapState: mapSubnetsState, mapActions: mapSubnetActions } = createNamespacedHelpers(
+ 'subnets',
+);
+const {
+ mapState: mapSecurityGroupsState,
+ mapActions: mapSecurityGroupsActions,
+} = createNamespacedHelpers('securityGroups');
export default {
components: {
- RoleNameDropdown,
- SecurityGroupDropdown,
- SubnetDropdown,
- VPCDropdown,
+ ClusterFormDropdown,
+ RegionDropdown,
+ GlFormInput,
+ GlFormCheckbox,
+ },
+ props: {
+ gitlabManagedClusterHelpPath: {
+ type: String,
+ required: true,
+ },
+ kubernetesIntegrationHelpPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState([
+ 'clusterName',
+ 'environmentScope',
+ 'kubernetesVersion',
+ 'selectedRegion',
+ 'selectedKeyPair',
+ 'selectedVpc',
+ 'selectedSubnet',
+ 'selectedRole',
+ 'selectedSecurityGroup',
+ 'gitlabManagedCluster',
+ ]),
+ ...mapRolesState({
+ roles: 'items',
+ isLoadingRoles: 'isLoadingItems',
+ loadingRolesError: 'loadingItemsError',
+ }),
+ ...mapRegionsState({
+ regions: 'items',
+ isLoadingRegions: 'isLoadingItems',
+ loadingRegionsError: 'loadingItemsError',
+ }),
+ ...mapKeyPairsState({
+ keyPairs: 'items',
+ isLoadingKeyPairs: 'isLoadingItems',
+ loadingKeyPairsError: 'loadingItemsError',
+ }),
+ ...mapVpcsState({
+ vpcs: 'items',
+ isLoadingVpcs: 'isLoadingItems',
+ loadingVpcsError: 'loadingItemsError',
+ }),
+ ...mapSubnetsState({
+ subnets: 'items',
+ isLoadingSubnets: 'isLoadingItems',
+ loadingSubnetsError: 'loadingItemsError',
+ }),
+ ...mapSecurityGroupsState({
+ securityGroups: 'items',
+ isLoadingSecurityGroups: 'isLoadingItems',
+ loadingSecurityGroupsError: 'loadingItemsError',
+ }),
+ kubernetesVersions() {
+ return KUBERNETES_VERSIONS;
+ },
+ vpcDropdownDisabled() {
+ return !this.selectedRegion;
+ },
+ keyPairDropdownDisabled() {
+ return !this.selectedRegion;
+ },
+ subnetDropdownDisabled() {
+ return !this.selectedVpc;
+ },
+ securityGroupDropdownDisabled() {
+ return !this.selectedVpc;
+ },
+ kubernetesIntegrationHelpText() {
+ const escapedUrl = _.escape(this.kubernetesIntegrationHelpPath);
+
+ return sprintf(
+ s__(
+ 'ClusterIntegration|Read our %{link_start}help page%{link_end} on Kubernetes cluster integration.',
+ ),
+ {
+ link_start: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`,
+ link_end: '</a>',
+ },
+ false,
+ );
+ },
+ roleDropdownHelpText() {
+ return sprintf(
+ s__(
+ 'ClusterIntegration|Select the IAM Role to allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role name, first create one on %{startLink}Amazon Web Services%{endLink}.',
+ ),
+ {
+ startLink:
+ '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">',
+ endLink: '</a>',
+ },
+ false,
+ );
+ },
+ keyPairDropdownHelpText() {
+ return sprintf(
+ s__(
+ 'ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{startLink}Amazon Web Services%{endLink}.',
+ ),
+ {
+ startLink:
+ '<a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html#having-ec2-create-your-key-pair" target="_blank" rel="noopener noreferrer">',
+ endLink: '</a>',
+ },
+ false,
+ );
+ },
+ vpcDropdownHelpText() {
+ return sprintf(
+ s__(
+ 'ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{startLink}Amazon Web Services%{endLink}.',
+ ),
+ {
+ startLink:
+ '<a href="https://console.aws.amazon.com/vpc/home?#vpc" target="_blank" rel="noopener noreferrer">',
+ endLink: '</a>',
+ },
+ false,
+ );
+ },
+ subnetDropdownHelpText() {
+ return sprintf(
+ s__(
+ 'ClusterIntegration|Choose the %{startLink}subnets%{endLink} in your VPC where your worker nodes will run.',
+ ),
+ {
+ startLink:
+ '<a href="https://console.aws.amazon.com/vpc/home?#subnets" target="_blank" rel="noopener noreferrer">',
+ endLink: '</a>',
+ },
+ false,
+ );
+ },
+ securityGroupDropdownHelpText() {
+ return sprintf(
+ s__(
+ 'ClusterIntegration|Choose the %{startLink}security groups%{endLink} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.',
+ ),
+ {
+ startLink:
+ '<a href="https://console.aws.amazon.com/vpc/home?#securityGroups" target="_blank" rel="noopener noreferrer">',
+ endLink: '</a>',
+ },
+ false,
+ );
+ },
+ gitlabManagedHelpText() {
+ const escapedUrl = _.escape(this.gitlabManagedClusterHelpPath);
+
+ return sprintf(
+ s__(
+ 'ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster. %{startLink}More information%{endLink}',
+ ),
+ {
+ startLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`,
+ endLink: '</a>',
+ },
+ false,
+ );
+ },
+ },
+ mounted() {
+ this.fetchRegions();
+ this.fetchRoles();
+ },
+ methods: {
+ ...mapActions([
+ 'setClusterName',
+ 'setEnvironmentScope',
+ 'setKubernetesVersion',
+ 'setRegion',
+ 'setVpc',
+ 'setSubnet',
+ 'setRole',
+ 'setKeyPair',
+ 'setSecurityGroup',
+ 'setGitlabManagedCluster',
+ ]),
+ ...mapRegionsActions({ fetchRegions: 'fetchItems' }),
+ ...mapVpcActions({ fetchVpcs: 'fetchItems' }),
+ ...mapSubnetActions({ fetchSubnets: 'fetchItems' }),
+ ...mapRolesActions({ fetchRoles: 'fetchItems' }),
+ ...mapKeyPairsActions({ fetchKeyPairs: 'fetchItems' }),
+ ...mapSecurityGroupsActions({ fetchSecurityGroups: 'fetchItems' }),
+ setRegionAndFetchVpcsAndKeyPairs(region) {
+ this.setRegion({ region });
+ this.fetchVpcs({ region });
+ this.fetchKeyPairs({ region });
+ },
+ setVpcAndFetchSubnets(vpc) {
+ this.setVpc({ vpc });
+ this.fetchSubnets({ vpc });
+ this.fetchSecurityGroups({ vpc });
+ },
},
};
</script>
<template>
<form name="eks-cluster-configuration-form">
+ <h2>
+ {{ s__('ClusterIntegration|Enter the details for your Amazon EKS Kubernetes cluster') }}
+ </h2>
+ <p v-html="kubernetesIntegrationHelpText"></p>
+ <div class="form-group">
+ <label class="label-bold" for="eks-cluster-name">{{
+ s__('ClusterIntegration|Kubernetes cluster name')
+ }}</label>
+ <gl-form-input
+ id="eks-cluster-name"
+ :value="clusterName"
+ @input="setClusterName({ clusterName: $event })"
+ />
+ </div>
+ <div class="form-group">
+ <label class="label-bold" for="eks-environment-scope">{{
+ s__('ClusterIntegration|Environment scope')
+ }}</label>
+ <gl-form-input
+ id="eks-environment-scope"
+ :value="environmentScope"
+ @input="setEnvironmentScope({ environmentScope: $event })"
+ />
+ </div>
+ <div class="form-group">
+ <label class="label-bold" for="eks-kubernetes-version">{{
+ s__('ClusterIntegration|Kubernetes version')
+ }}</label>
+ <cluster-form-dropdown
+ field-id="eks-kubernetes-version"
+ field-name="eks-kubernetes-version"
+ :value="kubernetesVersion"
+ :items="kubernetesVersions"
+ :empty-text="s__('ClusterIntegration|Kubernetes version not found')"
+ @input="setKubernetesVersion({ kubernetesVersion: $event })"
+ />
+ <p class="form-text text-muted" v-html="roleDropdownHelpText"></p>
+ </div>
+ <div class="form-group">
+ <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Role name') }}</label>
+ <cluster-form-dropdown
+ field-id="eks-role"
+ field-name="eks-role"
+ :input="selectedRole"
+ :items="roles"
+ :loading="isLoadingRoles"
+ :loading-text="s__('ClusterIntegration|Loading IAM Roles')"
+ :placeholder="s__('ClusterIntergation|Select role name')"
+ :search-field-placeholder="s__('ClusterIntegration|Search IAM Roles')"
+ :empty-text="s__('ClusterIntegration|No IAM Roles found')"
+ :has-errors="Boolean(loadingRolesError)"
+ :error-message="s__('ClusterIntegration|Could not load IAM roles')"
+ @input="setRole({ role: $event })"
+ />
+ <p class="form-text text-muted" v-html="roleDropdownHelpText"></p>
+ </div>
+ <div class="form-group">
+ <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Region') }}</label>
+ <region-dropdown
+ :value="selectedRegion"
+ :regions="regions"
+ :error="loadingRegionsError"
+ :loading="isLoadingRegions"
+ @input="setRegionAndFetchVpcsAndKeyPairs($event)"
+ />
+ </div>
+ <div class="form-group">
+ <label class="label-bold" for="eks-key-pair">{{
+ s__('ClusterIntegration|Key pair name')
+ }}</label>
+ <cluster-form-dropdown
+ field-id="eks-key-pair"
+ field-name="eks-key-pair"
+ :input="selectedKeyPair"
+ :items="keyPairs"
+ :disabled="keyPairDropdownDisabled"
+ :disabled-text="s__('ClusterIntegration|Select a region to choose a Key Pair')"
+ :loading="isLoadingKeyPairs"
+ :loading-text="s__('ClusterIntegration|Loading Key Pairs')"
+ :placeholder="s__('ClusterIntergation|Select key pair')"
+ :search-field-placeholder="s__('ClusterIntegration|Search Key Pairs')"
+ :empty-text="s__('ClusterIntegration|No Key Pairs found')"
+ :has-errors="Boolean(loadingKeyPairsError)"
+ :error-message="s__('ClusterIntegration|Could not load Key Pairs')"
+ @input="setKeyPair({ keyPair: $event })"
+ />
+ <p class="form-text text-muted" v-html="keyPairDropdownHelpText"></p>
+ </div>
+ <div class="form-group">
+ <label class="label-bold" for="eks-vpc">{{ s__('ClusterIntegration|VPC') }}</label>
+ <cluster-form-dropdown
+ field-id="eks-vpc"
+ field-name="eks-vpc"
+ :input="selectedVpc"
+ :items="vpcs"
+ :loading="isLoadingVpcs"
+ :disabled="vpcDropdownDisabled"
+ :disabled-text="s__('ClusterIntegration|Select a region to choose a VPC')"
+ :loading-text="s__('ClusterIntegration|Loading VPCs')"
+ :placeholder="s__('ClusterIntergation|Select a VPC')"
+ :search-field-placeholder="s__('ClusterIntegration|Search VPCs')"
+ :empty-text="s__('ClusterIntegration|No VPCs found')"
+ :has-errors="Boolean(loadingVpcsError)"
+ :error-message="s__('ClusterIntegration|Could not load VPCs for the selected region')"
+ @input="setVpcAndFetchSubnets($event)"
+ />
+ <p class="form-text text-muted" v-html="vpcDropdownHelpText"></p>
+ </div>
+ <div class="form-group">
+ <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Subnet') }}</label>
+ <cluster-form-dropdown
+ field-id="eks-subnet"
+ field-name="eks-subnet"
+ :input="selectedSubnet"
+ :items="subnets"
+ :loading="isLoadingSubnets"
+ :disabled="subnetDropdownDisabled"
+ :disabled-text="s__('ClusterIntegration|Select a VPC to choose a subnet')"
+ :loading-text="s__('ClusterIntegration|Loading subnets')"
+ :placeholder="s__('ClusterIntergation|Select a subnet')"
+ :search-field-placeholder="s__('ClusterIntegration|Search subnets')"
+ :empty-text="s__('ClusterIntegration|No subnet found')"
+ :has-errors="Boolean(loadingSubnetsError)"
+ :error-message="s__('ClusterIntegration|Could not load subnets for the selected VPC')"
+ @input="setSubnet({ subnet: $event })"
+ />
+ <p class="form-text text-muted" v-html="subnetDropdownHelpText"></p>
+ </div>
+ <div class="form-group">
+ <label class="label-bold" for="eks-security-group">{{
+ s__('ClusterIntegration|Security groups')
+ }}</label>
+ <cluster-form-dropdown
+ field-id="eks-security-group"
+ field-name="eks-security-group"
+ :input="selectedSecurityGroup"
+ :items="securityGroups"
+ :loading="isLoadingSecurityGroups"
+ :disabled="securityGroupDropdownDisabled"
+ :disabled-text="s__('ClusterIntegration|Select a VPC to choose a security group')"
+ :loading-text="s__('ClusterIntegration|Loading security groups')"
+ :placeholder="s__('ClusterIntergation|Select a security group')"
+ :search-field-placeholder="s__('ClusterIntegration|Search security groups')"
+ :empty-text="s__('ClusterIntegration|No security group found')"
+ :has-errors="Boolean(loadingSecurityGroupsError)"
+ :error-message="
+ s__('ClusterIntegration|Could not load security groups for the selected VPC')
+ "
+ @input="setSecurityGroup({ securityGroup: $event })"
+ />
+ <p class="form-text text-muted" v-html="securityGroupDropdownHelpText"></p>
+ </div>
<div class="form-group">
- <label class="label-bold" name="role" for="eks-role">
- {{ s__('ClusterIntegration|Role name') }}
- </label>
- <role-name-dropdown />
+ <gl-form-checkbox
+ :checked="gitlabManagedCluster"
+ @input="setGitlabManagedCluster({ gitlabManagedCluster: $event })"
+ >{{ s__('ClusterIntegration|GitLab-managed cluster') }}</gl-form-checkbox
+ >
+ <p class="form-text text-muted" v-html="gitlabManagedHelpText"></p>
</div>
</form>
</template>
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue
new file mode 100644
index 00000000000..765955305c8
--- /dev/null
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue
@@ -0,0 +1,63 @@
+<script>
+import { sprintf, s__ } from '~/locale';
+
+import ClusterFormDropdown from './cluster_form_dropdown.vue';
+
+export default {
+ components: {
+ ClusterFormDropdown,
+ },
+ props: {
+ regions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ error: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ hasErrors() {
+ return Boolean(this.error);
+ },
+ helpText() {
+ return sprintf(
+ s__('ClusterIntegration|Learn more about %{startLink}Regions%{endLink}.'),
+ {
+ startLink:
+ '<a href="https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/" target="_blank" rel="noopener noreferrer">',
+ endLink: '</a>',
+ },
+ false,
+ );
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <cluster-form-dropdown
+ field-id="eks-region"
+ field-name="eks-region"
+ :items="regions"
+ :loading="loading"
+ :loading-text="s__('ClusterIntegration|Loading Regions')"
+ :placeholder="s__('ClusterIntergation|Select a region')"
+ :search-field-placeholder="s__('ClusterIntegration|Search regions')"
+ :empty-text="s__('ClusterIntegration|No region found')"
+ :has-errors="hasErrors"
+ :error-message="s__('ClusterIntegration|Could not load regions from your AWS account')"
+ v-bind="$attrs"
+ v-on="$listeners"
+ />
+ <p class="form-text text-muted" v-html="helpText"></p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/role_name_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/role_name_dropdown.vue
deleted file mode 100644
index 70230b294ac..00000000000
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/role_name_dropdown.vue
+++ /dev/null
@@ -1,53 +0,0 @@
-<script>
-import { sprintf, s__ } from '~/locale';
-
-import ClusterFormDropdown from './cluster_form_dropdown.vue';
-
-export default {
- components: {
- ClusterFormDropdown,
- },
- props: {
- roles: {
- type: Array,
- required: false,
- default: () => [],
- },
- loading: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- computed: {
- helpText() {
- return sprintf(
- s__(
- 'ClusterIntegration|Select the IAM Role to allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role name, first create one on %{startLink}Amazon Web Services%{endLink}.',
- ),
- {
- startLink:
- '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">',
- endLink: '</a>',
- },
- false,
- );
- },
- },
-};
-</script>
-<template>
- <div>
- <cluster-form-dropdown
- field-id="eks-role-name"
- field-name="eks-role-name"
- :items="roles"
- :loading="loading"
- :loading-text="s__('ClusterIntegration|Loading IAM Roles')"
- :placeholder="s__('ClusterIntergation|Select role name')"
- :search-field-placeholder="s__('ClusterIntegration|Search IAM Roles')"
- :empty-text="s__('ClusterIntegration|No IAM Roles found')"
- />
- <p class="form-text text-muted" v-html="helpText"></p>
- </div>
-</template>
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/subnet_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/subnet_dropdown.vue
deleted file mode 100644
index e69de29bb2d..00000000000
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/subnet_dropdown.vue
+++ /dev/null
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/vpc_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/vpc_dropdown.vue
deleted file mode 100644
index e69de29bb2d..00000000000
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/vpc_dropdown.vue
+++ /dev/null
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/constants.js b/app/assets/javascripts/create_cluster/eks_cluster/constants.js
new file mode 100644
index 00000000000..339642f991e
--- /dev/null
+++ b/app/assets/javascripts/create_cluster/eks_cluster/constants.js
@@ -0,0 +1,7 @@
+// eslint-disable-next-line import/prefer-default-export
+export const KUBERNETES_VERSIONS = [
+ { name: '1.14', value: '1.14' },
+ { name: '1.13', value: '1.13' },
+ { name: '1.12', value: '1.12' },
+ { name: '1.11', value: '1.11' },
+];
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/index.js b/app/assets/javascripts/create_cluster/eks_cluster/index.js
index c62e5ec101d..1f595e9b2df 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/index.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/index.js
@@ -5,15 +5,22 @@ import createStore from './store';
Vue.use(Vuex);
-export default () =>
- new Vue({
- el: '.js-create-eks-cluster-form-container',
+export default el => {
+ const { gitlabManagedClusterHelpPath, kubernetesIntegrationHelpPath } = el.dataset;
+
+ return new Vue({
+ el,
store: createStore(),
components: {
CreateEksCluster,
},
- data() {},
render(createElement) {
- return createElement('create-eks-cluster');
+ return createElement('create-eks-cluster', {
+ props: {
+ gitlabManagedClusterHelpPath,
+ kubernetesIntegrationHelpPath,
+ },
+ });
},
});
+};
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js
index e69de29bb2d..d982e4db4c1 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js
@@ -0,0 +1,84 @@
+import EC2 from 'aws-sdk/clients/ec2';
+import IAM from 'aws-sdk/clients/iam';
+
+export const fetchRoles = () => {
+ const iam = new IAM();
+
+ return iam
+ .listRoles()
+ .promise()
+ .then(({ Roles: roles }) => roles.map(({ RoleName: name }) => ({ name })));
+};
+
+export const fetchKeyPairs = () => {
+ const ec2 = new EC2();
+
+ return ec2
+ .describeKeyPairs()
+ .promise()
+ .then(({ KeyPairs: keyPairs }) => keyPairs.map(({ RegionName: name }) => ({ name })));
+};
+
+export const fetchRegions = () => {
+ const ec2 = new EC2();
+
+ return ec2
+ .describeRegions()
+ .promise()
+ .then(({ Regions: regions }) =>
+ regions.map(({ RegionName: name }) => ({
+ name,
+ value: name,
+ })),
+ );
+};
+
+export const fetchVpcs = () => {
+ const ec2 = new EC2();
+
+ return ec2
+ .describeVpcs()
+ .promise()
+ .then(({ Vpcs: vpcs }) =>
+ vpcs.map(({ VpcId: id }) => ({
+ value: id,
+ name: id,
+ })),
+ );
+};
+
+export const fetchSubnets = ({ vpc }) => {
+ const ec2 = new EC2();
+
+ return ec2
+ .describeSubnets({
+ Filters: [
+ {
+ Name: 'vpc-id',
+ Values: [vpc],
+ },
+ ],
+ })
+ .promise()
+ .then(({ Subnets: subnets }) => subnets.map(({ SubnetId: id }) => ({ id, name: id })));
+};
+
+export const fetchSecurityGroups = ({ vpc }) => {
+ const ec2 = new EC2();
+
+ return ec2
+ .describeSecurityGroups({
+ Filters: [
+ {
+ Name: 'vpc-id',
+ Values: [vpc],
+ },
+ ],
+ })
+ .promise()
+ .then(({ SecurityGroups: securityGroups }) =>
+ securityGroups.map(({ GroupName: name, GroupId: value }) => ({ name, value })),
+ );
+};
+
+export default () => {};
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
index 861bcddfcc7..917c8da6c3e 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
@@ -1,3 +1,43 @@
-// import awsServices from '../services/aws_services_facade';
+import * as types from './mutation_types';
+
+export const setClusterName = ({ commit }, payload) => {
+ commit(types.SET_CLUSTER_NAME, payload);
+};
+
+export const setEnvironmentScope = ({ commit }, payload) => {
+ commit(types.SET_ENVIRONMENT_SCOPE, payload);
+};
+
+export const setKubernetesVersion = ({ commit }, payload) => {
+ commit(types.SET_KUBERNETES_VERSION, payload);
+};
+
+export const setRegion = ({ commit }, payload) => {
+ commit(types.SET_REGION, payload);
+};
+
+export const setKeyPair = ({ commit }, payload) => {
+ commit(types.SET_KEY_PAIR, payload);
+};
+
+export const setVpc = ({ commit }, payload) => {
+ commit(types.SET_VPC, payload);
+};
+
+export const setSubnet = ({ commit }, payload) => {
+ commit(types.SET_SUBNET, payload);
+};
+
+export const setRole = ({ commit }, payload) => {
+ commit(types.SET_ROLE, payload);
+};
+
+export const setSecurityGroup = ({ commit }, payload) => {
+ commit(types.SET_SECURITY_GROUP, payload);
+};
+
+export const setGitlabManagedCluster = ({ commit }, payload) => {
+ commit(types.SET_GITLAB_MANAGED_CLUSTER, payload);
+};
export default () => {};
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/actions.js
new file mode 100644
index 00000000000..5d250b2e29e
--- /dev/null
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/actions.js
@@ -0,0 +1,14 @@
+import * as types from './mutation_types';
+
+export default fetchItems => ({
+ requestItems: ({ commit }) => commit(types.REQUEST_ITEMS),
+ receiveItemsSuccess: ({ commit }, payload) => commit(types.RECEIVE_ITEMS_SUCCESS, payload),
+ receiveItemsError: ({ commit }, payload) => commit(types.RECEIVE_ITEMS_ERROR, payload),
+ fetchItems: ({ dispatch }, payload) => {
+ dispatch('requestItems');
+
+ return fetchItems(payload)
+ .then(items => dispatch('receiveItemsSuccess', { items }))
+ .catch(error => dispatch('receiveItemsError', { error }));
+ },
+});
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/security_group_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/getters.js
index e69de29bb2d..e69de29bb2d 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/security_group_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/getters.js
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js
new file mode 100644
index 00000000000..07a5821c47d
--- /dev/null
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js
@@ -0,0 +1,13 @@
+import * as getters from './getters';
+import actions from './actions';
+import mutations from './mutations';
+import state from './state';
+
+const createStore = fetchFn => ({
+ actions: actions(fetchFn),
+ getters,
+ mutations,
+ state: state(),
+});
+
+export default createStore;
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types.js b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types.js
new file mode 100644
index 00000000000..48959a73924
--- /dev/null
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types.js
@@ -0,0 +1,3 @@
+export const REQUEST_ITEMS = 'REQUEST_ITEMS';
+export const RECEIVE_ITEMS_SUCCESS = 'REQUEST_ITEMS_SUCCESS';
+export const RECEIVE_ITEMS_ERROR = 'RECEIVE_ITEMS_ERROR';
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutations.js b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutations.js
new file mode 100644
index 00000000000..d09689f1f6c
--- /dev/null
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutations.js
@@ -0,0 +1,16 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.REQUEST_ITEMS](state) {
+ state.isLoadingItems = true;
+ state.loadingItemsError = null;
+ },
+ [types.RECEIVE_ITEMS_SUCCESS](state, { items }) {
+ state.isLoadingItems = false;
+ state.items = items;
+ },
+ [types.RECEIVE_ITEMS_ERROR](state, { error }) {
+ state.isLoadingItems = false;
+ state.loadingItemsError = error;
+ },
+};
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/state.js b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/state.js
new file mode 100644
index 00000000000..b949a24216e
--- /dev/null
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/state.js
@@ -0,0 +1,5 @@
+export default () => ({
+ isLoadingItems: false,
+ items: [],
+ loadingItemsError: null,
+});
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js
index 99e9e35fd1a..d575deafd19 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js
@@ -4,12 +4,42 @@ import * as getters from './getters';
import mutations from './mutations';
import state from './state';
+import clusterDropdownStore from './cluster_dropdown';
+
+import * as awsServices from '../services/aws_services_facade';
+
const createStore = () =>
new Vuex.Store({
actions,
getters,
mutations,
- state,
+ state: state(),
+ modules: {
+ roles: {
+ namespaced: true,
+ ...clusterDropdownStore(awsServices.fetchRoles),
+ },
+ regions: {
+ namespaced: true,
+ ...clusterDropdownStore(awsServices.fetchRegions),
+ },
+ keyPairs: {
+ namespaced: true,
+ ...clusterDropdownStore(awsServices.fetchKeyPairs),
+ },
+ vpcs: {
+ namespaced: true,
+ ...clusterDropdownStore(awsServices.fetchVpcs),
+ },
+ subnets: {
+ namespaced: true,
+ ...clusterDropdownStore(awsServices.fetchSubnets),
+ },
+ securityGroups: {
+ namespaced: true,
+ ...clusterDropdownStore(awsServices.fetchSecurityGroups),
+ },
+ },
});
export default createStore;
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js
index e69de29bb2d..82eb512ac07 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js
@@ -0,0 +1,10 @@
+export const SET_CLUSTER_NAME = 'SET_CLUSTER_NAME';
+export const SET_ENVIRONMENT_SCOPE = 'SET_ENVIRONMENT_SCOPE';
+export const SET_KUBERNETES_VERSION = 'SET_KUBERNETES_VERSION';
+export const SET_REGION = 'SET_REGION';
+export const SET_VPC = 'SET_VPC';
+export const SET_KEY_PAIR = 'SET_KEY_PAIR';
+export const SET_SUBNET = 'SET_SUBNET';
+export const SET_ROLE = 'SET_ROLE';
+export const SET_SECURITY_GROUP = 'SET_SECURITY_GROUP';
+export const SET_GITLAB_MANAGED_CLUSTER = 'SET_GITLAB_MANAGED_CLUSTER';
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js
index e69de29bb2d..79950ac7dce 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js
@@ -0,0 +1,34 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_CLUSTER_NAME](state, { clusterName }) {
+ state.clusterName = clusterName;
+ },
+ [types.SET_ENVIRONMENT_SCOPE](state, { environmentScope }) {
+ state.environmentScope = environmentScope;
+ },
+ [types.SET_KUBERNETES_VERSION](state, { kubernetesVersion }) {
+ state.kubernetesVersion = kubernetesVersion;
+ },
+ [types.SET_REGION](state, { region }) {
+ state.selectedRegion = region;
+ },
+ [types.SET_KEY_PAIR](state, { keyPair }) {
+ state.selectedKeyPair = keyPair;
+ },
+ [types.SET_VPC](state, { vpc }) {
+ state.selectedVpc = vpc;
+ },
+ [types.SET_SUBNET](state, { subnet }) {
+ state.selectedSubnet = subnet;
+ },
+ [types.SET_ROLE](state, { role }) {
+ state.selectedRole = role;
+ },
+ [types.SET_SECURITY_GROUP](state, { securityGroup }) {
+ state.selectedSecurityGroup = securityGroup;
+ },
+ [types.SET_GITLAB_MANAGED_CLUSTER](state, { gitlabManagedCluster }) {
+ state.gitlabManagedCluster = gitlabManagedCluster;
+ },
+};
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js
index 9754ccfeeaf..bf74213bdce 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js
@@ -1,19 +1,18 @@
+import { KUBERNETES_VERSIONS } from '../constants';
+
export default () => ({
isValidatingCredentials: false,
validCredentials: false,
- isLoadingRoles: false,
- isLoadingVPCs: false,
- isLoadingSubnets: false,
- isLoadingSecurityGroups: false,
-
- roles: [],
- vpcs: [],
- subnets: [],
- securityGroups: [],
-
+ clusterName: '',
+ environmentScope: '*',
+ kubernetesVersion: [KUBERNETES_VERSIONS].value,
+ selectedRegion: '',
selectedRole: '',
- selectedVPC: '',
+ selectedKeyPair: '',
+ selectedVpc: '',
selectedSubnet: '',
selectedSecurityGroup: '',
+
+ gitlabManagedCluster: true,
});
diff --git a/app/assets/javascripts/create_item_dropdown.js b/app/assets/javascripts/create_item_dropdown.js
index fa0f04c7d82..95b890b04c1 100644
--- a/app/assets/javascripts/create_item_dropdown.js
+++ b/app/assets/javascripts/create_item_dropdown.js
@@ -1,4 +1,5 @@
import _ from 'underscore';
+import '~/gl_dropdown';
export default class CreateItemDropdown {
/**
diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/create_label.js
index eac0e37bcaa..9c0ed7f79d4 100644
--- a/app/assets/javascripts/create_label.js
+++ b/app/assets/javascripts/create_label.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, prefer-arrow-callback */
+/* eslint-disable func-names */
import $ from 'jquery';
import Api from './api';
@@ -50,7 +50,7 @@ export default class CreateLabelDropdown {
this.$dropdownBack.on('click', this.resetForm.bind(this));
- this.$cancelButton.on('click', function(e) {
+ this.$cancelButton.on('click', e => {
e.preventDefault();
e.stopPropagation();
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue
index 63549596fac..fc6d83bf96c 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue
@@ -34,7 +34,7 @@ export default {
class="more-actions-toggle btn btn-transparent p-0"
data-toggle="dropdown"
>
- <icon css-classes="icon" name="ellipsis_v" />
+ <icon class="icon" name="ellipsis_v" />
</gl-button>
<ul class="more-actions-dropdown dropdown-menu dropdown-open-left">
<slot name="dropdown-options"></slot>
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
index 7744984edfc..cd67ba5fab8 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
@@ -3,7 +3,6 @@ import Vue from 'vue';
import Cookies from 'js-cookie';
import { GlEmptyState } from '@gitlab/ui';
import filterMixins from 'ee_else_ce/analytics/cycle_analytics/mixins/filter_mixins';
-import addStageMixin from 'ee_else_ce/analytics/cycle_analytics/mixins/add_stage_mixin';
import Flash from '../flash';
import { __ } from '~/locale';
import Translate from '../vue_shared/translate';
@@ -44,12 +43,8 @@ export default () => {
DateRangeDropdown: () =>
import('ee_component/analytics/shared/components/date_range_dropdown.vue'),
'stage-nav-item': stageNavItem,
- CustomStageForm: () =>
- import('ee_component/analytics/cycle_analytics/components/custom_stage_form.vue'),
- AddStageButton: () =>
- import('ee_component/analytics/cycle_analytics/components/add_stage_button.vue'),
},
- mixins: [filterMixins, addStageMixin],
+ mixins: [filterMixins],
data() {
return {
store: CycleAnalyticsStore,
@@ -129,7 +124,6 @@ export default () => {
return;
}
- this.hideAddStageForm();
this.isLoadingStage = true;
this.store.setStageEvents([], stage);
this.store.setActiveStage(stage);
diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js
index 0687028ca54..27990b0a45e 100644
--- a/app/assets/javascripts/diff_notes/services/resolve.js
+++ b/app/assets/javascripts/diff_notes/services/resolve.js
@@ -2,7 +2,6 @@
import Vue from 'vue';
import Flash from '../../flash';
-import '../../vue_shared/vue_resource_interceptor';
import { __ } from '~/locale';
window.gl = window.gl || {};
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index 761fd1583ed..43a7703f611 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -121,7 +121,7 @@ export default {
<div class="label label-monospace monospace" v-text="commit.short_id"></div>
<clipboard-button
:text="commit.id"
- :title="__('Copy commit SHA to clipboard')"
+ :title="__('Copy commit SHA')"
class="btn btn-default"
/>
</div>
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index bfcc726a030..665328eb234 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -209,7 +209,7 @@ export default {
</a>
<clipboard-button
- :title="__('Copy file path to clipboard')"
+ :title="__('Copy file path')"
:text="diffFile.file_path"
:gfm="gfmCopyText"
css-class="btn-default btn-transparent btn-clipboard"
diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue
index aee01409db7..1eb17588376 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue
@@ -45,12 +45,11 @@ export default {
:data-commit-id="commitId"
class="code diff-wrap-lines js-syntax-highlight text-file js-diff-inline-view"
>
- <!-- Need to insert an empty row to solve "table-layout:fixed" equal width when expansion row is the first line -->
- <tr>
- <td style="width: 50px;"></td>
- <td style="width: 50px;"></td>
- <td></td>
- </tr>
+ <colgroup>
+ <col style="width: 50px;" />
+ <col style="width: 50px;" />
+ <col />
+ </colgroup>
<tbody>
<template v-for="(line, index) in diffLines">
<inline-diff-expansion-row
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
index d400eb2c586..88baac092a1 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
@@ -45,13 +45,12 @@ export default {
:data-commit-id="commitId"
class="code diff-wrap-lines js-syntax-highlight text-file"
>
- <!-- Need to insert an empty row to solve "table-layout:fixed" equal width when expansion row is the first line -->
- <tr>
- <td style="width: 50px;"></td>
- <td></td>
- <td style="width: 50px;"></td>
- <td></td>
- </tr>
+ <colgroup>
+ <col style="width: 50px;" />
+ <col />
+ <col style="width: 50px;" />
+ <col />
+ </colgroup>
<tbody>
<template v-for="(line, index) in diffLines">
<parallel-diff-expansion-row
diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue
index 2cc3412e075..1ea4e30a7c1 100644
--- a/app/assets/javascripts/environments/components/stop_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue
@@ -1,7 +1,7 @@
<script>
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { GlTooltipDirective } from '@gitlab/ui';
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { s__, sprintf } from '~/locale';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import eventHub from '../event_hub';
@@ -11,7 +11,7 @@ export default {
name: 'StopEnvironmentModal',
components: {
- GlModal,
+ GlModal: DeprecatedModal2,
LoadingButton,
},
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
index b1d568532a6..cd298e2c692 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -4,13 +4,15 @@ import { GlEmptyState, GlButton, GlLink, GlLoadingIcon, GlTable } from '@gitlab/
import Icon from '~/vue_shared/components/icon.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { __ } from '~/locale';
+import TrackEventDirective from '~/vue_shared/directives/track_event';
+import { trackViewInSentryOptions, trackClickErrorLinkToSentryOptions } from '../utils';
export default {
fields: [
- { key: 'error', label: __('Open errors') },
+ { key: 'error', label: __('Open errors'), thClass: 'w-70p' },
{ key: 'events', label: __('Events') },
{ key: 'users', label: __('Users') },
- { key: 'lastSeen', label: __('Last seen') },
+ { key: 'lastSeen', label: __('Last seen'), thClass: 'w-15p' },
],
components: {
GlEmptyState,
@@ -21,6 +23,9 @@ export default {
Icon,
TimeAgo,
},
+ directives: {
+ TrackEvent: TrackEventDirective,
+ },
props: {
indexPath: {
type: String,
@@ -53,6 +58,8 @@ export default {
},
methods: {
...mapActions(['startPolling', 'restartPolling']),
+ trackViewInSentryOptions,
+ trackClickErrorLinkToSentryOptions,
},
};
</script>
@@ -65,42 +72,52 @@ export default {
</div>
<div v-else>
<div class="d-flex justify-content-end">
- <gl-button class="my-3 ml-auto" variant="primary" :href="externalUrl" target="_blank">
+ <gl-button
+ v-track-event="trackViewInSentryOptions(externalUrl)"
+ class="my-3 ml-auto"
+ variant="primary"
+ :href="externalUrl"
+ target="_blank"
+ >
{{ __('View in Sentry') }}
- <icon name="external-link" />
+ <icon name="external-link" class="flex-shrink-0" />
</gl-button>
</div>
- <gl-table :items="errors" :fields="$options.fields" :show-empty="true">
+
+ <gl-table :items="errors" :fields="$options.fields" :show-empty="true" fixed stacked="sm">
<template slot="HEAD_events" slot-scope="data">
- <div class="text-right">{{ data.label }}</div>
+ <div class="text-md-right">{{ data.label }}</div>
</template>
<template slot="HEAD_users" slot-scope="data">
- <div class="text-right">{{ data.label }}</div>
+ <div class="text-md-right">{{ data.label }}</div>
</template>
<template slot="error" slot-scope="errors">
<div class="d-flex flex-column">
- <div class="d-flex">
- <gl-link :href="errors.item.externalUrl" class="d-flex text-dark" target="_blank">
- <strong>{{ errors.item.title.trim() }}</strong>
- <icon name="external-link" class="ml-1" />
- </gl-link>
- <span class="text-secondary ml-2">{{ errors.item.culprit }}</span>
- </div>
- {{ errors.item.message || __('No details available') }}
+ <gl-link
+ v-track-event="trackClickErrorLinkToSentryOptions(errors.item.externalUrl)"
+ :href="errors.item.externalUrl"
+ class="d-flex text-dark"
+ target="_blank"
+ >
+ <strong class="text-truncate">{{ errors.item.title.trim() }}</strong>
+ <icon name="external-link" class="ml-1 flex-shrink-0" />
+ </gl-link>
+ <span class="text-secondary text-truncate">
+ {{ errors.item.culprit }}
+ </span>
</div>
</template>
<template slot="events" slot-scope="errors">
- <div class="text-right">{{ errors.item.count }}</div>
+ <div class="text-md-right">{{ errors.item.count }}</div>
</template>
<template slot="users" slot-scope="errors">
- <div class="text-right">{{ errors.item.userCount }}</div>
+ <div class="text-md-right">{{ errors.item.userCount }}</div>
</template>
<template slot="lastSeen" slot-scope="errors">
<div class="d-flex align-items-center">
- <icon name="calendar" css-classes="text-secondary mr-1" />
<time-ago :time="errors.item.lastSeen" class="text-secondary" />
</div>
</template>
diff --git a/app/assets/javascripts/error_tracking/utils.js b/app/assets/javascripts/error_tracking/utils.js
new file mode 100644
index 00000000000..b832b1371b1
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/utils.js
@@ -0,0 +1,23 @@
+/* eslint-disable @gitlab/i18n/no-non-i18n-strings */
+
+/**
+ * Tracks snowplow event when user clicks View in Sentry btn
+ * @param {String} externalUrl that will be send as a property for the event
+ */
+export const trackViewInSentryOptions = url => ({
+ category: 'Error Tracking',
+ action: 'click_view_in_sentry',
+ label: 'External Url',
+ property: url,
+});
+
+/**
+ * Tracks snowplow event when User clicks on error link to Sentry
+ * @param {String} externalUrl that will be send as a property for the event
+ */
+export const trackClickErrorLinkToSentryOptions = url => ({
+ category: 'Error Tracking',
+ action: 'click_error_link_to_sentry',
+ label: 'Error Link',
+ property: url,
+});
diff --git a/app/assets/javascripts/event_tracking/issue_sidebar.js b/app/assets/javascripts/event_tracking/issue_sidebar.js
deleted file mode 100644
index 6909f82c66f..00000000000
--- a/app/assets/javascripts/event_tracking/issue_sidebar.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export const initSidebarTracking = () => {};
-export const trackEvent = () => {};
diff --git a/app/assets/javascripts/event_tracking/notes.js b/app/assets/javascripts/event_tracking/notes.js
deleted file mode 100644
index 1f70290c397..00000000000
--- a/app/assets/javascripts/event_tracking/notes.js
+++ /dev/null
@@ -1,2 +0,0 @@
-// Noop function which has a EE counter-part
-export default () => {};
diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js
index 77080691dcb..c21fba06d42 100644
--- a/app/assets/javascripts/filterable_list.js
+++ b/app/assets/javascripts/filterable_list.js
@@ -22,6 +22,7 @@ export default class FilterableList {
getPagePath() {
const action = this.filterForm.getAttribute('action');
+ // eslint-disable-next-line no-jquery/no-serialize
const params = $(this.filterForm).serialize();
return `${action}${action.indexOf('?') > 0 ? '&' : '?'}${params}`;
}
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index 660f0f0ba3e..fc9c5827ed4 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -40,13 +40,17 @@ const createFlashEl = (message, type) => `
<div class="flash-content flash-${type} rounded">
<div class="flash-text">
${_.escape(message)}
- ${spriteIcon('close', 'close-icon')}
+ <div class="close-icon-wrapper js-close-icon">
+ ${spriteIcon('close', 'close-icon')}
+ </div>
</div>
</div>
`;
const removeFlashClickListener = (flashEl, fadeTransition) => {
- flashEl.addEventListener('click', () => hideFlash(flashEl, fadeTransition));
+ flashEl
+ .querySelector('.js-close-icon')
+ .addEventListener('click', () => hideFlash(flashEl, fadeTransition));
};
/*
@@ -78,7 +82,6 @@ const createFlash = function createFlash(
flashContainer.innerHTML = createFlashEl(message, type);
const flashEl = flashContainer.querySelector(`.flash-${type}`);
- removeFlashClickListener(flashEl, fadeTransition);
if (actionConfig) {
flashEl.innerHTML += createAction(actionConfig);
@@ -90,6 +93,8 @@ const createFlash = function createFlash(
}
}
+ removeFlashClickListener(flashEl, fadeTransition);
+
flashContainer.style.display = 'block';
if (addBodyClass) document.body.classList.add('flash-shown');
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index b308cd9c236..db3ad0bb4c9 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -337,6 +337,7 @@ class GfmAutoComplete {
},
// eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${title}',
+ limit: 20,
callbacks: {
...this.getDefaultCallbacks(),
beforeSave(merges) {
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index f49246cf07b..4e1b4f2652c 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-underscore-dangle, no-var, one-var, vars-on-top, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func */
+/* eslint-disable func-names, no-underscore-dangle, no-var, one-var, vars-on-top, no-shadow, no-cond-assign, no-return-assign, no-else-return, camelcase, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, no-param-reassign, no-loop-func */
import $ from 'jquery';
import _ from 'underscore';
@@ -35,13 +35,13 @@ GitLabDropdownInput = (function() {
);
this.input
- .on('keydown', function(e) {
+ .on('keydown', e => {
var keyCode = e.which;
if (keyCode === 13 && !options.elIsInput) {
e.preventDefault();
}
})
- .on('input', function(e) {
+ .on('input', e => {
var val = e.currentTarget.value || _this.options.inputFieldName;
val = val
.split(' ')
@@ -95,42 +95,33 @@ GitLabDropdownFilter = (function() {
// Key events
timeout = '';
this.input
- .on('keydown', function(e) {
+ .on('keydown', e => {
var keyCode = e.which;
if (keyCode === 13 && !options.elIsInput) {
e.preventDefault();
}
})
- .on(
- 'input',
- function() {
- if (this.input.val() !== '' && !$inputContainer.hasClass(HAS_VALUE_CLASS)) {
- $inputContainer.addClass(HAS_VALUE_CLASS);
- } else if (this.input.val() === '' && $inputContainer.hasClass(HAS_VALUE_CLASS)) {
- $inputContainer.removeClass(HAS_VALUE_CLASS);
- }
- // Only filter asynchronously only if option remote is set
- if (this.options.remote) {
- clearTimeout(timeout);
- return (timeout = setTimeout(
- function() {
- $inputContainer.parent().addClass('is-loading');
-
- return this.options.query(
- this.input.val(),
- function(data) {
- $inputContainer.parent().removeClass('is-loading');
- return this.options.callback(data);
- }.bind(this),
- );
- }.bind(this),
- 250,
- ));
- } else {
- return this.filter(this.input.val());
- }
- }.bind(this),
- );
+ .on('input', () => {
+ if (this.input.val() !== '' && !$inputContainer.hasClass(HAS_VALUE_CLASS)) {
+ $inputContainer.addClass(HAS_VALUE_CLASS);
+ } else if (this.input.val() === '' && $inputContainer.hasClass(HAS_VALUE_CLASS)) {
+ $inputContainer.removeClass(HAS_VALUE_CLASS);
+ }
+ // Only filter asynchronously only if option remote is set
+ if (this.options.remote) {
+ clearTimeout(timeout);
+ return (timeout = setTimeout(() => {
+ $inputContainer.parent().addClass('is-loading');
+
+ return this.options.query(this.input.val(), data => {
+ $inputContainer.parent().removeClass('is-loading');
+ return this.options.callback(data);
+ });
+ }, 250));
+ } else {
+ return this.filter(this.input.val());
+ }
+ });
}
GitLabDropdownFilter.prototype.shouldBlur = function(keyCode) {
@@ -175,9 +166,7 @@ GitLabDropdownFilter = (function() {
key: this.options.keys,
});
if (tmp.length) {
- results[key] = tmp.map(function(item) {
- return item;
- });
+ results[key] = tmp.map(item => item);
}
}
}
@@ -283,7 +272,7 @@ GitLabDropdown = (function() {
NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-item';
- SELECTABLE_CLASSES = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ', .option-hidden)';
+ SELECTABLE_CLASSES = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES}, .option-hidden)`;
CURSOR_SELECT_SCROLL_PADDING = 5;
@@ -370,9 +359,9 @@ GitLabDropdown = (function() {
instance: this,
elements: (function(_this) {
return function() {
- selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')';
+ selector = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES})`;
if (_this.dropdown.find('.dropdown-toggle-page').length) {
- selector = '.dropdown-page-one ' + selector;
+ selector = `.dropdown-page-one ${selector}`;
}
return $(selector, this.instance.dropdown);
};
@@ -388,7 +377,7 @@ GitLabDropdown = (function() {
if (_this.filterInput.val() !== '') {
selector = SELECTABLE_CLASSES;
if (_this.dropdown.find('.dropdown-toggle-page').length) {
- selector = '.dropdown-page-one ' + selector;
+ selector = `.dropdown-page-one ${selector}`;
}
if ($(_this.el).is('input')) {
currentIndex = -1;
@@ -453,32 +442,28 @@ GitLabDropdown = (function() {
if (this.dropdown.find('.dropdown-toggle-page').length) {
selector = '.dropdown-page-one .dropdown-content a';
}
- this.dropdown.on(
- 'click',
- selector,
- function(e) {
- var $el, selected, selectedObj, isMarking;
- $el = $(e.currentTarget);
- selected = self.rowClicked($el);
- selectedObj = selected ? selected[0] : null;
- isMarking = selected ? selected[1] : null;
- if (this.options.clicked) {
- this.options.clicked.call(this, {
- selectedObj,
- $el,
- e,
- isMarking,
- });
- }
+ this.dropdown.on('click', selector, e => {
+ var $el, selected, selectedObj, isMarking;
+ $el = $(e.currentTarget);
+ selected = self.rowClicked($el);
+ selectedObj = selected ? selected[0] : null;
+ isMarking = selected ? selected[1] : null;
+ if (this.options.clicked) {
+ this.options.clicked.call(this, {
+ selectedObj,
+ $el,
+ e,
+ isMarking,
+ });
+ }
- // Update label right after all modifications in dropdown has been done
- if (this.options.toggleLabel) {
- this.updateLabel(selectedObj, $el, this);
- }
+ // Update label right after all modifications in dropdown has been done
+ if (this.options.toggleLabel) {
+ this.updateLabel(selectedObj, $el, this);
+ }
- $el.trigger('blur');
- }.bind(this),
- );
+ $el.trigger('blur');
+ });
}
}
@@ -525,9 +510,7 @@ GitLabDropdown = (function() {
name,
),
);
- this.renderData(groupData, name).map(function(item) {
- return html.push(item);
- });
+ this.renderData(groupData, name).map(item => html.push(item));
}
} else {
// Render each row
@@ -708,9 +691,9 @@ GitLabDropdown = (function() {
return text
.split('')
- .map(function(character, i) {
+ .map((character, i) => {
if (indexOf.call(occurrences, i) !== -1) {
- return '<b>' + character + '</b>';
+ return `<b>${character}</b>`;
} else {
return character;
}
@@ -734,6 +717,7 @@ GitLabDropdown = (function() {
selectedObject = this.renderedData[groupName][selectedIndex];
} else {
selectedIndex = el.closest('li').index();
+ this.selectedIndex = selectedIndex;
selectedObject = this.renderedData[selectedIndex];
}
}
@@ -755,9 +739,7 @@ GitLabDropdown = (function() {
} else if (value != null) {
field = this.dropdown
.parent()
- .find(
- "input[name='" + fieldName + "'][value='" + value.toString().replace(/'/g, "\\'") + "']",
- );
+ .find(`input[name='${fieldName}'][value='${value.toString().replace(/'/g, "\\'")}']`);
}
if (this.options.isSelectable && !this.options.isSelectable(selectedObject, el)) {
@@ -783,11 +765,11 @@ GitLabDropdown = (function() {
} else {
isMarking = true;
if (!this.options.multiSelect || el.hasClass('dropdown-clear-active')) {
- this.dropdown.find('.' + ACTIVE_CLASS).removeClass(ACTIVE_CLASS);
+ this.dropdown.find(`.${ACTIVE_CLASS}`).removeClass(ACTIVE_CLASS);
if (!isInput) {
this.dropdown
.parent()
- .find("input[name='" + fieldName + "']")
+ .find(`input[name='${fieldName}']`)
.remove();
}
}
@@ -826,7 +808,7 @@ GitLabDropdown = (function() {
var $input;
// Create hidden input for form
if (single) {
- $('input[name="' + fieldName + '"]').remove();
+ $(`input[name="${fieldName}"]`).remove();
}
$input = $('<input>')
@@ -854,12 +836,12 @@ GitLabDropdown = (function() {
var $el, selector;
// If we pass an option index
if (typeof index !== 'undefined') {
- selector = SELECTABLE_CLASSES + ':eq(' + index + ') a';
+ selector = `${SELECTABLE_CLASSES}:eq(${index}) a`;
} else {
selector = '.dropdown-content .is-focused';
}
if (this.dropdown.find('.dropdown-toggle-page').length) {
- selector = '.dropdown-page-one ' + selector;
+ selector = `.dropdown-page-one ${selector}`;
}
// simulate a click on the first link
$el = $(selector, this.dropdown);
@@ -878,7 +860,7 @@ GitLabDropdown = (function() {
ARROW_KEY_CODES = [38, 40];
selector = SELECTABLE_CLASSES;
if (this.dropdown.find('.dropdown-toggle-page').length) {
- selector = '.dropdown-page-one ' + selector;
+ selector = `.dropdown-page-one ${selector}`;
}
return $('body').on(
'keydown',
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 830385941d8..ede74d18ed4 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -104,11 +104,11 @@ export default {
/>
<div
:class="{ 'd-sm-flex': !group.isChildrenLoading }"
- class="avatar-container rect-avatar s32 d-none flex-grow-0 flex-shrink-0 "
+ class="avatar-container rect-avatar s40 d-none flex-grow-0 flex-shrink-0 "
>
<a :href="group.relativePath" class="no-expand">
- <img v-if="hasAvatar" :src="group.avatarUrl" class="avatar s32" />
- <identicon v-else :entity-id="group.id" :entity-name="group.name" size-class="s32" />
+ <img v-if="hasAvatar" :src="group.avatarUrl" class="avatar s40" />
+ <identicon v-else :entity-id="group.id" :entity-name="group.name" size-class="s40" />
</a>
</div>
<div class="group-text-container d-flex flex-fill align-items-center">
diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue
index cafd22731b1..4b569970204 100644
--- a/app/assets/javascripts/groups/components/item_actions.vue
+++ b/app/assets/javascripts/groups/components/item_actions.vue
@@ -56,7 +56,7 @@ export default {
class="leave-group btn btn-xs no-expand"
@click.prevent="onLeaveGroup"
>
- <icon name="leave" css-classes="position-top-0" />
+ <icon name="leave" class="position-top-0" />
</a>
<a
v-if="group.canEdit"
@@ -68,7 +68,7 @@ export default {
data-placement="bottom"
class="edit-group btn btn-xs no-expand"
>
- <icon name="settings" css-classes="position-top-0" />
+ <icon name="settings" class="position-top-0" />
</a>
</div>
</template>
diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js
index f1cc6756583..a5e38022b8d 100644
--- a/app/assets/javascripts/groups_select.js
+++ b/app/assets/javascripts/groups_select.js
@@ -4,96 +4,97 @@ import Api from './api';
import { normalizeHeaders } from './lib/utils/common_utils';
import { __ } from '~/locale';
-export default function groupsSelect() {
- import(/* webpackChunkName: 'select2' */ 'select2/select2')
- .then(() => {
- // Needs to be accessible in rspec
- window.GROUP_SELECT_PER_PAGE = 20;
- $('.ajax-groups-select').each(function setAjaxGroupsSelect2() {
- const $select = $(this);
- const allAvailable = $select.data('allAvailable');
- const skipGroups = $select.data('skipGroups') || [];
- const parentGroupID = $select.data('parentId');
- const groupsPath = parentGroupID
- ? Api.subgroupsPath.replace(':id', parentGroupID)
- : Api.groupsPath;
+const groupsSelect = () => {
+ // Needs to be accessible in rspec
+ window.GROUP_SELECT_PER_PAGE = 20;
+ $('.ajax-groups-select').each(function setAjaxGroupsSelect2() {
+ const $select = $(this);
+ const allAvailable = $select.data('allAvailable');
+ const skipGroups = $select.data('skipGroups') || [];
+ const parentGroupID = $select.data('parentId');
+ const groupsPath = parentGroupID
+ ? Api.subgroupsPath.replace(':id', parentGroupID)
+ : Api.groupsPath;
- $select.select2({
- placeholder: __('Search for a group'),
- allowClear: $select.hasClass('allowClear'),
- multiple: $select.hasClass('multiselect'),
- minimumInputLength: 0,
- ajax: {
- url: Api.buildUrl(groupsPath),
- dataType: 'json',
- quietMillis: 250,
- transport(params) {
- axios[params.type.toLowerCase()](params.url, {
- params: params.data,
- })
- .then(res => {
- const results = res.data || [];
- const headers = normalizeHeaders(res.headers);
- const currentPage = parseInt(headers['X-PAGE'], 10) || 0;
- const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0;
- const more = currentPage < totalPages;
+ $select.select2({
+ placeholder: __('Search for a group'),
+ allowClear: $select.hasClass('allowClear'),
+ multiple: $select.hasClass('multiselect'),
+ minimumInputLength: 0,
+ ajax: {
+ url: Api.buildUrl(groupsPath),
+ dataType: 'json',
+ quietMillis: 250,
+ transport(params) {
+ axios[params.type.toLowerCase()](params.url, {
+ params: params.data,
+ })
+ .then(res => {
+ const results = res.data || [];
+ const headers = normalizeHeaders(res.headers);
+ const currentPage = parseInt(headers['X-PAGE'], 10) || 0;
+ const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0;
+ const more = currentPage < totalPages;
- params.success({
- results,
- pagination: {
- more,
- },
- });
- })
- .catch(params.error);
- },
- data(search, page) {
- return {
- search,
- page,
- per_page: window.GROUP_SELECT_PER_PAGE,
- all_available: allAvailable,
- };
- },
- results(data, page) {
- if (data.length) return { results: [] };
+ params.success({
+ results,
+ pagination: {
+ more,
+ },
+ });
+ })
+ .catch(params.error);
+ },
+ data(search, page) {
+ return {
+ search,
+ page,
+ per_page: window.GROUP_SELECT_PER_PAGE,
+ all_available: allAvailable,
+ };
+ },
+ results(data, page) {
+ if (data.length) return { results: [] };
- const groups = data.length ? data : data.results || [];
- const more = data.pagination ? data.pagination.more : false;
- const results = groups.filter(group => skipGroups.indexOf(group.id) === -1);
+ const groups = data.length ? data : data.results || [];
+ const more = data.pagination ? data.pagination.more : false;
+ const results = groups.filter(group => skipGroups.indexOf(group.id) === -1);
- return {
- results,
- page,
- more,
- };
- },
- },
- // eslint-disable-next-line consistent-return
- initSelection(element, callback) {
- const id = $(element).val();
- if (id !== '') {
- return Api.group(id, callback);
- }
- },
- formatResult(object) {
- return `<div class='group-result'> <div class='group-name'>${object.full_name}</div> <div class='group-path'>${object.full_path}</div> </div>`;
- },
- formatSelection(object) {
- return object.full_name;
- },
- dropdownCssClass: 'ajax-groups-dropdown select2-infinite',
- // we do not want to escape markup since we are displaying html in results
- escapeMarkup(m) {
- return m;
- },
- });
+ return {
+ results,
+ page,
+ more,
+ };
+ },
+ },
+ // eslint-disable-next-line consistent-return
+ initSelection(element, callback) {
+ const id = $(element).val();
+ if (id !== '') {
+ return Api.group(id, callback);
+ }
+ },
+ formatResult(object) {
+ return `<div class='group-result'> <div class='group-name'>${object.full_name}</div> <div class='group-path'>${object.full_path}</div> </div>`;
+ },
+ formatSelection(object) {
+ return object.full_name;
+ },
+ dropdownCssClass: 'ajax-groups-dropdown select2-infinite',
+ // we do not want to escape markup since we are displaying html in results
+ escapeMarkup(m) {
+ return m;
+ },
+ });
- $select.on('select2-loaded', () => {
- const dropdown = document.querySelector('.select2-infinite .select2-results');
- dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`;
- });
- });
- })
+ $select.on('select2-loaded', () => {
+ const dropdown = document.querySelector('.select2-infinite .select2-results');
+ dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`;
+ });
+ });
+};
+
+export default () =>
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(groupsSelect)
.catch(() => {});
-}
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index 3d846310008..fdd27e08793 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -15,11 +15,10 @@ import { parseBoolean } from '~/lib/utils/common_utils';
*/
export default function initTodoToggle() {
$(document).on('todo:toggle', (e, count) => {
- const parsedCount = parseInt(count, 10);
const $todoPendingCount = $('.todos-count');
- $todoPendingCount.text(highCountTrim(parsedCount));
- $todoPendingCount.toggleClass('hidden', parsedCount === 0);
+ $todoPendingCount.text(highCountTrim(count));
+ $todoPendingCount.toggleClass('hidden', count === 0);
});
}
diff --git a/app/assets/javascripts/ide/.eslintrc.yml b/app/assets/javascripts/ide/.eslintrc.yml
new file mode 100644
index 00000000000..92b96d717be
--- /dev/null
+++ b/app/assets/javascripts/ide/.eslintrc.yml
@@ -0,0 +1,3 @@
+rules:
+ # https://gitlab.com/gitlab-org/gitlab/issues/33024
+ promise/no-nesting: off
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
index 11d5d9639b6..6b2ef34c960 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
@@ -43,7 +43,12 @@ export default {
<template>
<div class="d-flex ide-commit-editor-header align-items-center">
<file-icon :file-name="activeFile.name" :size="16" class="mr-2" />
- <strong class="mr-2"> {{ activeFile.path }} </strong>
+ <strong class="mr-2">
+ <template v-if="activeFile.prevPath && activeFile.prevPath !== activeFile.path">
+ {{ activeFile.prevPath }} &#x2192;
+ </template>
+ {{ activeFile.path }}
+ </strong>
<changed-file-icon :file="activeFile" :is-centered="false" />
<div class="ml-auto">
<button
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
index 4f1260de0bc..e16918ae025 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
@@ -3,7 +3,7 @@ import $ from 'jquery';
import { mapActions } from 'vuex';
import { __, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import ListItem from './list_item.vue';
@@ -11,7 +11,7 @@ export default {
components: {
Icon,
ListItem,
- GlModal,
+ GlModal: DeprecatedModal2,
},
directives: {
tooltip,
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
index 3156a398113..b6fc567f8cc 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue
@@ -86,7 +86,7 @@ export default {
data-placement="left"
class="append-bottom-10"
>
- <icon :name="additionIconName" :size="18" :css-classes="addedFilesIconClass" />
+ <icon :name="additionIconName" :size="18" :class="addedFilesIconClass" />
</div>
{{ addedFilesLength }}
<div
@@ -96,7 +96,7 @@ export default {
data-placement="left"
class="prepend-top-10 append-bottom-10"
>
- <icon :name="modifiedIconName" :size="18" :css-classes="modifiedFilesClass" />
+ <icon :name="modifiedIconName" :size="18" :class="modifiedFilesClass" />
</div>
{{ modifiedFilesLength }}
</div>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
index 302adccd759..230dfaf047b 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
@@ -110,11 +110,14 @@ export default {
>
<span class="multi-file-commit-list-file-path d-flex align-items-center">
<file-icon :file-name="file.name" class="append-right-8" />
+ <template v-if="file.prevName && file.prevName !== file.name">
+ {{ file.prevName }} &#x2192;
+ </template>
{{ file.name }}
</span>
<div class="ml-auto d-flex align-items-center">
<div class="d-flex align-items-center ide-commit-list-changed-icon">
- <icon :name="iconName" :size="16" :css-classes="iconClass" />
+ <icon :name="iconName" :size="16" :class="iconClass" />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue
index 09c9d135614..c14b8a47841 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue
@@ -4,12 +4,12 @@ import { mapActions } from 'vuex';
import { sprintf, __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
export default {
components: {
Icon,
- GlModal,
+ GlModal: DeprecatedModal2,
},
directives: {
tooltip,
diff --git a/app/assets/javascripts/ide/components/external_link.vue b/app/assets/javascripts/ide/components/external_link.vue
index d1857f0176a..558da9b706e 100644
--- a/app/assets/javascripts/ide/components/external_link.vue
+++ b/app/assets/javascripts/ide/components/external_link.vue
@@ -28,7 +28,7 @@ export default {
rel="noopener noreferrer"
>
<span class="vertical-align-middle">{{ __('Open in file view') }}</span>
- <icon :size="16" name="external-link" css-classes="vertical-align-middle space-right" />
+ <icon :size="16" name="external-link" class="vertical-align-middle space-right" />
</a>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue
index 48be97c8952..f0bedcfbd6b 100644
--- a/app/assets/javascripts/ide/components/file_row_extra.vue
+++ b/app/assets/javascripts/ide/components/file_row_extra.vue
@@ -34,6 +34,9 @@ export default {
'getUnstagedFilesCountForPath',
'getStagedFilesCountForPath',
]),
+ isTree() {
+ return this.file.type === 'tree';
+ },
folderUnstagedCount() {
return this.getUnstagedFilesCountForPath(this.file.path);
},
@@ -58,10 +61,13 @@ export default {
});
},
showTreeChangesCount() {
- return this.file.type === 'tree' && this.changesCount > 0 && !this.file.opened;
+ return this.isTree && this.changesCount > 0 && !this.file.opened;
+ },
+ isModified() {
+ return this.file.changed || this.file.tempFile || this.file.staged || this.file.prevPath;
},
showChangedFileIcon() {
- return this.file.changed || this.file.tempFile || this.file.staged;
+ return !this.isTree && this.isModified;
},
},
};
@@ -79,7 +85,7 @@ export default {
data-container="body"
data-placement="right"
name="file-modified"
- css-classes="prepend-left-5 ide-file-modified"
+ class="prepend-left-5 ide-file-modified"
/>
</span>
<changed-file-icon
diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue
index 1af86a94482..95782b2c88a 100644
--- a/app/assets/javascripts/ide/components/ide_tree_list.vue
+++ b/app/assets/javascripts/ide/components/ide_tree_list.vue
@@ -30,9 +30,6 @@ export default {
showLoading() {
return !this.currentTree || this.currentTree.loading;
},
- actualTreeList() {
- return this.currentTree.tree.filter(entry => !entry.moved);
- },
},
mounted() {
this.updateViewer(this.viewerType);
@@ -57,9 +54,9 @@ export default {
<slot name="header"></slot>
</header>
<div class="ide-tree-body h-100">
- <template v-if="actualTreeList.length">
+ <template v-if="currentTree.tree.length">
<file-row
- v-for="file in actualTreeList"
+ v-for="file in currentTree.tree"
:key="file.key"
:file="file"
:level="0"
diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue
index b1be25ea602..9ad9d4455b5 100644
--- a/app/assets/javascripts/ide/components/jobs/stage.vue
+++ b/app/assets/javascripts/ide/components/jobs/stage.vue
@@ -77,7 +77,7 @@ export default {
<div v-if="!stage.isLoading || stage.jobs.length" class="append-right-8 prepend-left-4">
<span class="badge badge-pill"> {{ jobsCount }} </span>
</div>
- <icon :name="collapseIcon" css-classes="ide-stage-collapse-icon" />
+ <icon :name="collapseIcon" class="ide-stage-collapse-icon" />
</div>
<div v-show="!stage.isCollapsed" class="card-body">
<gl-loading-icon v-if="showLoadingIcon" />
diff --git a/app/assets/javascripts/ide/components/mr_file_icon.vue b/app/assets/javascripts/ide/components/mr_file_icon.vue
index 821be319cce..cf8a1abbde4 100644
--- a/app/assets/javascripts/ide/components/mr_file_icon.vue
+++ b/app/assets/javascripts/ide/components/mr_file_icon.vue
@@ -18,6 +18,6 @@ export default {
:title="__('Part of merge request changes')"
:size="12"
name="git-merge"
- css-classes="append-right-8"
+ class="append-right-8"
/>
</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/button.vue b/app/assets/javascripts/ide/components/new_dropdown/button.vue
index 062a64a19d7..5bd6642930c 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/button.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/button.vue
@@ -52,7 +52,7 @@ export default {
class="btn-blank"
@click.stop.prevent="clicked"
>
- <icon :name="icon" :css-classes="iconClasses" />
+ <icon :name="icon" :class="iconClasses" />
<template v-if="showLabel">
{{ label }}
</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index f67666f1fbf..d2ed1fe3e55 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -3,12 +3,12 @@ import $ from 'jquery';
import flash from '~/flash';
import { __, sprintf, s__ } from '~/locale';
import { mapActions, mapState, mapGetters } from 'vuex';
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { modalTypes } from '../../constants';
export default {
components: {
- GlModal,
+ GlModal: DeprecatedModal2,
},
data() {
return {
@@ -91,7 +91,6 @@ export default {
this.renameEntry({
path: this.entryModal.entry.path,
name: entryName,
- entryPath: null,
parentPath,
}),
)
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 802b7f1fa6f..3bf8308ccea 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -155,15 +155,7 @@ export default {
this.editor.clearEditor();
- this.getFileData({
- path: this.file.path,
- makeFileActive: false,
- })
- .then(() =>
- this.getRawFileData({
- path: this.file.path,
- }),
- )
+ this.fetchFileData()
.then(() => {
this.createEditorInstance();
})
@@ -179,6 +171,20 @@ export default {
throw err;
});
},
+ fetchFileData() {
+ if (this.file.tempFile) {
+ return Promise.resolve();
+ }
+
+ return this.getFileData({
+ path: this.file.path,
+ makeFileActive: false,
+ }).then(() =>
+ this.getRawFileData({
+ path: this.file.path,
+ }),
+ );
+ },
createEditorInstance() {
this.editor.dispose();
diff --git a/app/assets/javascripts/ide/components/repo_file_status_icon.vue b/app/assets/javascripts/ide/components/repo_file_status_icon.vue
index 84a962bfc7d..9773e835a5c 100644
--- a/app/assets/javascripts/ide/components/repo_file_status_icon.vue
+++ b/app/assets/javascripts/ide/components/repo_file_status_icon.vue
@@ -29,6 +29,6 @@ export default {
<template>
<span v-if="file.file_lock" v-tooltip :title="lockTooltip" data-container="body">
- <icon name="lock" css-classes="file-status-icon" />
+ <icon name="lock" class="file-status-icon" />
</span>
</template>
diff --git a/app/assets/javascripts/ide/lib/files.js b/app/assets/javascripts/ide/lib/files.js
index 51278640b5b..e86dac20104 100644
--- a/app/assets/javascripts/ide/lib/files.js
+++ b/app/assets/javascripts/ide/lib/files.js
@@ -1,7 +1,5 @@
import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
-import { decorateData, sortTree } from '../stores/utils';
-
-export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/');
+import { decorateData, sortTree, escapeFileUrl } from '../stores/utils';
export const splitParent = path => {
const idx = path.lastIndexOf('/');
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index 8c0119a1fed..4e18ec58feb 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -9,6 +9,7 @@ import { decorateFiles } from '../lib/files';
import { stageKeys } from '../constants';
import service from '../services';
import router from '../ide_router';
+import eventHub from '../eventhub';
export const redirectToUrl = (self, url) => visitUrl(url);
@@ -171,8 +172,10 @@ export const setCurrentBranchId = ({ commit }, currentBranchId) => {
export const updateTempFlagForEntry = ({ commit, dispatch, state }, { file, tempFile }) => {
commit(types.UPDATE_TEMP_FLAG, { path: file.path, tempFile });
- if (file.parentPath) {
- dispatch('updateTempFlagForEntry', { file: state.entries[file.parentPath], tempFile });
+ const parent = file.parentPath && state.entries[file.parentPath];
+
+ if (parent) {
+ dispatch('updateTempFlagForEntry', { file: parent, tempFile });
}
};
@@ -199,51 +202,71 @@ export const openNewEntryModal = ({ commit }, { type, path = '' }) => {
export const deleteEntry = ({ commit, dispatch, state }, path) => {
const entry = state.entries[path];
-
+ const { prevPath, prevName, prevParentPath } = entry;
+ const isTree = entry.type === 'tree';
+
+ if (prevPath) {
+ dispatch('renameEntry', {
+ path,
+ name: prevName,
+ parentPath: prevParentPath,
+ });
+ dispatch('deleteEntry', prevPath);
+ return;
+ }
if (state.unusedSeal) dispatch('burstUnusedSeal');
if (entry.opened) dispatch('closeFile', entry);
- if (entry.type === 'tree') {
+ if (isTree) {
entry.tree.forEach(f => dispatch('deleteEntry', f.path));
}
commit(types.DELETE_ENTRY, path);
- dispatch('stageChange', path);
+
+ // Only stage if we're not a directory or a new file
+ if (!isTree && !entry.tempFile) {
+ dispatch('stageChange', path);
+ }
dispatch('triggerFilesChange');
};
export const resetOpenFiles = ({ commit }) => commit(types.RESET_OPEN_FILES);
-export const renameEntry = (
- { dispatch, commit, state },
- { path, name, entryPath = null, parentPath },
-) => {
- const entry = state.entries[entryPath || path];
+export const renameEntry = ({ dispatch, commit, state }, { path, name, parentPath }) => {
+ const entry = state.entries[path];
+ const newPath = parentPath ? `${parentPath}/${name}` : name;
- commit(types.RENAME_ENTRY, { path, name, entryPath, parentPath });
+ commit(types.RENAME_ENTRY, { path, name, parentPath });
if (entry.type === 'tree') {
- const slashedParentPath = parentPath ? `${parentPath}/` : '';
- const targetEntry = entryPath ? entryPath.split('/').pop() : name;
- const newParentPath = `${slashedParentPath}${targetEntry}`;
-
- state.entries[entryPath || path].tree.forEach(f => {
+ state.entries[newPath].tree.forEach(f => {
dispatch('renameEntry', {
- path,
- name,
- entryPath: f.path,
- parentPath: newParentPath,
+ path: f.path,
+ name: f.name,
+ parentPath: newPath,
});
});
} else {
- const newPath = parentPath ? `${parentPath}/${name}` : name;
const newEntry = state.entries[newPath];
- commit(types.TOGGLE_FILE_CHANGED, { file: newEntry, changed: true });
+ const isRevert = newPath === entry.prevPath;
+ const isReset = isRevert && !newEntry.changed && !newEntry.tempFile;
+ const isInChanges = state.changedFiles
+ .concat(state.stagedFiles)
+ .some(({ key }) => key === newEntry.key);
+
+ if (isReset) {
+ commit(types.REMOVE_FILE_FROM_STAGED_AND_CHANGED, newEntry);
+ } else if (!isInChanges) {
+ commit(types.ADD_FILE_TO_CHANGED, newPath);
+ }
+
+ if (!newEntry.tempFile) {
+ eventHub.$emit(`editor.update.model.dispose.${entry.key}`);
+ }
- if (entry.opened) {
+ if (newEntry.opened) {
router.push(`/project${newEntry.url}`);
- commit(types.TOGGLE_FILE_OPEN, entry.path);
}
}
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
index 7627b6e03af..59445afc7a4 100644
--- a/app/assets/javascripts/ide/stores/actions/file.js
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -5,7 +5,7 @@ import eventHub from '../../eventhub';
import service from '../../services';
import * as types from '../mutation_types';
import router from '../../ide_router';
-import { setPageTitle } from '../utils';
+import { setPageTitle, replaceFileUrl } from '../utils';
import { viewerTypes, stageKeys } from '../../constants';
export const closeFile = ({ commit, state, dispatch }, file) => {
@@ -67,7 +67,7 @@ export const getFileData = (
commit(types.TOGGLE_LOADING, { entry: file });
- const url = file.prevPath ? file.url.replace(file.path, file.prevPath) : file.url;
+ const url = file.prevPath ? replaceFileUrl(file.url, file.path, file.prevPath) : file.url;
return service
.getFileData(joinPaths(gon.relative_url_root || '', url.replace('/-/', '/')))
@@ -186,11 +186,6 @@ export const discardFileChanges = ({ dispatch, state, commit, getters }, path) =
dispatch('restoreTree', file.parentPath);
}
- if (file.movedPath) {
- commit(types.DISCARD_FILE_CHANGES, file.movedPath);
- commit(types.REMOVE_FILE_FROM_CHANGED, file.movedPath);
- }
-
commit(types.DISCARD_FILE_CHANGES, path);
commit(types.REMOVE_FILE_FROM_CHANGED, path);
diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
index dd8f17e4f3a..20887e7d0ac 100644
--- a/app/assets/javascripts/ide/stores/actions/project.js
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -92,13 +92,27 @@ export const showEmptyState = ({ commit, state }, { projectId, branchId }) => {
});
};
-export const openBranch = ({ dispatch, state, getters }, { projectId, branchId, basePath }) => {
- dispatch('setCurrentBranchId', branchId);
+export const loadFile = ({ dispatch, state }, { basePath }) => {
+ if (basePath) {
+ const path = basePath.slice(-1) === '/' ? basePath.slice(0, -1) : basePath;
+ const treeEntryKey = Object.keys(state.entries).find(
+ key => key === path && !state.entries[key].pending,
+ );
+ const treeEntry = state.entries[treeEntryKey];
- if (getters.emptyRepo) {
- return dispatch('showEmptyState', { projectId, branchId });
+ if (treeEntry) {
+ dispatch('handleTreeEntryAction', treeEntry);
+ } else {
+ dispatch('createTempEntry', {
+ name: path,
+ type: 'blob',
+ });
+ }
}
- return dispatch('getBranchData', {
+};
+
+export const loadBranch = ({ dispatch }, { projectId, branchId }) =>
+ dispatch('getBranchData', {
projectId,
branchId,
})
@@ -107,42 +121,38 @@ export const openBranch = ({ dispatch, state, getters }, { projectId, branchId,
projectId,
branchId,
});
- dispatch('getFiles', {
+ return dispatch('getFiles', {
projectId,
branchId,
- })
- .then(() => {
- if (basePath) {
- const path = basePath.slice(-1) === '/' ? basePath.slice(0, -1) : basePath;
- const treeEntryKey = Object.keys(state.entries).find(
- key => key === path && !state.entries[key].pending,
- );
- const treeEntry = state.entries[treeEntryKey];
-
- if (treeEntry) {
- dispatch('handleTreeEntryAction', treeEntry);
- } else {
- dispatch('createTempEntry', {
- name: path,
- type: 'blob',
- });
- }
- }
- })
- .catch(
- () =>
- new Error(
- sprintf(
- __('An error occurred whilst getting files for - %{branchId}'),
- {
- branchId: `<strong>${_.escape(projectId)}/${_.escape(branchId)}</strong>`,
- },
- false,
- ),
- ),
- );
+ });
})
.catch(() => {
dispatch('showBranchNotFoundError', branchId);
+ return Promise.reject();
});
+
+export const openBranch = ({ dispatch, state, getters }, { projectId, branchId, basePath }) => {
+ const currentProject = state.projects[projectId];
+ if (getters.emptyRepo) {
+ return dispatch('showEmptyState', { projectId, branchId });
+ }
+ if (!currentProject || !currentProject.branches[branchId]) {
+ dispatch('setCurrentBranchId', branchId);
+
+ return dispatch('loadBranch', { projectId, branchId })
+ .then(() => dispatch('loadFile', { basePath }))
+ .catch(
+ () =>
+ new Error(
+ sprintf(
+ __('An error occurred whilst getting files for - %{branchId}'),
+ {
+ branchId: `<strong>${_.escape(projectId)}/${_.escape(branchId)}</strong>`,
+ },
+ false,
+ ),
+ ),
+ );
+ }
+ return Promise.resolve(dispatch('loadFile', { basePath }));
};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index 23caf2d48ed..e89ed49318b 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -152,6 +152,12 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
branch: getters.branchName,
})
.then(() => {
+ commit(rootTypes.CLEAR_STAGED_CHANGES, null, { root: true });
+
+ setTimeout(() => {
+ commit(rootTypes.SET_LAST_COMMIT_MSG, '', { root: true });
+ }, 5000);
+
if (state.shouldCreateMR) {
const { currentProject } = rootGetters;
const targetBranch = getters.isCreatingNewBranch
@@ -164,14 +170,6 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
{ root: true },
);
}
-
- commit(rootTypes.CLEAR_STAGED_CHANGES, null, { root: true });
-
- commit(rootTypes.CLEAR_REPLACED_FILES, null, { root: true });
-
- setTimeout(() => {
- commit(rootTypes.SET_LAST_COMMIT_MSG, '', { root: true });
- }, 5000);
})
.then(() => {
if (rootGetters.lastOpenedFile) {
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index f021729c451..f0b4718d025 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -59,8 +59,7 @@ export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE';
export const CLEAR_STAGED_CHANGES = 'CLEAR_STAGED_CHANGES';
export const STAGE_CHANGE = 'STAGE_CHANGE';
export const UNSTAGE_CHANGE = 'UNSTAGE_CHANGE';
-
-export const CLEAR_REPLACED_FILES = 'CLEAR_REPLACED_FILES';
+export const REMOVE_FILE_FROM_STAGED_AND_CHANGED = 'REMOVE_FILE_FROM_STAGED_AND_CHANGED';
export const UPDATE_FILE_AFTER_COMMIT = 'UPDATE_FILE_AFTER_COMMIT';
export const ADD_PENDING_TAB = 'ADD_PENDING_TAB';
@@ -79,5 +78,6 @@ export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE';
export const OPEN_NEW_ENTRY_MODAL = 'OPEN_NEW_ENTRY_MODAL';
export const DELETE_ENTRY = 'DELETE_ENTRY';
export const RENAME_ENTRY = 'RENAME_ENTRY';
+export const REVERT_RENAME_ENTRY = 'REVERT_RENAME_ENTRY';
export const RESTORE_TREE = 'RESTORE_TREE';
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index ea125214ebb..e84e2782e46 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -5,7 +5,14 @@ import mergeRequestMutation from './mutations/merge_request';
import fileMutations from './mutations/file';
import treeMutations from './mutations/tree';
import branchMutations from './mutations/branch';
-import { sortTree } from './utils';
+import {
+ sortTree,
+ replaceFileUrl,
+ swapInParentTreeWithSorting,
+ updateFileCollections,
+ removeFromParentTree,
+ pathsAreEqual,
+} from './utils';
export default {
[types.SET_INITIAL_DATA](state, data) {
@@ -56,11 +63,6 @@ export default {
stagedFiles: [],
});
},
- [types.CLEAR_REPLACED_FILES](state) {
- Object.assign(state, {
- replacedFiles: [],
- });
- },
[types.SET_ENTRIES](state, entries) {
Object.assign(state, {
entries,
@@ -71,16 +73,15 @@ export default {
const entry = data.entries[key];
const foundEntry = state.entries[key];
+ // NOTE: We can't clone `entry` in any of the below assignments because
+ // we need `state.entries` and the `entry.tree` to reference the same object.
if (!foundEntry) {
Object.assign(state.entries, {
[key]: entry,
});
} else if (foundEntry.deleted) {
Object.assign(state.entries, {
- [key]: {
- ...entry,
- replaces: true,
- },
+ [key]: Object.assign(entry, { replaces: true }),
});
} else {
const tree = entry.tree.filter(
@@ -157,9 +158,14 @@ export default {
changed: Boolean(changedFile),
staged: false,
replaces: false,
- prevPath: '',
- moved: false,
lastCommitSha: lastCommit.commit.id,
+
+ prevId: undefined,
+ prevPath: undefined,
+ prevName: undefined,
+ prevUrl: undefined,
+ prevKey: undefined,
+ prevParentPath: undefined,
});
if (prevPath) {
@@ -209,7 +215,9 @@ export default {
entry.deleted = true;
- parent.tree = parent.tree.filter(f => f.path !== entry.path);
+ if (parent) {
+ parent.tree = parent.tree.filter(f => f.path !== entry.path);
+ }
if (entry.type === 'blob') {
if (tempFile) {
@@ -219,51 +227,61 @@ export default {
}
}
},
- [types.RENAME_ENTRY](state, { path, name, entryPath = null, parentPath }) {
- const oldEntry = state.entries[entryPath || path];
- const slashedParentPath = parentPath ? `${parentPath}/` : '';
- const newPath = entryPath
- ? `${slashedParentPath}${oldEntry.name}`
- : `${slashedParentPath}${name}`;
+ [types.RENAME_ENTRY](state, { path, name, parentPath }) {
+ const oldEntry = state.entries[path];
+ const newPath = parentPath ? `${parentPath}/${name}` : name;
+ const isRevert = newPath === oldEntry.prevPath;
- Vue.set(state.entries, newPath, {
+ const newUrl = replaceFileUrl(oldEntry.url, oldEntry.path, newPath);
+
+ const newKey = oldEntry.key.replace(new RegExp(oldEntry.path, 'g'), newPath);
+
+ const baseProps = {
...oldEntry,
+ name,
id: newPath,
- key: `${newPath}-${oldEntry.type}-${oldEntry.path}`,
path: newPath,
- name: entryPath ? oldEntry.name : name,
- tempFile: true,
- prevPath: oldEntry.tempFile ? null : oldEntry.path,
- url: oldEntry.url.replace(new RegExp(`${oldEntry.path}/?$`), newPath),
- tree: [],
- raw: '',
- opened: false,
- parentPath,
- });
+ url: newUrl,
+ key: newKey,
+ parentPath: parentPath || '',
+ };
- oldEntry.moved = true;
- oldEntry.movedPath = newPath;
+ const prevProps =
+ oldEntry.tempFile || isRevert
+ ? {
+ prevId: undefined,
+ prevPath: undefined,
+ prevName: undefined,
+ prevUrl: undefined,
+ prevKey: undefined,
+ prevParentPath: undefined,
+ }
+ : {
+ prevId: oldEntry.prevId || oldEntry.id,
+ prevPath: oldEntry.prevPath || oldEntry.path,
+ prevName: oldEntry.prevName || oldEntry.name,
+ prevUrl: oldEntry.prevUrl || oldEntry.url,
+ prevKey: oldEntry.prevKey || oldEntry.key,
+ prevParentPath: oldEntry.prevParentPath || oldEntry.parentPath,
+ };
- const parent = parentPath
- ? state.entries[parentPath]
- : state.trees[`${state.currentProjectId}/${state.currentBranchId}`];
- const newEntry = state.entries[newPath];
-
- parent.tree = sortTree(parent.tree.concat(newEntry));
+ Vue.set(state.entries, newPath, {
+ ...baseProps,
+ ...prevProps,
+ });
- if (newEntry.type === 'blob') {
- state.changedFiles = state.changedFiles.concat(newEntry);
+ if (pathsAreEqual(oldEntry.parentPath, parentPath)) {
+ swapInParentTreeWithSorting(state, oldEntry.key, newPath, parentPath);
+ } else {
+ removeFromParentTree(state, oldEntry.key, oldEntry.parentPath);
+ swapInParentTreeWithSorting(state, oldEntry.key, newPath, parentPath);
}
- if (oldEntry.tempFile) {
- const filterMethod = f => f.path !== oldEntry.path;
-
- state.openFiles = state.openFiles.filter(filterMethod);
- state.changedFiles = state.changedFiles.filter(filterMethod);
- parent.tree = parent.tree.filter(filterMethod);
-
- Vue.delete(state.entries, oldEntry.path);
+ if (oldEntry.type === 'blob') {
+ updateFileCollections(state, oldEntry.key, newPath);
}
+
+ Vue.delete(state.entries, oldEntry.path);
},
...projectMutations,
diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js
index 1442ea7dbfa..8caeb2d73b2 100644
--- a/app/assets/javascripts/ide/stores/mutations/file.js
+++ b/app/assets/javascripts/ide/stores/mutations/file.js
@@ -138,8 +138,6 @@ export default {
content: stagedFile ? stagedFile.content : state.entries[path].raw,
changed: false,
deleted: false,
- moved: false,
- movedPath: '',
});
if (deleted) {
@@ -179,11 +177,6 @@ export default {
});
if (stagedFile) {
- Object.assign(state, {
- replacedFiles: state.replacedFiles.concat({
- ...stagedFile,
- }),
- });
Object.assign(stagedFile, {
...state.entries[path],
});
@@ -252,4 +245,15 @@ export default {
openFiles: state.openFiles.filter(f => f.key !== file.key),
});
},
+ [types.REMOVE_FILE_FROM_STAGED_AND_CHANGED](state, file) {
+ Object.assign(state, {
+ changedFiles: state.changedFiles.filter(f => f.key !== file.key),
+ stagedFiles: state.stagedFiles.filter(f => f.key !== file.key),
+ });
+
+ Object.assign(state.entries[file.path], {
+ changed: false,
+ staged: false,
+ });
+ },
};
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index c4da482bf0a..d400b9831a9 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -6,7 +6,6 @@ export default () => ({
currentMergeRequestId: '',
changedFiles: [],
stagedFiles: [],
- replacedFiles: [],
endpoints: {},
lastCommitMsg: '',
lastCommitPath: '',
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index 52200ce7847..a8d8ff31afe 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -50,9 +50,7 @@ export const dataStructure = () => ({
lastOpenedAt: 0,
mrChange: null,
deleted: false,
- prevPath: '',
- movedPath: '',
- moved: false,
+ prevPath: undefined,
});
export const decorateData = entity => {
@@ -129,7 +127,7 @@ export const commitActionForFile = file => {
export const getCommitFiles = stagedFiles =>
stagedFiles.reduce((acc, file) => {
- if (file.moved || file.type === 'tree') return acc;
+ if (file.type === 'tree') return acc;
return acc.concat({
...file,
@@ -148,9 +146,9 @@ export const createCommitPayload = ({
commit_message: state.commitMessage || getters.preBuiltCommitMessage,
actions: getCommitFiles(rootState.stagedFiles).map(f => ({
action: commitActionForFile(f),
- file_path: f.moved ? f.movedPath : f.path,
- previous_path: f.prevPath === '' ? undefined : f.prevPath,
- content: f.prevPath ? null : f.content || undefined,
+ file_path: f.path,
+ previous_path: f.prevPath || undefined,
+ content: f.prevPath && !f.changed ? null : f.content || undefined,
encoding: f.base64 ? 'base64' : 'text',
last_commit_id:
newBranch || f.deleted || f.prevPath || f.replaces ? undefined : f.lastCommitSha,
@@ -213,3 +211,61 @@ export const mergeTrees = (fromTree, toTree) => {
return toTree;
};
+
+export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/');
+
+export const replaceFileUrl = (url, oldPath, newPath) => {
+ // Add `/-/` so that we don't accidentally replace project path
+ const result = url.replace(`/-/${escapeFileUrl(oldPath)}`, `/-/${escapeFileUrl(newPath)}`);
+
+ return result;
+};
+
+export const swapInStateArray = (state, arr, key, entryPath) =>
+ Object.assign(state, {
+ [arr]: state[arr].map(f => (f.key === key ? state.entries[entryPath] : f)),
+ });
+
+export const getEntryOrRoot = (state, path) =>
+ path ? state.entries[path] : state.trees[`${state.currentProjectId}/${state.currentBranchId}`];
+
+export const swapInParentTreeWithSorting = (state, oldKey, newPath, parentPath) => {
+ if (!newPath) {
+ return;
+ }
+
+ const parent = getEntryOrRoot(state, parentPath);
+
+ if (parent) {
+ const tree = parent.tree
+ // filter out old entry && new entry
+ .filter(({ key, path }) => key !== oldKey && path !== newPath)
+ // concat new entry
+ .concat(state.entries[newPath]);
+
+ parent.tree = sortTree(tree);
+ }
+};
+
+export const removeFromParentTree = (state, oldKey, parentPath) => {
+ const parent = getEntryOrRoot(state, parentPath);
+
+ if (parent) {
+ parent.tree = sortTree(parent.tree.filter(({ key }) => key !== oldKey));
+ }
+};
+
+export const updateFileCollections = (state, key, entryPath) => {
+ ['openFiles', 'changedFiles', 'stagedFiles'].forEach(fileCollection => {
+ swapInStateArray(state, fileCollection, key, entryPath);
+ });
+};
+
+export const cleanTrailingSlash = path => path.replace(/\/$/, '');
+
+export const pathsAreEqual = (a, b) => {
+ const cleanA = a ? cleanTrailingSlash(a) : '';
+ const cleanB = b ? cleanTrailingSlash(b) : '';
+
+ return cleanA === cleanB;
+};
diff --git a/app/assets/javascripts/image_diff/helpers/badge_helper.js b/app/assets/javascripts/image_diff/helpers/badge_helper.js
index 000157efad0..7921650e8a0 100644
--- a/app/assets/javascripts/image_diff/helpers/badge_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/badge_helper.js
@@ -1,3 +1,5 @@
+import { spriteIcon } from '~/lib/utils/common_utils';
+
export function createImageBadge(noteId, { x, y }, classNames = []) {
const buttonEl = document.createElement('button');
const classList = classNames.concat(['js-image-badge']);
@@ -20,7 +22,7 @@ export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) {
export function addImageCommentBadge(containerEl, { coordinate, noteId }) {
const buttonEl = createImageBadge(noteId, coordinate, ['image-comment-badge']);
- buttonEl.innerHTML = gl.utils.spriteIcon('image-comment-dark');
+ buttonEl.innerHTML = spriteIcon('image-comment-dark');
containerEl.appendChild(buttonEl);
}
diff --git a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
index 7051a968dac..df3d90cff68 100644
--- a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
@@ -1,3 +1,5 @@
+import { spriteIcon } from '~/lib/utils/common_utils';
+
export function addCommentIndicator(containerEl, { x, y }) {
const buttonEl = document.createElement('button');
buttonEl.classList.add('btn-transparent');
@@ -6,7 +8,7 @@ export function addCommentIndicator(containerEl, { x, y }) {
buttonEl.style.left = `${x}px`;
buttonEl.style.top = `${y}px`;
- buttonEl.innerHTML = gl.utils.spriteIcon('image-comment-dark');
+ buttonEl.innerHTML = spriteIcon('image-comment-dark');
containerEl.appendChild(buttonEl);
}
diff --git a/app/assets/javascripts/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_projects/components/import_projects_table.vue
index 00eb0afb3bf..e5ac3cbafe5 100644
--- a/app/assets/javascripts/import_projects/components/import_projects_table.vue
+++ b/app/assets/javascripts/import_projects/components/import_projects_table.vue
@@ -1,4 +1,5 @@
<script>
+import _ from 'underscore';
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
@@ -7,6 +8,8 @@ import ImportedProjectTableRow from './imported_project_table_row.vue';
import ProviderRepoTableRow from './provider_repo_table_row.vue';
import eventHub from '../event_hub';
+const reposFetchThrottleDelay = 1000;
+
export default {
name: 'ImportProjectsTable',
components: {
@@ -23,11 +26,11 @@ export default {
},
computed: {
- ...mapState(['importedProjects', 'providerRepos', 'isLoadingRepos']),
+ ...mapState(['importedProjects', 'providerRepos', 'isLoadingRepos', 'filter']),
...mapGetters(['isImportingAnyRepo', 'hasProviderRepos', 'hasImportedProjects']),
emptyStateText() {
- return sprintf(__('No %{providerTitle} repositories available to import'), {
+ return sprintf(__('No %{providerTitle} repositories found'), {
providerTitle: this.providerTitle,
});
},
@@ -47,21 +50,38 @@ export default {
},
methods: {
- ...mapActions(['fetchRepos', 'fetchJobs', 'stopJobsPolling', 'clearJobsEtagPoll']),
+ ...mapActions([
+ 'fetchRepos',
+ 'fetchReposFiltered',
+ 'fetchJobs',
+ 'stopJobsPolling',
+ 'clearJobsEtagPoll',
+ 'setFilter',
+ ]),
importAll() {
eventHub.$emit('importAll');
},
+
+ handleFilterInput({ target }) {
+ this.setFilter(target.value);
+ },
+
+ throttledFetchRepos: _.throttle(function fetch() {
+ eventHub.$off('importAll');
+ this.fetchRepos();
+ }, reposFetchThrottleDelay),
},
};
</script>
<template>
<div>
+ <p class="light text-nowrap mt-2">
+ {{ s__('ImportProjects|Select the projects you want to import') }}
+ </p>
+
<div class="d-flex justify-content-between align-items-end flex-wrap mb-3">
- <p class="light text-nowrap mt-2 my-sm-0">
- {{ s__('ImportProjects|Select the projects you want to import') }}
- </p>
<loading-button
container-class="btn btn-success js-import-all"
:loading="isImportingAnyRepo"
@@ -70,6 +90,19 @@ export default {
type="button"
@click="importAll"
/>
+ <form novalidate @submit.prevent>
+ <input
+ :value="filter"
+ data-qa-selector="githubish_import_filter_field"
+ class="form-control"
+ name="filter"
+ :placeholder="__('Filter your projects by name')"
+ autofocus
+ size="40"
+ @input="handleFilterInput($event)"
+ @keyup.enter="throttledFetchRepos"
+ />
+ </form>
</div>
<gl-loading-icon
v-if="isLoadingRepos"
diff --git a/app/assets/javascripts/import_projects/index.js b/app/assets/javascripts/import_projects/index.js
index 2d99d716609..b069dcb7766 100644
--- a/app/assets/javascripts/import_projects/index.js
+++ b/app/assets/javascripts/import_projects/index.js
@@ -38,7 +38,7 @@ export default function mountImportProjectsTable(mountElement) {
},
methods: {
- ...mapActions(['setInitialData']),
+ ...mapActions(['setInitialData', 'setFilter']),
},
render(createElement) {
diff --git a/app/assets/javascripts/import_projects/store/actions.js b/app/assets/javascripts/import_projects/store/actions.js
index c44500937cc..0fb9a4cdfd4 100644
--- a/app/assets/javascripts/import_projects/store/actions.js
+++ b/app/assets/javascripts/import_projects/store/actions.js
@@ -5,6 +5,7 @@ import Poll from '~/lib/utils/poll';
import createFlash from '~/flash';
import { s__, sprintf } from '~/locale';
import axios from '~/lib/utils/axios_utils';
+import { jobsPathWithFilter, reposPathWithFilter } from './getters';
let eTagPoll;
@@ -19,16 +20,20 @@ export const restartJobsPolling = () => {
};
export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
+export const setFilter = ({ commit }, filter) => commit(types.SET_FILTER, filter);
export const requestRepos = ({ commit }, repos) => commit(types.REQUEST_REPOS, repos);
export const receiveReposSuccess = ({ commit }, repos) =>
commit(types.RECEIVE_REPOS_SUCCESS, repos);
export const receiveReposError = ({ commit }) => commit(types.RECEIVE_REPOS_ERROR);
export const fetchRepos = ({ state, dispatch }) => {
+ dispatch('stopJobsPolling');
dispatch('requestRepos');
+ const { provider } = state;
+
return axios
- .get(state.reposPath)
+ .get(reposPathWithFilter(state))
.then(({ data }) =>
dispatch('receiveReposSuccess', convertObjectPropsToCamelCase(data, { deep: true })),
)
@@ -36,7 +41,7 @@ export const fetchRepos = ({ state, dispatch }) => {
.catch(() => {
createFlash(
sprintf(s__('ImportProjects|Requesting your %{provider} repositories failed'), {
- provider: state.provider,
+ provider,
}),
);
@@ -77,16 +82,23 @@ export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, rep
export const receiveJobsSuccess = ({ commit }, updatedProjects) =>
commit(types.RECEIVE_JOBS_SUCCESS, updatedProjects);
export const fetchJobs = ({ state, dispatch }) => {
- if (eTagPoll) return;
+ const { filter } = state;
+
+ if (eTagPoll) {
+ stopJobsPolling();
+ clearJobsEtagPoll();
+ }
eTagPoll = new Poll({
resource: {
- fetchJobs: () => axios.get(state.jobsPath),
+ fetchJobs: () => axios.get(jobsPathWithFilter(state)),
},
method: 'fetchJobs',
successCallback: ({ data }) =>
dispatch('receiveJobsSuccess', convertObjectPropsToCamelCase(data, { deep: true })),
- errorCallback: () => createFlash(s__('ImportProjects|Updating the imported projects failed')),
+ errorCallback: () =>
+ createFlash(s__('ImportProjects|Update of imported projects with realtime changes failed')),
+ data: { filter },
});
if (!Visibility.hidden()) {
diff --git a/app/assets/javascripts/import_projects/store/getters.js b/app/assets/javascripts/import_projects/store/getters.js
index 727b80765bd..b107c293181 100644
--- a/app/assets/javascripts/import_projects/store/getters.js
+++ b/app/assets/javascripts/import_projects/store/getters.js
@@ -20,3 +20,8 @@ export const isImportingAnyRepo = state => state.reposBeingImported.length > 0;
export const hasProviderRepos = state => state.providerRepos.length > 0;
export const hasImportedProjects = state => state.importedProjects.length > 0;
+
+export const reposPathWithFilter = ({ reposPath, filter = '' }) =>
+ filter ? `${reposPath}?filter=${filter}` : reposPath;
+export const jobsPathWithFilter = ({ jobsPath, filter = '' }) =>
+ filter ? `${jobsPath}?filter=${filter}` : jobsPath;
diff --git a/app/assets/javascripts/import_projects/store/mutation_types.js b/app/assets/javascripts/import_projects/store/mutation_types.js
index 6ba3fd6f29e..16574f4450f 100644
--- a/app/assets/javascripts/import_projects/store/mutation_types.js
+++ b/app/assets/javascripts/import_projects/store/mutation_types.js
@@ -9,3 +9,5 @@ export const RECEIVE_IMPORT_SUCCESS = 'RECEIVE_IMPORT_SUCCESS';
export const RECEIVE_IMPORT_ERROR = 'RECEIVE_IMPORT_ERROR';
export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS';
+
+export const SET_FILTER = 'SET_FILTER';
diff --git a/app/assets/javascripts/import_projects/store/mutations.js b/app/assets/javascripts/import_projects/store/mutations.js
index b88de0268e7..6c56cfa8298 100644
--- a/app/assets/javascripts/import_projects/store/mutations.js
+++ b/app/assets/javascripts/import_projects/store/mutations.js
@@ -6,6 +6,10 @@ export default {
Object.assign(state, data);
},
+ [types.SET_FILTER](state, filter) {
+ state.filter = filter;
+ },
+
[types.REQUEST_REPOS](state) {
state.isLoadingRepos = true;
},
diff --git a/app/assets/javascripts/import_projects/store/state.js b/app/assets/javascripts/import_projects/store/state.js
index 637fef6e53c..829f3aa4fbb 100644
--- a/app/assets/javascripts/import_projects/store/state.js
+++ b/app/assets/javascripts/import_projects/store/state.js
@@ -12,4 +12,5 @@ export default () => ({
isLoadingRepos: false,
canSelectNamespace: false,
ciCdOnly: false,
+ filter: '',
});
diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js
index a7746bb3a0b..1c9b94ade8a 100644
--- a/app/assets/javascripts/integrations/integration_settings_form.js
+++ b/app/assets/javascripts/integrations/integration_settings_form.js
@@ -42,6 +42,7 @@ export default class IntegrationSettingsForm {
// and test the service using provided configuration.
if (this.$form.get(0).checkValidity() && this.canTestService) {
e.preventDefault();
+ // eslint-disable-next-line no-jquery/no-serialize
this.testSettings(this.$form.serialize());
}
}
diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js
index c855f3973b0..45de287d44d 100644
--- a/app/assets/javascripts/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable_bulk_update_actions.js
@@ -1,4 +1,4 @@
-/* eslint-disable consistent-return, func-names, array-callback-return, prefer-arrow-callback */
+/* eslint-disable consistent-return, func-names, array-callback-return */
import $ from 'jquery';
import _ from 'underscore';
@@ -45,7 +45,7 @@ export default {
this.getSelectedIssues().map(function() {
const labelsData = $(this).data('labels');
if (labelsData) {
- return labelsData.map(function(labelId) {
+ return labelsData.map(labelId => {
if (labels.indexOf(labelId) === -1) {
return labels.push(labelId);
}
diff --git a/app/assets/javascripts/issuable_sidebar/components/sidebar_app.vue b/app/assets/javascripts/issuable_sidebar/components/sidebar_app.vue
new file mode 100644
index 00000000000..06c50f62aab
--- /dev/null
+++ b/app/assets/javascripts/issuable_sidebar/components/sidebar_app.vue
@@ -0,0 +1,23 @@
+<script>
+export default {
+ props: {
+ signedIn: {
+ type: Boolean,
+ required: true,
+ },
+ sidebarStatusClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
+</script>
+
+<template>
+ <aside
+ :class="sidebarStatusClass"
+ class="right-sidebar js-right-sidebar js-issuable-sidebar"
+ aria-live="polite"
+ ></aside>
+</template>
diff --git a/app/assets/javascripts/issuable_sidebar/sidebar_bundle.js b/app/assets/javascripts/issuable_sidebar/sidebar_bundle.js
new file mode 100644
index 00000000000..c8acafa8cd8
--- /dev/null
+++ b/app/assets/javascripts/issuable_sidebar/sidebar_bundle.js
@@ -0,0 +1,27 @@
+import Vue from 'vue';
+
+import SidebarApp from './components/sidebar_app.vue';
+
+export default () => {
+ const el = document.getElementById('js-vue-issuable-sidebar');
+
+ if (!el) {
+ return false;
+ }
+
+ const { sidebarStatusClass } = el.dataset;
+ // An empty string is present when user is signed in.
+ const signedIn = el.dataset.signedIn === '';
+
+ return new Vue({
+ el,
+ components: { SidebarApp },
+ render: createElement =>
+ createElement('sidebar-app', {
+ props: {
+ signedIn,
+ sidebarStatusClass,
+ },
+ }),
+ });
+};
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index 88975c2cc73..b8b3a4f44fd 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -102,10 +102,10 @@ export default {
required: false,
default: '',
},
- issuableTemplates: {
- type: Array,
+ issuableTemplateNamesPath: {
+ type: String,
required: false,
- default: () => [],
+ default: '',
},
markdownPreviewPath: {
type: String,
@@ -156,9 +156,13 @@ export default {
store,
state: store.state,
showForm: false,
+ templatesRequested: false,
};
},
computed: {
+ issuableTemplates() {
+ return this.store.formState.issuableTemplates;
+ },
formState() {
return this.store.formState;
},
@@ -233,6 +237,7 @@ export default {
}
return undefined;
},
+
updateStoreState() {
return this.service
.getData()
@@ -245,7 +250,7 @@ export default {
});
},
- openForm() {
+ updateAndShowForm(templates = []) {
if (!this.showForm) {
this.showForm = true;
this.store.setFormState({
@@ -254,9 +259,32 @@ export default {
lock_version: this.state.lock_version,
lockedWarningVisible: false,
updateLoading: false,
+ issuableTemplates: templates,
+ });
+ }
+ },
+
+ requestTemplatesAndShowForm() {
+ return this.service
+ .loadTemplates(this.issuableTemplateNamesPath)
+ .then(res => {
+ this.updateAndShowForm(res.data);
+ })
+ .catch(() => {
+ createFlash(this.defaultErrorMessage);
+ this.updateAndShowForm();
});
+ },
+
+ openForm() {
+ if (!this.templatesRequested) {
+ this.templatesRequested = true;
+ this.requestTemplatesAndShowForm();
+ } else {
+ this.updateAndShowForm(this.issuableTemplates);
}
},
+
closeForm() {
this.showForm = false;
},
diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js
index 5a9dd91817e..e170d338408 100644
--- a/app/assets/javascripts/issue_show/index.js
+++ b/app/assets/javascripts/issue_show/index.js
@@ -1,8 +1,6 @@
import Vue from 'vue';
-import { initSidebarTracking } from 'ee_else_ce/event_tracking/issue_sidebar';
import issuableApp from './components/app.vue';
import { parseIssuableData } from './utils/parse_data';
-import '../vue_shared/vue_resource_interceptor';
export default function initIssueableApp() {
return new Vue({
@@ -10,9 +8,6 @@ export default function initIssueableApp() {
components: {
issuableApp,
},
- mounted() {
- initSidebarTracking();
- },
render(createElement) {
return createElement('issuable-app', {
props: parseIssuableData(),
diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js
index 3c8334bee50..b1deeaae0fc 100644
--- a/app/assets/javascripts/issue_show/services/index.js
+++ b/app/assets/javascripts/issue_show/services/index.js
@@ -17,4 +17,13 @@ export default class Service {
updateIssuable(data) {
return axios.put(this.endpoint, data);
}
+
+ // eslint-disable-next-line class-methods-use-this
+ loadTemplates(templateNamesEndpoint) {
+ if (!templateNamesEndpoint) {
+ return Promise.resolve([]);
+ }
+
+ return axios.get(templateNamesEndpoint);
+ }
}
diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js
index 3c17e73ccec..688ba7b268d 100644
--- a/app/assets/javascripts/issue_show/stores/index.js
+++ b/app/assets/javascripts/issue_show/stores/index.js
@@ -1,4 +1,6 @@
+import _ from 'underscore';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import updateDescription from '../utils/update_description';
export default class Store {
constructor(initialState) {
@@ -9,6 +11,7 @@ export default class Store {
lockedWarningVisible: false,
updateLoading: false,
lock_version: 0,
+ issuableTemplates: [],
};
}
@@ -18,8 +21,15 @@ export default class Store {
}
Object.assign(this.state, convertObjectPropsToCamelCase(data));
+ // find if there is an open details node inside of the issue description.
+ const descriptionSection = document.body.querySelector(
+ '.detail-page-description.content-block',
+ );
+ const details =
+ !_.isNull(descriptionSection) && descriptionSection.getElementsByTagName('details');
+
+ this.state.descriptionHtml = updateDescription(data.description, details);
this.state.titleHtml = data.title;
- this.state.descriptionHtml = data.description;
this.state.lock_version = data.lock_version;
}
diff --git a/app/assets/javascripts/issue_show/utils/update_description.js b/app/assets/javascripts/issue_show/utils/update_description.js
new file mode 100644
index 00000000000..315f6c23b02
--- /dev/null
+++ b/app/assets/javascripts/issue_show/utils/update_description.js
@@ -0,0 +1,38 @@
+import _ from 'underscore';
+
+/**
+ * Function that replaces the open attribute for the <details> element.
+ *
+ * @param {String} descriptionHtml - The html string passed back from the server as a result of polling
+ * @param {Array} details - All detail nodes inside of the issue description.
+ */
+
+const updateDescription = (descriptionHtml = '', details) => {
+ let detailNodes = details;
+
+ if (_.isEmpty(details)) {
+ detailNodes = [];
+ }
+
+ const placeholder = document.createElement('div');
+ placeholder.innerHTML = descriptionHtml;
+
+ const newDetails = placeholder.getElementsByTagName('details');
+
+ if (newDetails.length !== detailNodes.length) {
+ return descriptionHtml;
+ }
+
+ Array.from(newDetails).forEach((el, i) => {
+ /*
+ * <details> has an open attribute that can have a value, "", "true", "false"
+ * and will show the dropdown, which is why we are setting the attribute
+ * explicitly to true.
+ */
+ if (detailNodes[i].open) el.setAttribute('open', true);
+ });
+
+ return placeholder.innerHTML;
+};
+
+export default updateDescription;
diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue
index 9fac880c5f8..8156f26ffb1 100644
--- a/app/assets/javascripts/jobs/components/commit_block.vue
+++ b/app/assets/javascripts/jobs/components/commit_block.vue
@@ -41,7 +41,7 @@ export default {
<clipboard-button
:text="commit.id"
- :title="__('Copy commit SHA to clipboard')"
+ :title="__('Copy commit SHA')"
css-class="btn btn-clipboard btn-transparent"
/>
diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue
index 8cda7dac51f..163849d3c40 100644
--- a/app/assets/javascripts/jobs/components/environments_block.vue
+++ b/app/assets/javascripts/jobs/components/environments_block.vue
@@ -19,69 +19,18 @@ export default {
},
computed: {
environment() {
- let environmentText;
switch (this.deploymentStatus.status) {
case 'last':
- environmentText = sprintf(
- __('This job is the most recent deployment to %{link}.'),
- { link: this.environmentLink },
- false,
- );
- break;
+ return this.lastEnvironmentMessage();
case 'out_of_date':
- if (this.hasLastDeployment) {
- environmentText = sprintf(
- __(
- 'This job is an out-of-date deployment to %{environmentLink}. View the most recent deployment %{deploymentLink}.',
- ),
- {
- environmentLink: this.environmentLink,
- deploymentLink: this.deploymentLink(`#${this.lastDeployment.iid}`),
- },
- false,
- );
- } else {
- environmentText = sprintf(
- __('This job is an out-of-date deployment to %{environmentLink}.'),
- { environmentLink: this.environmentLink },
- false,
- );
- }
-
- break;
+ return this.outOfDateEnvironmentMessage();
case 'failed':
- environmentText = sprintf(
- __('The deployment of this job to %{environmentLink} did not succeed.'),
- { environmentLink: this.environmentLink },
- false,
- );
- break;
+ return this.failedEnvironmentMessage();
case 'creating':
- if (this.hasLastDeployment) {
- environmentText = sprintf(
- __(
- 'This job is creating a deployment to %{environmentLink} and will overwrite the %{deploymentLink}.',
- ),
- {
- environmentLink: this.environmentLink,
- deploymentLink: this.deploymentLink(__('latest deployment')),
- },
- false,
- );
- } else {
- environmentText = sprintf(
- __('This job is creating a deployment to %{environmentLink}.'),
- { environmentLink: this.environmentLink },
- false,
- );
- }
- break;
+ return this.creatingEnvironmentMessage();
default:
- break;
+ return '';
}
- return environmentText && this.hasCluster
- ? `${environmentText} ${this.clusterText}`
- : environmentText;
},
environmentLink() {
if (this.hasEnvironment) {
@@ -137,11 +86,6 @@ export default {
false,
);
},
- clusterText() {
- return this.hasCluster
- ? sprintf(__('Cluster %{cluster} was used.'), { cluster: this.clusterNameOrLink }, false)
- : '';
- },
},
methods: {
deploymentLink(name) {
@@ -155,6 +99,91 @@ export default {
false,
);
},
+ failedEnvironmentMessage() {
+ const { environmentLink } = this;
+
+ return sprintf(
+ __('The deployment of this job to %{environmentLink} did not succeed.'),
+ { environmentLink },
+ false,
+ );
+ },
+ lastEnvironmentMessage() {
+ const { environmentLink, clusterNameOrLink, hasCluster } = this;
+
+ const message = hasCluster
+ ? __('This job is deployed to %{environmentLink} using cluster %{clusterNameOrLink}.')
+ : __('This job is deployed to %{environmentLink}.');
+
+ return sprintf(message, { environmentLink, clusterNameOrLink }, false);
+ },
+ outOfDateEnvironmentMessage() {
+ const { hasLastDeployment, hasCluster, environmentLink, clusterNameOrLink } = this;
+
+ if (hasLastDeployment) {
+ const message = hasCluster
+ ? __(
+ 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink}. View the %{deploymentLink}.',
+ )
+ : __(
+ 'This job is an out-of-date deployment to %{environmentLink}. View the %{deploymentLink}.',
+ );
+
+ return sprintf(
+ message,
+ {
+ environmentLink,
+ clusterNameOrLink,
+ deploymentLink: this.deploymentLink(__('most recent deployment')),
+ },
+ false,
+ );
+ }
+
+ const message = hasCluster
+ ? __(
+ 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink}.',
+ )
+ : __('This job is an out-of-date deployment to %{environmentLink}.');
+
+ return sprintf(
+ message,
+ {
+ environmentLink,
+ clusterNameOrLink,
+ },
+ false,
+ );
+ },
+ creatingEnvironmentMessage() {
+ const { hasLastDeployment, hasCluster, environmentLink, clusterNameOrLink } = this;
+
+ if (hasLastDeployment) {
+ const message = hasCluster
+ ? __(
+ 'This job is creating a deployment to %{environmentLink} using cluster %{clusterNameOrLink}. This will overwrite the %{deploymentLink}.',
+ )
+ : __(
+ 'This job is creating a deployment to %{environmentLink}. This will overwrite the %{deploymentLink}.',
+ );
+
+ return sprintf(
+ message,
+ {
+ environmentLink,
+ clusterNameOrLink,
+ deploymentLink: this.deploymentLink(__('latest deployment')),
+ },
+ false,
+ );
+ }
+
+ return sprintf(
+ __('This job is creating a deployment to %{environmentLink}.'),
+ { environmentLink },
+ false,
+ );
+ },
},
};
</script>
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index 36701a95673..859f839741f 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -30,7 +30,7 @@ export default {
EnvironmentsBlock,
ErasedBlock,
Icon,
- Log: () => (isNewJobLogActive() ? import('./job_log_json.vue') : import('./job_log.vue')),
+ Log: () => (isNewJobLogActive() ? import('./log/log.vue') : import('./job_log.vue')),
LogTopBar,
StuckBlock,
UnmetPrerequisitesBlock,
@@ -130,6 +130,10 @@ export default {
return title;
},
+
+ shouldRenderHeaderCallout() {
+ return this.shouldRenderCalloutMessage && !this.hasUnmetPrerequisitesFailure;
+ },
},
watch: {
// Once the job log is loaded,
@@ -239,10 +243,9 @@ export default {
/>
</div>
- <callout
- v-if="shouldRenderCalloutMessage && !hasUnmetPrerequisitesFailure"
- :message="job.callout_message"
- />
+ <callout v-if="shouldRenderHeaderCallout">
+ <div v-html="job.callout_message"></div>
+ </callout>
</header>
<!-- EO Header Section -->
diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue
index a55dffbe488..7bd299bcfa0 100644
--- a/app/assets/javascripts/jobs/components/job_container_item.vue
+++ b/app/assets/javascripts/jobs/components/job_container_item.vue
@@ -54,7 +54,7 @@ export default {
:href="job.status.details_path"
:title="tooltipText"
data-boundary="viewport"
- class="js-job-link"
+ class="js-job-link d-flex"
>
<icon
v-if="isActive"
@@ -64,7 +64,7 @@ export default {
<ci-icon :status="job.status" />
- <span>{{ job.name ? job.name : job.id }}</span>
+ <span class="text-truncate w-100">{{ job.name ? job.name : job.id }}</span>
<icon v-if="job.retried" name="retry" class="js-retry-icon" />
</gl-link>
diff --git a/app/assets/javascripts/jobs/components/job_log.vue b/app/assets/javascripts/jobs/components/job_log.vue
index a3fbe9338ee..20888c0af42 100644
--- a/app/assets/javascripts/jobs/components/job_log.vue
+++ b/app/assets/javascripts/jobs/components/job_log.vue
@@ -19,18 +19,13 @@ export default {
updated() {
this.$nextTick(() => {
this.handleScrollDown();
- this.handleCollapsibleRows();
});
},
mounted() {
this.$nextTick(() => {
this.handleScrollDown();
- this.handleCollapsibleRows();
});
},
- destroyed() {
- this.removeEventListener();
- },
methods: {
...mapActions(['scrollBottom']),
/**
@@ -47,53 +42,6 @@ export default {
}, 0);
}
},
- removeEventListener() {
- this.$el.querySelectorAll('.js-section-start').forEach(el => {
- const titleSection = el.nextSibling;
- titleSection.removeEventListener(
- 'click',
- this.handleHeaderClick.bind(this, el, el.dataset.section),
- );
- el.removeEventListener('click', this.handleSectionClick);
- });
- },
- /**
- * The collapsible rows are sent in HTML from the backend
- * We need tos add a onclick handler for the divs that match `.js-section-start`
- *
- */
- handleCollapsibleRows() {
- this.$el.querySelectorAll('.js-section-start').forEach(el => {
- const titleSection = el.nextSibling;
- titleSection.addEventListener(
- 'click',
- this.handleHeaderClick.bind(this, el, el.dataset.section),
- );
- el.addEventListener('click', this.handleSectionClick);
- });
- },
-
- handleHeaderClick(arrowElement, section) {
- this.updateToggleSection(arrowElement, section);
- },
-
- updateToggleSection(arrow, section) {
- // toggle the arrow class
- arrow.classList.toggle('fa-caret-right');
- arrow.classList.toggle('fa-caret-down');
-
- // hide the sections
- const sibilings = this.$el.querySelectorAll(`.js-s-${section}:not(.js-section-header)`);
- sibilings.forEach(row => row.classList.toggle('hidden'));
- },
- /**
- * On click, we toggle the hidden class of
- * all the rows that match the `data-section` selector
- */
- handleSectionClick(evt) {
- const clickedArrow = evt.currentTarget;
- this.updateToggleSection(clickedArrow, clickedArrow.dataset.section);
- },
},
};
</script>
diff --git a/app/assets/javascripts/jobs/components/job_log_json.vue b/app/assets/javascripts/jobs/components/job_log_json.vue
deleted file mode 100644
index 2198b20eb8f..00000000000
--- a/app/assets/javascripts/jobs/components/job_log_json.vue
+++ /dev/null
@@ -1,10 +0,0 @@
-<script>
-export default {
- name: 'JobLogJSON',
-};
-</script>
-<template>
- <pre>
- {{ __('This feature is in development. Please disable the `job_log_json` feature flag') }}
- </pre>
-</template>
diff --git a/app/assets/javascripts/jobs/components/log/collapsible_section.vue b/app/assets/javascripts/jobs/components/log/collapsible_section.vue
new file mode 100644
index 00000000000..0c7b78a3da7
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/log/collapsible_section.vue
@@ -0,0 +1,51 @@
+<script>
+import LogLine from './line.vue';
+import LogLineHeader from './line_header.vue';
+
+export default {
+ name: 'CollpasibleLogSection',
+ components: {
+ LogLine,
+ LogLineHeader,
+ },
+ props: {
+ section: {
+ type: Object,
+ required: true,
+ },
+ traceEndpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ badgeDuration() {
+ return this.section.line && this.section.line.section_duration;
+ },
+ },
+ methods: {
+ handleOnClickCollapsibleLine(section) {
+ this.$emit('onClickCollapsibleLine', section);
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <log-line-header
+ :line="section.line"
+ :duration="badgeDuration"
+ :path="traceEndpoint"
+ :is-closed="section.isClosed"
+ @toggleLine="handleOnClickCollapsibleLine(section)"
+ />
+ <template v-if="!section.isClosed">
+ <log-line
+ v-for="line in section.lines"
+ :key="line.offset"
+ :line="line"
+ :path="traceEndpoint"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/log/duration_badge.vue b/app/assets/javascripts/jobs/components/log/duration_badge.vue
index 31a101d2c95..8e5dcdcc902 100644
--- a/app/assets/javascripts/jobs/components/log/duration_badge.vue
+++ b/app/assets/javascripts/jobs/components/log/duration_badge.vue
@@ -9,7 +9,7 @@ export default {
};
</script>
<template>
- <div class="log-duration-badge rounded align-self-start px-2 ml-2 flex-shrink-0">
+ <div class="log-duration-badge rounded align-self-start px-2 ml-2 flex-shrink-0 ws-normal">
{{ duration }}
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/log/line.vue b/app/assets/javascripts/jobs/components/log/line.vue
index 4e09c85b25a..33ee84bd4ee 100644
--- a/app/assets/javascripts/jobs/components/log/line.vue
+++ b/app/assets/javascripts/jobs/components/log/line.vue
@@ -19,10 +19,14 @@ export default {
</script>
<template>
- <div class="log-line">
+ <div class="js-line log-line">
<line-number :line-number="line.lineNumber" :path="path" />
- <span v-for="(content, i) in line.content" :key="i" :class="content.style">{{
- content.text
- }}</span>
+ <span
+ v-for="(content, i) in line.content"
+ :key="i"
+ :class="content.style"
+ class="ws-pre-wrap"
+ >{{ content.text }}</span
+ >
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/log/line_header.vue b/app/assets/javascripts/jobs/components/log/line_header.vue
index 92cf3b3cf5f..85ccd5996b5 100644
--- a/app/assets/javascripts/jobs/components/log/line_header.vue
+++ b/app/assets/javascripts/jobs/components/log/line_header.vue
@@ -43,15 +43,19 @@ export default {
<template>
<div
- class="log-line collapsible-line d-flex justify-content-between"
+ class="log-line collapsible-line d-flex justify-content-between ws-normal"
role="button"
@click="handleOnClick"
>
<icon :name="iconName" class="arrow position-absolute" />
<line-number :line-number="line.lineNumber" :path="path" />
- <span v-for="(content, i) in line.content" :key="i" class="line-text" :class="content.style">{{
- content.text
- }}</span>
+ <span
+ v-for="(content, i) in line.content"
+ :key="i"
+ class="line-text w-100 ws-pre-wrap"
+ :class="content.style"
+ >{{ content.text }}</span
+ >
<duration-badge v-if="duration" :duration="duration" />
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/log/line_number.vue b/app/assets/javascripts/jobs/components/log/line_number.vue
index 6c76bef13d3..ae96c32874b 100644
--- a/app/assets/javascripts/jobs/components/log/line_number.vue
+++ b/app/assets/javascripts/jobs/components/log/line_number.vue
@@ -48,7 +48,7 @@ export default {
<template>
<gl-link
:id="lineNumberId"
- class="d-inline-block text-right position-absolute line-number"
+ class="d-inline-block text-right line-number flex-shrink-0"
:href="buildLineNumber"
>{{ parsedLineNumber }}</gl-link
>
diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue
index 429796aeb4e..ef126166e8b 100644
--- a/app/assets/javascripts/jobs/components/log/log.vue
+++ b/app/assets/javascripts/jobs/components/log/log.vue
@@ -1,12 +1,12 @@
<script>
import { mapState, mapActions } from 'vuex';
+import CollpasibleLogSection from './collapsible_section.vue';
import LogLine from './line.vue';
-import LogLineHeader from './line_header.vue';
export default {
components: {
+ CollpasibleLogSection,
LogLine,
- LogLineHeader,
},
computed: {
...mapState(['traceEndpoint', 'trace', 'isTraceComplete']),
@@ -22,24 +22,13 @@ export default {
<template>
<code class="job-log d-block">
<template v-for="(section, index) in trace">
- <template v-if="section.isHeader">
- <log-line-header
- :key="`collapsible-${index}`"
- :line="section.line"
- :duration="section.section_duration"
- :path="traceEndpoint"
- :is-closed="section.isClosed"
- @toggleLine="handleOnClickCollapsibleLine(section)"
- />
- <template v-if="!section.isClosed">
- <log-line
- v-for="line in section.lines"
- :key="line.offset"
- :line="line"
- :path="traceEndpoint"
- />
- </template>
- </template>
+ <collpasible-log-section
+ v-if="section.isHeader"
+ :key="`collapsible-${index}`"
+ :section="section"
+ :trace-endpoint="traceEndpoint"
+ @onClickCollapsibleLine="handleOnClickCollapsibleLine"
+ />
<log-line v-else :key="section.offset" :line="section" :path="traceEndpoint" />
</template>
diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js
index 540c3e2ad69..77c68cac4a6 100644
--- a/app/assets/javascripts/jobs/store/mutations.js
+++ b/app/assets/javascripts/jobs/store/mutations.js
@@ -19,15 +19,14 @@ export default {
state.isSidebarOpen = true;
},
- [types.RECEIVE_TRACE_SUCCESS](state, log) {
+ [types.RECEIVE_TRACE_SUCCESS](state, log = {}) {
if (log.state) {
state.traceState = log.state;
}
if (log.append) {
if (isNewJobLogActive()) {
- state.originalTrace = state.originalTrace.concat(log.trace);
- state.trace = updateIncrementalTrace(state.originalTrace, state.trace, log.lines);
+ state.trace = log.lines ? updateIncrementalTrace(log.lines, state.trace) : state.trace;
} else {
state.trace += log.html;
}
@@ -36,10 +35,9 @@ export default {
// When the job still does not have a trace
// the trace response will not have a defined
// html or size. We keep the old value otherwise these
- // will be set to `undefined`
+ // will be set to `null`
if (isNewJobLogActive()) {
- state.originalTrace = log.lines || state.trace;
- state.trace = logLinesParser(log.lines) || state.trace;
+ state.trace = log.lines ? logLinesParser(log.lines) : state.trace;
} else {
state.trace = log.html || state.trace;
}
diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js
index 585878f8240..cdc1780f3d6 100644
--- a/app/assets/javascripts/jobs/store/state.js
+++ b/app/assets/javascripts/jobs/store/state.js
@@ -19,7 +19,6 @@ export default () => ({
isScrolledToBottomBeforeReceivingTrace: true,
trace: isNewJobLogActive() ? [] : '',
- originalTrace: [],
isTraceComplete: false,
traceSize: 0,
isTraceSizeVisible: false,
diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js
index 261ec90cd12..58e49f54d96 100644
--- a/app/assets/javascripts/jobs/store/utils.js
+++ b/app/assets/javascripts/jobs/store/utils.js
@@ -9,6 +9,85 @@ export const parseLine = (line = {}, lineNumber) => ({
});
/**
+ * When a line has `section_header` set to true, we create a new
+ * structure to allow to nest the lines that belong to the
+ * collpasible section
+ *
+ * @param Object line
+ * @param Number lineNumber
+ */
+export const parseHeaderLine = (line = {}, lineNumber) => ({
+ isClosed: true,
+ isHeader: true,
+ line: parseLine(line, lineNumber),
+ lines: [],
+});
+
+/**
+ * Finds the matching header section
+ * for the section_duration object and adds it to it
+ *
+ * {
+ * isHeader: true,
+ * line: {
+ * content: [],
+ * lineNumber: 0,
+ * section_duration: "",
+ * },
+ * lines: []
+ * }
+ *
+ * @param Array data
+ * @param Object durationLine
+ */
+export function addDurationToHeader(data, durationLine) {
+ data.forEach(el => {
+ if (el.line && el.line.section === durationLine.section) {
+ el.line.section_duration = durationLine.section_duration;
+ }
+ });
+}
+
+/**
+ * Check is the current section belongs to a collapsible section
+ *
+ * @param Array acc
+ * @param Object last
+ * @param Object section
+ *
+ * @returns Boolean
+ */
+export const isCollapsibleSection = (acc = [], last = {}, section = {}) =>
+ acc.length > 0 &&
+ last.isHeader === true &&
+ !section.section_duration &&
+ section.section === last.line.section;
+
+/**
+ * Returns the lineNumber of the last line in
+ * a parsed log
+ *
+ * @param Array acc
+ * @returns Number
+ */
+export const getIncrementalLineNumber = acc => {
+ let lineNumberValue;
+ const lastIndex = acc.length - 1;
+ const lastElement = acc[lastIndex];
+ const nestedLines = lastElement.lines;
+
+ if (lastElement.isHeader && !nestedLines.length && lastElement.line) {
+ lineNumberValue = lastElement.line.lineNumber;
+ } else if (lastElement.isHeader && nestedLines.length) {
+ lineNumberValue = nestedLines[nestedLines.length - 1].lineNumber;
+ } else {
+ lineNumberValue = lastElement.lineNumber;
+ }
+
+ return lineNumberValue === 0 ? 1 : lineNumberValue + 1;
+};
+
+/**
* Parses the job log content into a structure usable by the template
*
* For collaspible lines (section_header = true):
@@ -17,33 +96,71 @@ export const parseLine = (line = {}, lineNumber) => ({
* - adds a isHeader property to handle template logic
* - adds the section_duration
* For each line:
- * - adds the index as lineNumber
+ * - adds the index as lineNumber
*
- * @param {Array} lines
- * @returns {Array}
+ * @param Array lines
+ * @param Array accumulator
+ * @returns Array parsed log lines
*/
-export const logLinesParser = (lines = [], lineNumberStart) =>
- lines.reduce((acc, line, index) => {
- const lineNumber = lineNumberStart ? lineNumberStart + index : index;
- const last = acc[acc.length - 1];
-
- if (line.section_header) {
- acc.push({
- isClosed: true,
- isHeader: true,
- line: parseLine(line, lineNumber),
- lines: [],
- });
- } else if (acc.length && last.isHeader && !line.section_duration && line.content.length) {
- last.lines.push(parseLine(line, lineNumber));
- } else if (acc.length && last.isHeader && line.section_duration) {
- last.section_duration = line.section_duration;
- } else if (line.content.length) {
- acc.push(parseLine(line, lineNumber));
+export const logLinesParser = (lines = [], accumulator = []) =>
+ lines.reduce(
+ (acc, line, index) => {
+ const lineNumber = accumulator.length > 0 ? getIncrementalLineNumber(acc) : index;
+
+ const last = acc[acc.length - 1];
+
+ // If the object is an header, we parse it into another structure
+ if (line.section_header) {
+ acc.push(parseHeaderLine(line, lineNumber));
+ } else if (isCollapsibleSection(acc, last, line)) {
+ // if the object belongs to a nested section, we append it to the new `lines` array of the
+ // previously formated header
+ last.lines.push(parseLine(line, lineNumber));
+ } else if (line.section_duration) {
+ // if the line has section_duration, we look for the correct header to add it
+ addDurationToHeader(acc, line);
+ } else {
+ // otherwise it's a regular line
+ acc.push(parseLine(line, lineNumber));
+ }
+
+ return acc;
+ },
+ [...accumulator],
+ );
+
+/**
+ * Finds the repeated offset, removes the old one
+ *
+ * Returns a new array with the updated log without
+ * the repeated offset
+ *
+ * @param Array newLog
+ * @param Array oldParsed
+ * @returns Array
+ *
+ */
+export const findOffsetAndRemove = (newLog = [], oldParsed = []) => {
+ const cloneOldLog = [...oldParsed];
+ const lastIndex = cloneOldLog.length - 1;
+ const last = cloneOldLog[lastIndex];
+
+ const firstNew = newLog[0];
+
+ if (last && firstNew) {
+ if (last.offset === firstNew.offset || (last.line && last.line.offset === firstNew.offset)) {
+ cloneOldLog.splice(lastIndex);
+ } else if (last.lines && last.lines.length) {
+ const lastNestedIndex = last.lines.length - 1;
+ const lastNested = last.lines[lastNestedIndex];
+ if (lastNested.offset === firstNew.offset) {
+ last.lines.splice(lastNestedIndex);
+ }
}
+ }
- return acc;
- }, []);
+ return cloneOldLog;
+};
/**
* When the trace is not complete, backend may send the last received line
@@ -52,40 +169,13 @@ export const logLinesParser = (lines = [], lineNumberStart) =>
* We need to check if that is the case by looking for the offset property
* before parsing the incremental part
*
- * @param array originalTrace
* @param array oldLog
* @param array newLog
*/
-export const updateIncrementalTrace = (originalTrace = [], oldLog = [], newLog = []) => {
- const firstLine = newLog[0];
- const firstLineOffset = firstLine.offset;
-
- // We are going to return a new array,
- // let's make a shallow copy to make sure we
- // are not updating the state outside of a mutation first.
- const cloneOldLog = [...oldLog];
+export const updateIncrementalTrace = (newLog = [], oldParsed = []) => {
+ const parsedLog = findOffsetAndRemove(newLog, oldParsed);
- const lastIndex = cloneOldLog.length - 1;
- const lastLine = cloneOldLog[lastIndex];
-
- // The last line may be inside a collpasible section
- // If it is, we use the not parsed saved log, remove the last element
- // and parse the first received part togheter with the incremental log
- if (
- lastLine.isHeader &&
- (lastLine.line.offset === firstLineOffset ||
- (lastLine.lines.length &&
- lastLine.lines[lastLine.lines.length - 1].offset === firstLineOffset))
- ) {
- const cloneOriginal = [...originalTrace];
- cloneOriginal.splice(cloneOriginal.length - 1);
- return logLinesParser(cloneOriginal.concat(newLog));
- } else if (lastLine.offset === firstLineOffset) {
- cloneOldLog.splice(lastIndex);
- return cloneOldLog.concat(logLinesParser(newLog, cloneOldLog.length));
- }
- // there are no matches, let's parse the new log and return them together
- return cloneOldLog.concat(logLinesParser(newLog, cloneOldLog.length));
+ return logLinesParser(newLog, parsedLog);
};
export const isNewJobLogActive = () => gon && gon.features && gon.features.jobLogJson;
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index b028e9564c9..72de3b5d726 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-useless-return, func-names, no-var, no-underscore-dangle, prefer-arrow-callback, one-var, prefer-template, no-new, consistent-return, no-shadow, no-param-reassign, vars-on-top, no-lonely-if, no-else-return, dot-notation, no-empty */
+/* eslint-disable no-useless-return, func-names, no-var, no-underscore-dangle, one-var, no-new, consistent-return, no-shadow, no-param-reassign, vars-on-top, no-lonely-if, no-else-return, dot-notation, no-empty */
/* global Issuable */
/* global ListLabel */
@@ -24,7 +24,7 @@ export default class LabelsSelect {
$els = $('.js-label-select');
}
- $els.each(function(i, dropdown) {
+ $els.each((i, dropdown) => {
var $block,
$dropdown,
$form,
@@ -32,6 +32,7 @@ export default class LabelsSelect {
$selectbox,
$sidebarCollapsedValue,
$value,
+ $dropdownMenu,
abilityName,
defaultLabel,
issueUpdateURL,
@@ -67,10 +68,11 @@ export default class LabelsSelect {
$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span');
$sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip');
$value = $block.find('.value');
+ $dropdownMenu = $dropdown.parent().find('.dropdown-menu');
$loading = $block.find('.block-loading').fadeOut();
fieldName = $dropdown.data('fieldName');
initialSelected = $selectbox
- .find('input[name="' + $dropdown.data('fieldName') + '"]')
+ .find(`input[name="${$dropdown.data('fieldName')}"]`)
.map(function() {
return this.value;
})
@@ -92,7 +94,7 @@ export default class LabelsSelect {
var data, selected;
selected = $dropdown
.closest('.selectbox')
- .find("input[name='" + fieldName + "']")
+ .find(`input[name='${fieldName}']`)
.map(function() {
return this.value;
})
@@ -120,7 +122,7 @@ export default class LabelsSelect {
labelCount = 0;
if (data.labels.length && issueUpdateURL) {
template = LabelsSelect.getLabelTemplate({
- labels: data.labels,
+ labels: _.sortBy(data.labels, 'title'),
issueUpdateURL,
enableScopedLabels: scopedLabels,
scopedLabelsDocumentationLink,
@@ -172,9 +174,7 @@ export default class LabelsSelect {
$sidebarCollapsedValue.text(labelCount);
if (data.labels.length) {
- labelTitles = data.labels.map(function(label) {
- return label.title;
- });
+ labelTitles = data.labels.map(label => label.title);
if (labelTitles.length > 5) {
labelTitles = labelTitles.slice(0, 5);
@@ -269,11 +269,7 @@ export default class LabelsSelect {
if (
$form.find(
- "input[type='hidden'][name='" +
- this.fieldName +
- "'][value='" +
- dropdownValue +
- "']",
+ `input[type='hidden'][name='${this.fieldName}'][value='${dropdownValue}']`,
).length
) {
selectedClass.push('is-active');
@@ -286,8 +282,7 @@ export default class LabelsSelect {
}
if (label.color) {
- colorEl =
- "<span class='dropdown-label-box' style='background: " + label.color + "'></span>";
+ colorEl = `<span class='dropdown-label-box' style='background: ${label.color}'></span>`;
} else {
colorEl = '';
}
@@ -456,16 +451,26 @@ export default class LabelsSelect {
);
} else {
var { labels } = boardsStore.detail.issue;
- labels = labels.filter(function(selectedLabel) {
- return selectedLabel.id !== label.id;
- });
+ labels = labels.filter(selectedLabel => selectedLabel.id !== label.id);
boardsStore.detail.issue.labels = labels;
}
$loading.fadeIn();
+ const oldLabels = boardsStore.detail.issue.labels;
boardsStore.detail.issue
.update($dropdown.attr('data-issue-update'))
+ .then(() => {
+ if (isScopedLabel(label)) {
+ const prevIds = oldLabels.map(label => label.id);
+ const newIds = boardsStore.detail.issue.labels.map(label => label.id);
+ const differentIds = _.difference(prevIds, newIds);
+ $dropdown.data('marked', newIds);
+ $dropdownMenu
+ .find(differentIds.map(id => `[data-label-id="${id}"]`).join(','))
+ .removeClass('is-active');
+ }
+ })
.then(fadeOutLoader)
.catch(fadeOutLoader);
} else if (handleClick) {
diff --git a/app/assets/javascripts/lib/utils/axios_utils.js b/app/assets/javascripts/lib/utils/axios_utils.js
index 37721cd030c..a04fe609015 100644
--- a/app/assets/javascripts/lib/utils/axios_utils.js
+++ b/app/assets/javascripts/lib/utils/axios_utils.js
@@ -1,5 +1,6 @@
import axios from 'axios';
import csrf from './csrf';
+import suppressAjaxErrorsDuringNavigation from './suppress_ajax_errors_during_navigation';
axios.defaults.headers.common[csrf.headerKey] = csrf.token;
// Used by Rails to check if it is a valid XHR request
@@ -8,23 +9,37 @@ axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
// Maintain a global counter for active requests
// see: spec/support/wait_for_requests.rb
axios.interceptors.request.use(config => {
- window.activeVueResources = window.activeVueResources || 0;
- window.activeVueResources += 1;
+ window.pendingRequests = window.pendingRequests || 0;
+ window.pendingRequests += 1;
return config;
});
// Remove the global counter
axios.interceptors.response.use(
response => {
- window.activeVueResources -= 1;
+ window.pendingRequests -= 1;
return response;
},
err => {
- window.activeVueResources -= 1;
+ window.pendingRequests -= 1;
return Promise.reject(err);
},
);
+let isUserNavigating = false;
+window.addEventListener('beforeunload', () => {
+ isUserNavigating = true;
+});
+
+// Ignore AJAX errors caused by requests
+// being cancelled due to browser navigation
+const { gon } = window;
+const featureFlagEnabled = gon && gon.features && gon.features.suppressAjaxNavigationErrors;
+axios.interceptors.response.use(
+ response => response,
+ err => suppressAjaxErrorsDuringNavigation(err, isUserNavigating, featureFlagEnabled),
+);
+
export default axios;
/**
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 6e8f63a10a4..177ae4f9838 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -15,6 +15,8 @@ export const getPagePath = (index = 0) => {
return page.split(':')[index];
};
+export const getDashPath = (path = window.location.pathname) => path.split('/-/')[1] || null;
+
export const isInGroupsPage = () => getPagePath() === 'groups';
export const isInProjectPage = () => getPagePath() === 'projects';
@@ -175,6 +177,15 @@ export const urlParamsToArray = (path = '') =>
export const getUrlParamsArray = () => urlParamsToArray(window.location.search);
+/**
+ * Accepts encoding string which includes query params being
+ * sent to URL.
+ *
+ * @param {string} path Query param string
+ *
+ * @returns {object} Query params object containing key-value pairs
+ * with both key and values decoded into plain string.
+ */
export const urlParamsToObject = (path = '') =>
splitPath(path).reduce((dataParam, filterParam) => {
if (filterParam === '') {
@@ -183,6 +194,7 @@ export const urlParamsToObject = (path = '') =>
const data = dataParam;
let [key, value] = filterParam.split('=');
+ key = /%\w+/g.test(key) ? decodeURIComponent(key) : key;
const isArray = key.includes('[]');
key = key.replace('[]', '');
value = decodeURIComponent(value.replace(/\+/g, ' '));
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index a4715789337..37b0215f6f9 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -537,13 +537,6 @@ export const stringifyTime = (timeObject, fullNameFormat = false) => {
};
/**
- * Accepts a time string of any size (e.g. '1w 2d 3h 5m' or '1w 2d') and returns
- * the first non-zero unit/value pair.
- */
-export const abbreviateTime = timeStr =>
- timeStr.split(' ').filter(unitStr => unitStr.charAt(0) !== '0')[0];
-
-/**
* Calculates the milliseconds between now and a given date string.
* The result cannot become negative.
*
@@ -554,3 +547,20 @@ export const calculateRemainingMilliseconds = endDate => {
const remainingMilliseconds = new Date(endDate).getTime() - Date.now();
return Math.max(remainingMilliseconds, 0);
};
+
+/**
+ * Subtracts a given number of days from a given date and returns the new date.
+ *
+ * @param {Date} date the date that we will substract days from
+ * @param {number} daysInPast number of days that are subtracted from a given date
+ * @returns {String} Date string in ISO format
+ */
+export const getDateInPast = (date, daysInPast) => {
+ const dateClone = newDate(date);
+ return new Date(
+ dateClone.setTime(dateClone.getTime() - daysInPast * 24 * 60 * 60 * 1000),
+ ).toISOString();
+};
+
+export const beginOfDayTime = 'T00:00:00Z';
+export const endOfDayTime = 'T23:59:59Z';
diff --git a/app/assets/javascripts/lib/utils/notify.js b/app/assets/javascripts/lib/utils/notify.js
index 3439db1e326..cd509a13193 100644
--- a/app/assets/javascripts/lib/utils/notify.js
+++ b/app/assets/javascripts/lib/utils/notify.js
@@ -1,12 +1,14 @@
-/* eslint-disable func-names, no-var, consistent-return, prefer-arrow-callback, no-return-assign */
+/* eslint-disable no-var, consistent-return, no-return-assign */
function notificationGranted(message, opts, onclick) {
var notification;
notification = new Notification(message, opts);
- setTimeout(function() {
- // Hide the notification after X amount of seconds
- return notification.close();
- }, 8000);
+ setTimeout(
+ () =>
+ // Hide the notification after X amount of seconds
+ notification.close(),
+ 8000,
+ );
return (notification.onclick = onclick || notification.close);
}
@@ -32,7 +34,7 @@ function notifyMe(message, body, icon, onclick) {
// If it's okay let's create a notification
return notificationGranted(message, opts, onclick);
} else if (Notification.permission !== 'denied') {
- return Notification.requestPermission(function(permission) {
+ return Notification.requestPermission(permission => {
// If the user accepts, let's create a notification
if (permission === 'granted') {
return notificationGranted(message, opts, onclick);
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index 61c8b8803d7..0f2cc57b1f9 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -106,3 +106,14 @@ export const sum = (a = 0, b = 0) => a + b;
* @param {Int} number
*/
export const isOdd = (number = 0) => number % 2;
+
+/**
+ * Computes the median for a given array.
+ * @param {Array} arr An array of numbers
+ * @returns {Number} The median of the given array
+ */
+export const median = arr => {
+ const middle = Math.floor(arr.length / 2);
+ const sorted = arr.sort((a, b) => a - b);
+ return arr.length % 2 !== 0 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
+};
diff --git a/app/assets/javascripts/lib/utils/set.js b/app/assets/javascripts/lib/utils/set.js
new file mode 100644
index 00000000000..3845d648b61
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/set.js
@@ -0,0 +1,9 @@
+/**
+ * Checks if the first argument is a subset of the second argument.
+ * @param {Set} subset The set to be considered as the subset.
+ * @param {Set} superset The set to be considered as the superset.
+ * @returns {boolean}
+ */
+// eslint-disable-next-line import/prefer-default-export
+export const isSubset = (subset, superset) =>
+ Array.from(subset).every(value => superset.has(value));
diff --git a/app/assets/javascripts/lib/utils/suppress_ajax_errors_during_navigation.js b/app/assets/javascripts/lib/utils/suppress_ajax_errors_during_navigation.js
new file mode 100644
index 00000000000..4c61da9b862
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/suppress_ajax_errors_during_navigation.js
@@ -0,0 +1,16 @@
+/**
+ * An Axios error interceptor that suppresses AJAX errors caused
+ * by the request being cancelled when the user navigates to a new page
+ */
+export default (err, isUserNavigating, featureFlagEnabled) => {
+ if (featureFlagEnabled && isUserNavigating && err.code === 'ECONNABORTED') {
+ // If the user is navigating away from the current page,
+ // prevent .then() and .catch() handlers from being
+ // called by returning a Promise that never resolves
+ return new Promise(() => {});
+ }
+
+ // The error is not related to browser navigation,
+ // so propagate the error
+ return Promise.reject(err);
+};
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 7873eaf059f..2e0270ee42f 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, no-param-reassign, one-var, operator-assignment, no-else-return, prefer-template, prefer-arrow-callback, consistent-return */
+/* eslint-disable func-names, no-var, no-param-reassign, one-var, operator-assignment, no-else-return, consistent-return */
import $ from 'jquery';
import { insertText } from '~/lib/utils/common_utils';
@@ -218,7 +218,7 @@ export function insertMarkdownText({
: blockTagText(text, textArea, blockTag, selected);
} else {
textToInsert = selectedSplit
- .map(function(val) {
+ .map(val => {
if (tag.indexOf(textPlaceholder) > -1) {
return tag.replace(textPlaceholder, val);
}
@@ -237,7 +237,7 @@ export function insertMarkdownText({
}
if (removedFirstNewLine) {
- textToInsert = '\n' + textToInsert;
+ textToInsert = `\n${textToInsert}`;
}
if (removedLastNewLine) {
@@ -301,7 +301,7 @@ export function addMarkdownListeners(form) {
export function addEditorMarkdownListeners(editor) {
$('.js-md')
.off('click')
- .on('click', function(e) {
+ .on('click', e => {
const { mdTag, mdBlock, mdPrepend, mdSelect } = $(e.currentTarget).data();
insertMarkdownText({
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index 7ead9d46fbb..4be0d05a9b7 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -88,6 +88,14 @@ export function getLocationHash(url = window.location.href) {
}
/**
+ * Returns a boolean indicating whether the URL hash contains the given string value
+ */
+export function doesHashExistInUrl(hashName) {
+ const hash = getLocationHash();
+ return hash && hash.includes(hashName);
+}
+
+/**
* Apply the fragment to the given url by returning a new url string that includes
* the fragment. If the given url already contains a fragment, the original fragment
* will be removed.
diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js
index 4db63c841a9..b6b96fe7bd5 100644
--- a/app/assets/javascripts/line_highlighter.js
+++ b/app/assets/javascripts/line_highlighter.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, no-underscore-dangle, no-param-reassign, prefer-template, consistent-return, one-var, no-else-return */
+/* eslint-disable func-names, no-var, no-underscore-dangle, no-param-reassign, consistent-return, one-var, no-else-return */
import $ from 'jquery';
@@ -106,7 +106,7 @@ LineHighlighter.prototype.clickHandler = function(event) {
};
LineHighlighter.prototype.clearHighlight = function() {
- return $('.' + this.highlightLineClass).removeClass(this.highlightLineClass);
+ return $(`.${this.highlightLineClass}`).removeClass(this.highlightLineClass);
};
// Convert a URL hash String into line numbers
@@ -137,7 +137,7 @@ LineHighlighter.prototype.hashToRange = function(hash) {
//
// lineNumber - Line number to highlight
LineHighlighter.prototype.highlightLine = function(lineNumber) {
- return $('#LC' + lineNumber).addClass(this.highlightLineClass);
+ return $(`#LC${lineNumber}`).addClass(this.highlightLineClass);
};
// Highlight all lines within a range
@@ -162,9 +162,9 @@ LineHighlighter.prototype.highlightRange = function(range) {
LineHighlighter.prototype.setHash = function(firstLineNumber, lastLineNumber) {
var hash;
if (lastLineNumber) {
- hash = '#L' + firstLineNumber + '-' + lastLineNumber;
+ hash = `#L${firstLineNumber}-${lastLineNumber}`;
} else {
- hash = '#L' + firstLineNumber;
+ hash = `#L${firstLineNumber}`;
}
this._hash = hash;
return this.__setLocationHash__(hash);
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 0ddf40b0405..c19a845eb69 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -37,6 +37,7 @@ import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
import { initUserTracking } from './tracking';
import { __ } from './locale';
+import initPrivacyPolicyUpdateCallout from './privacy_policy_update_callout';
import 'ee_else_ce/main_ee';
@@ -96,6 +97,7 @@ function deferredInitialisation() {
initUsagePingConsent();
initUserPopovers();
initUserTracking();
+ initPrivacyPolicyUpdateCallout();
if (document.querySelector('.search')) initSearchAutocomplete();
@@ -312,6 +314,7 @@ document.addEventListener('DOMContentLoaded', () => {
const action = `${this.action}${link.search === '' ? '?' : '&'}`;
event.preventDefault();
+ // eslint-disable-next-line no-jquery/no-serialize
visitUrl(`${action}${$(this).serialize()}`);
});
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index 3b42a154af8..7223b5c0d43 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, no-underscore-dangle, one-var, consistent-return, prefer-arrow-callback */
+/* eslint-disable func-names, no-var, no-underscore-dangle, one-var, consistent-return */
import $ from 'jquery';
import { __ } from '~/locale';
@@ -105,7 +105,7 @@ MergeRequest.prototype.submitNoteForm = function(form, $button) {
};
MergeRequest.prototype.initCommitMessageListeners = function() {
- $(document).on('click', 'a.js-with-description-link', function(e) {
+ $(document).on('click', 'a.js-with-description-link', e => {
var textarea = $('textarea.js-commit-message');
e.preventDefault();
@@ -114,7 +114,7 @@ MergeRequest.prototype.initCommitMessageListeners = function() {
$('.js-without-description-hint').show();
});
- $(document).on('click', 'a.js-without-description-link', function(e) {
+ $(document).on('click', 'a.js-without-description-link', e => {
var textarea = $('textarea.js-commit-message');
e.preventDefault();
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
index f3f3bf15295..78fe575717a 100644
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -1,6 +1,6 @@
<script>
-import { __ } from '~/locale';
-import { GlLink, GlButton } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import { GlLink, GlButton, GlTooltip } from '@gitlab/ui';
import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
import { debounceByAnimationFrame, roundOffFloat } from '~/lib/utils/common_utils';
@@ -16,6 +16,7 @@ export default {
components: {
GlAreaChart,
GlLineChart,
+ GlTooltip,
GlButton,
GlChartSeriesLabel,
GlLink,
@@ -52,6 +53,21 @@ export default {
required: false,
default: () => [],
},
+ legendAverageText: {
+ type: String,
+ required: false,
+ default: s__('Metrics|Avg'),
+ },
+ legendMaxText: {
+ type: String,
+ required: false,
+ default: s__('Metrics|Max'),
+ },
+ groupId: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -62,6 +78,7 @@ export default {
isDeployment: false,
sha: '',
},
+ showTitleTooltip: false,
width: 0,
height: chartHeight,
svgs: {},
@@ -122,7 +139,7 @@ export default {
},
},
series: this.scatterSeries,
- dataZoom: this.dataZoomConfig,
+ dataZoom: [this.dataZoomConfig],
};
},
dataZoomConfig() {
@@ -192,6 +209,12 @@ export default {
watch: {
containerWidth: 'onResize',
},
+ mounted() {
+ const graphTitleEl = this.$refs.graphTitle;
+ if (graphTitleEl && graphTitleEl.scrollWidth > graphTitleEl.offsetWidth) {
+ this.showTitleTooltip = true;
+ }
+ },
beforeDestroy() {
window.removeEventListener('resize', debouncedResize);
},
@@ -255,22 +278,32 @@ export default {
<template>
<div class="prometheus-graph">
<div class="prometheus-graph-header">
- <h5 class="prometheus-graph-title js-graph-title">{{ graphData.title }}</h5>
- <div class="prometheus-graph-widgets js-graph-widgets">
+ <h5
+ ref="graphTitle"
+ class="prometheus-graph-title js-graph-title text-truncate append-right-8"
+ >
+ {{ graphData.title }}
+ </h5>
+ <gl-tooltip :target="() => $refs.graphTitle" :disabled="!showTitleTooltip">
+ {{ graphData.title }}
+ </gl-tooltip>
+ <div class="prometheus-graph-widgets js-graph-widgets flex-fill">
<slot></slot>
</div>
</div>
-
<component
:is="glChartComponent"
ref="chart"
v-bind="$attrs"
+ :group-id="groupId"
:data="chartData"
:option="chartOptions"
:format-tooltip-text="formatTooltipText"
:thresholds="thresholds"
:width="width"
:height="height"
+ :average-text="legendAverageText"
+ :max-text="legendMaxText"
@updated="onChartUpdated"
>
<template v-if="tooltip.isDeployment">
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 12a4c83e053..b4ea415bb51 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,4 +1,7 @@
<script>
+import _ from 'underscore';
+import { mapActions, mapState } from 'vuex';
+import VueDraggable from 'vuedraggable';
import {
GlButton,
GlDropdown,
@@ -8,24 +11,26 @@ import {
GlModalDirective,
GlTooltipDirective,
} from '@gitlab/ui';
-import _ from 'underscore';
-import { mapActions, mapState } from 'vuex';
import { __, s__ } from '~/locale';
+import createFlash from '~/flash';
import Icon from '~/vue_shared/components/icon.vue';
-import { getParameterValues, mergeUrlParams } from '~/lib/utils/url_utility';
+import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
import invalidUrl from '~/lib/utils/invalid_url';
import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
+import DateTimePicker from './date_time_picker/date_time_picker.vue';
import MonitorTimeSeriesChart from './charts/time_series.vue';
import MonitorSingleStatChart from './charts/single_stat.vue';
import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue';
-import { sidebarAnimationDuration, timeWindows } from '../constants';
-import { getTimeDiff, getTimeWindow } from '../utils';
+import { sidebarAnimationDuration } from '../constants';
+import TrackEventDirective from '~/vue_shared/directives/track_event';
+import { getTimeDiff, isValidDate, downloadCSVOptions, generateLinkToChartOptions } from '../utils';
let sidebarMutationObserver;
export default {
components: {
+ VueDraggable,
MonitorTimeSeriesChart,
MonitorSingleStatChart,
PanelType,
@@ -37,10 +42,12 @@ export default {
GlDropdownItem,
GlFormGroup,
GlModal,
+ DateTimePicker,
},
directives: {
GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective,
+ TrackEvent: TrackEventDirective,
},
props: {
externalDashboardUrl: {
@@ -151,15 +158,19 @@ export default {
required: false,
default: false,
},
+ rearrangePanelsAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
state: 'gettingStarted',
elWidth: 0,
- selectedTimeWindow: '',
- selectedTimeWindowKey: '',
formIsValid: null,
- timeWindows: {},
+ selectedTimeWindow: {},
+ isRearrangingPanels: false,
};
},
computed: {
@@ -175,7 +186,6 @@ export default {
'metricsWithData',
'useDashboardEndpoint',
'allDashboards',
- 'multipleDashboardsEnabled',
'additionalPanelTypesEnabled',
]),
firstDashboard() {
@@ -184,6 +194,9 @@ export default {
selectedDashboardText() {
return this.currentDashboard || this.firstDashboard.display_name;
},
+ showRearrangePanelsBtn() {
+ return !this.showEmptyState && this.rearrangePanelsAvailable;
+ },
addingMetricsAvailable() {
return IS_EE && this.canAddMetrics && !this.showEmptyState;
},
@@ -219,11 +232,13 @@ export default {
end,
};
- this.timeWindows = timeWindows;
- this.selectedTimeWindowKey = getTimeWindow(range);
- this.selectedTimeWindow = this.timeWindows[this.selectedTimeWindowKey];
+ this.selectedTimeWindow = range;
- this.fetchData(range);
+ if (!isValidDate(start) || !isValidDate(end)) {
+ this.showInvalidDateError();
+ } else {
+ this.fetchData(range);
+ }
sidebarMutationObserver = new MutationObserver(this.onSidebarMutation);
sidebarMutationObserver.observe(document.querySelector('.layout-page'), {
@@ -272,9 +287,17 @@ export default {
return Object.values(this.getGraphAlerts(queries));
},
showToast() {
- this.$toast.show(__('Link copied to clipboard'));
+ this.$toast.show(__('Link copied'));
},
// TODO: END
+ removeGraph(metrics, graphIndex) {
+ // At present graphs will not be removed, they should removed using the vuex store
+ // See https://gitlab.com/gitlab-org/gitlab/issues/27835
+ metrics.splice(graphIndex, 1);
+ },
+ showInvalidDateError() {
+ createFlash(s__('Metrics|Link contains an invalid time window.'));
+ },
generateLink(group, title, yLabel) {
const dashboard = this.currentDashboard || this.firstDashboard.path;
const params = _.pick({ dashboard, group, title, y_label: yLabel }, value => value != null);
@@ -288,22 +311,23 @@ export default {
this.elWidth = this.$el.clientWidth;
}, sidebarAnimationDuration);
},
+ toggleRearrangingPanels() {
+ this.isRearrangingPanels = !this.isRearrangingPanels;
+ },
setFormValidity(isValid) {
this.formIsValid = isValid;
},
submitCustomMetricsForm() {
this.$refs.customMetricsForm.submit();
},
- activeTimeWindow(key) {
- return this.timeWindows[key] === this.selectedTimeWindow;
- },
- setTimeWindowParameter(key) {
- const { start, end } = getTimeDiff(key);
- return `?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`;
- },
groupHasData(group) {
return this.chartsWithData(group.metrics).length > 0;
},
+ onDateTimePickerApply(timeWindowUrlParams) {
+ return redirectTo(mergeUrlParams(timeWindowUrlParams, window.location.href));
+ },
+ downloadCSVOptions,
+ generateLinkToChartOptions,
},
addMetric: {
title: s__('Metrics|Add metric'),
@@ -314,15 +338,14 @@ export default {
<template>
<div class="prometheus-graphs">
- <div class="gl-p-3 pb-0 border-bottom bg-gray-light">
+ <div class="prometheus-graphs-header gl-p-3 pb-0 border-bottom bg-gray-light">
<div class="row">
<template v-if="environmentsEndpoint">
<gl-form-group
- v-if="multipleDashboardsEnabled"
:label="__('Dashboard')"
label-size="sm"
label-for="monitor-dashboards-dropdown"
- class="col-sm-12 col-md-4 col-lg-2"
+ class="col-sm-12 col-md-6 col-lg-2"
>
<gl-dropdown
id="monitor-dashboards-dropdown"
@@ -345,7 +368,7 @@ export default {
:label="s__('Metrics|Environment')"
label-size="sm"
label-for="monitor-environments-dropdown"
- class="col-sm-6 col-md-4 col-lg-2"
+ class="col-sm-6 col-md-6 col-lg-2"
>
<gl-dropdown
id="monitor-environments-dropdown"
@@ -370,36 +393,35 @@ export default {
:label="s__('Metrics|Show last')"
label-size="sm"
label-for="monitor-time-window-dropdown"
- class="col-sm-6 col-md-4 col-lg-2"
+ class="col-sm-6 col-md-6 col-lg-4"
>
- <gl-dropdown
- id="monitor-time-window-dropdown"
- class="mb-0 d-flex js-time-window-dropdown"
- toggle-class="dropdown-menu-toggle"
- :text="selectedTimeWindow"
- >
- <gl-dropdown-item
- v-for="(value, key) in timeWindows"
- :key="key"
- :active="activeTimeWindow(key)"
- :href="setTimeWindowParameter(key)"
- active-class="active"
- >{{ value }}</gl-dropdown-item
- >
- </gl-dropdown>
+ <date-time-picker
+ :selected-time-window="selectedTimeWindow"
+ @onApply="onDateTimePickerApply"
+ />
</gl-form-group>
</template>
<gl-form-group
- v-if="addingMetricsAvailable || externalDashboardUrl.length"
+ v-if="addingMetricsAvailable || showRearrangePanelsBtn || externalDashboardUrl.length"
label-for="prometheus-graphs-dropdown-buttons"
- class="dropdown-buttons col-lg d-lg-flex align-items-end"
+ class="dropdown-buttons col-md d-md-flex col-lg d-lg-flex align-items-end"
>
<div id="prometheus-graphs-dropdown-buttons">
<gl-button
+ v-if="showRearrangePanelsBtn"
+ :pressed="isRearrangingPanels"
+ variant="default"
+ class="mr-2 mt-1 js-rearrange-button"
+ @click="toggleRearrangingPanels"
+ >
+ {{ __('Arrange charts') }}
+ </gl-button>
+ <gl-button
v-if="addingMetricsAvailable"
v-gl-modal="$options.addMetric.modalId"
- class="mr-2 mt-1 js-add-metric-button text-success border-success"
+ variant="outline-success"
+ class="mr-2 mt-1 js-add-metric-button"
>
{{ $options.addMetric.title }}
</gl-button>
@@ -453,17 +475,42 @@ export default {
:collapse-group="groupHasData(groupData)"
>
<template v-if="additionalPanelTypesEnabled">
- <panel-type
- v-for="(graphData, graphIndex) in groupData.metrics"
- :key="`panel-type-${graphIndex}`"
- class="col-12 col-lg-6 pb-3"
- :clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)"
- :graph-data="graphData"
- :dashboard-width="elWidth"
- :alerts-endpoint="alertsEndpoint"
- :prometheus-alerts-available="prometheusAlertsAvailable"
- :index="`${index}-${graphIndex}`"
- />
+ <vue-draggable
+ :list="groupData.metrics"
+ group="metrics-dashboard"
+ :component-data="{ attrs: { class: 'row mx-0 w-100' } }"
+ :disabled="!isRearrangingPanels"
+ >
+ <div
+ v-for="(graphData, graphIndex) in groupData.metrics"
+ :key="`panel-type-${graphIndex}`"
+ class="col-12 col-lg-6 px-2 mb-2 draggable"
+ :class="{ 'draggable-enabled': isRearrangingPanels }"
+ >
+ <div class="position-relative draggable-panel js-draggable-panel">
+ <div
+ v-if="isRearrangingPanels"
+ class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end"
+ @click="removeGraph(groupData.metrics, graphIndex)"
+ >
+ <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')"
+ ><icon name="close"
+ /></a>
+ </div>
+
+ <panel-type
+ :clipboard-text="
+ generateLink(groupData.group, graphData.title, graphData.y_label)
+ "
+ :graph-data="graphData"
+ :dashboard-width="elWidth"
+ :alerts-endpoint="alertsEndpoint"
+ :prometheus-alerts-available="prometheusAlertsAvailable"
+ :index="`${index}-${graphIndex}`"
+ />
+ </div>
+ </div>
+ </vue-draggable>
</template>
<template v-else>
<monitor-time-series-chart
@@ -477,7 +524,10 @@ export default {
:project-path="projectPath"
group-id="monitor-time-series-chart"
>
- <div class="d-flex align-items-center">
+ <div
+ class="d-flex align-items-center"
+ :class="alertWidgetAvailable ? 'justify-content-between' : 'justify-content-end'"
+ >
<alert-widget
v-if="alertWidgetAvailable && graphData"
:modal-id="`alert-modal-${index}-${graphIndex}`"
@@ -488,7 +538,7 @@ export default {
/>
<gl-dropdown
v-gl-tooltip
- class="mx-2"
+ class="ml-2 mr-3"
toggle-class="btn btn-transparent border-0"
:right="true"
:no-caret="true"
@@ -497,10 +547,19 @@ export default {
<template slot="button-content">
<icon name="ellipsis_v" class="text-secondary" />
</template>
- <gl-dropdown-item :href="downloadCsv(graphData)" download="chart_metrics.csv">
+ <gl-dropdown-item
+ v-track-event="downloadCSVOptions(graphData.title)"
+ :href="downloadCsv(graphData)"
+ download="chart_metrics.csv"
+ >
{{ __('Download CSV') }}
</gl-dropdown-item>
<gl-dropdown-item
+ v-track-event="
+ generateLinkToChartOptions(
+ generateLink(groupData.group, graphData.title, graphData.y_label),
+ )
+ "
class="js-chart-link"
:data-clipboard-text="
generateLink(groupData.group, graphData.title, graphData.y_label)
diff --git a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue
new file mode 100644
index 00000000000..4616a767295
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue
@@ -0,0 +1,151 @@
+<script>
+import { GlButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+import DateTimePickerInput from './date_time_picker_input.vue';
+import {
+ getTimeDiff,
+ getTimeWindow,
+ stringToISODate,
+ ISODateToString,
+ truncateZerosInDateTime,
+ isDateTimePickerInputValid,
+} from '~/monitoring/utils';
+import { timeWindows } from '~/monitoring/constants';
+
+export default {
+ components: {
+ Icon,
+ DateTimePickerInput,
+ GlFormGroup,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ },
+ props: {
+ timeWindows: {
+ type: Object,
+ required: false,
+ default: () => timeWindows,
+ },
+ selectedTimeWindow: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ },
+ data() {
+ return {
+ selectedTimeWindowText: '',
+ customTime: {
+ from: null,
+ to: null,
+ },
+ };
+ },
+ computed: {
+ applyEnabled() {
+ return Boolean(this.inputState.from && this.inputState.to);
+ },
+ inputState() {
+ const { from, to } = this.customTime;
+ return {
+ from: from && isDateTimePickerInputValid(from),
+ to: to && isDateTimePickerInputValid(to),
+ };
+ },
+ },
+ mounted() {
+ const range = getTimeWindow(this.selectedTimeWindow);
+ if (range) {
+ this.selectedTimeWindowText = this.timeWindows[range];
+ } else {
+ this.customTime = {
+ from: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.start)),
+ to: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.end)),
+ };
+ this.selectedTimeWindowText = sprintf(s__('%{from} to %{to}'), this.customTime);
+ }
+ },
+ methods: {
+ activeTimeWindow(key) {
+ return this.timeWindows[key] === this.selectedTimeWindowText;
+ },
+ setCustomTimeWindowParameter() {
+ this.$emit('onApply', {
+ start: stringToISODate(this.customTime.from),
+ end: stringToISODate(this.customTime.to),
+ });
+ },
+ setTimeWindowParameter(key) {
+ const { start, end } = getTimeDiff(key);
+ this.$emit('onApply', {
+ start,
+ end,
+ });
+ },
+ closeDropdown() {
+ this.$refs.dropdown.hide();
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown
+ ref="dropdown"
+ :text="selectedTimeWindowText"
+ menu-class="time-window-dropdown-menu"
+ class="js-time-window-dropdown"
+ >
+ <div class="d-flex justify-content-between time-window-dropdown-menu-container">
+ <gl-form-group
+ :label="__('Custom range')"
+ label-for="custom-from-time"
+ class="custom-time-range-form-group col-md-7 p-0 m-0"
+ >
+ <date-time-picker-input
+ id="custom-time-from"
+ v-model="customTime.from"
+ :label="__('From')"
+ :state="inputState.from"
+ />
+ <date-time-picker-input
+ id="custom-time-to"
+ v-model="customTime.to"
+ :label="__('To')"
+ :state="inputState.to"
+ />
+ <gl-form-group>
+ <gl-button @click="closeDropdown">{{ __('Cancel') }}</gl-button>
+ <gl-button
+ variant="success"
+ :disabled="!applyEnabled"
+ @click="setCustomTimeWindowParameter"
+ >{{ __('Apply') }}</gl-button
+ >
+ </gl-form-group>
+ </gl-form-group>
+ <gl-form-group
+ :label="__('Quick range')"
+ label-for="group-id-dropdown"
+ label-align="center"
+ class="col-md-4 p-0 m-0"
+ >
+ <gl-dropdown-item
+ v-for="(value, key) in timeWindows"
+ :key="key"
+ :active="activeTimeWindow(key)"
+ active-class="active"
+ @click="setTimeWindowParameter(key)"
+ >
+ <icon
+ name="mobile-issue-close"
+ class="align-bottom"
+ :class="{ invisible: !activeTimeWindow(key) }"
+ />
+ {{ value }}
+ </gl-dropdown-item>
+ </gl-form-group>
+ </div>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue
new file mode 100644
index 00000000000..0388a6190d9
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue
@@ -0,0 +1,77 @@
+<script>
+import _ from 'underscore';
+import { s__, sprintf } from '~/locale';
+import { GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { dateFormats } from '~/monitoring/constants';
+
+const inputGroupText = {
+ invalidFeedback: sprintf(s__('Format: %{dateFormat}'), {
+ dateFormat: dateFormats.dateTimePicker.format,
+ }),
+ placeholder: dateFormats.dateTimePicker.format,
+};
+
+export default {
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ },
+ props: {
+ state: {
+ default: null,
+ required: true,
+ validator: prop => typeof prop === 'boolean' || prop === null,
+ },
+ value: {
+ default: null,
+ required: false,
+ validator: prop => typeof prop === 'string' || prop === null,
+ },
+ label: {
+ type: String,
+ default: '',
+ required: true,
+ },
+ id: {
+ type: String,
+ required: false,
+ default: () => _.uniqueId('dateTimePicker_'),
+ },
+ },
+ data() {
+ return {
+ inputGroupText,
+ };
+ },
+ computed: {
+ invalidFeedback() {
+ return this.state ? '' : this.inputGroupText.invalidFeedback;
+ },
+ inputState() {
+ // When the state is valid we want to show no
+ // green outline. Hence passing null and not true.
+ if (this.state === true) {
+ return null;
+ }
+ return this.state;
+ },
+ },
+ methods: {
+ onInputBlur(e) {
+ this.$emit('input', e.target.value.trim() || null);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group :label="label" label-size="sm" :label-for="id" :invalid-feedback="invalidFeedback">
+ <gl-form-input
+ :id="id"
+ :value="value"
+ :state="inputState"
+ :placeholder="inputGroupText.placeholder"
+ @blur="onInputBlur"
+ />
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue
index da1e88071ab..7857aaa6ecc 100644
--- a/app/assets/javascripts/monitoring/components/embed.vue
+++ b/app/assets/javascripts/monitoring/components/embed.vue
@@ -98,7 +98,7 @@ export default {
class="w-100"
:graph-data="graphData"
:container-width="elWidth"
- group-id="monitor-area-chart"
+ :group-id="dashboardUrl"
:project-path="null"
:show-border="true"
:single-embed="isSingleChart"
diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue
index 72ddd8d4fcf..ee3a2bae79b 100644
--- a/app/assets/javascripts/monitoring/components/graph_group.vue
+++ b/app/assets/javascripts/monitoring/components/graph_group.vue
@@ -52,7 +52,7 @@ export default {
<div
v-if="collapseGroup"
v-show="collapseGroup && showGroup"
- class="card-body prometheus-graph-group"
+ class="card-body prometheus-graph-group p-0"
>
<slot></slot>
</div>
diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue
index 73ff651d510..1a14d06f4c8 100644
--- a/app/assets/javascripts/monitoring/components/panel_type.vue
+++ b/app/assets/javascripts/monitoring/components/panel_type.vue
@@ -13,6 +13,8 @@ import Icon from '~/vue_shared/components/icon.vue';
import MonitorTimeSeriesChart from './charts/time_series.vue';
import MonitorSingleStatChart from './charts/single_stat.vue';
import MonitorEmptyChart from './charts/empty_chart.vue';
+import TrackEventDirective from '~/vue_shared/directives/track_event';
+import { downloadCSVOptions, generateLinkToChartOptions } from '../utils';
export default {
components: {
@@ -27,6 +29,7 @@ export default {
directives: {
GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective,
+ TrackEvent: TrackEventDirective,
},
props: {
clipboardText: {
@@ -82,8 +85,10 @@ export default {
return this.graphData.type && this.graphData.type === type;
},
showToast() {
- this.$toast.show(__('Link copied to clipboard'));
+ this.$toast.show(__('Link copied'));
},
+ downloadCSVOptions,
+ generateLinkToChartOptions,
},
};
</script>
@@ -121,13 +126,18 @@ export default {
<template slot="button-content">
<icon name="ellipsis_v" class="text-secondary" />
</template>
- <gl-dropdown-item :href="downloadCsv" download="chart_metrics.csv">
+ <gl-dropdown-item
+ v-track-event="downloadCSVOptions(graphData.title)"
+ :href="downloadCsv"
+ download="chart_metrics.csv"
+ >
{{ __('Download CSV') }}
</gl-dropdown-item>
<gl-dropdown-item
+ v-track-event="generateLinkToChartOptions(clipboardText)"
class="js-chart-link"
:data-clipboard-text="clipboardText"
- @click="showToast"
+ @click="showToast(clipboardText)"
>
{{ __('Generate link to chart') }}
</gl-dropdown-item>
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index 13aba3d9f44..2836fe4fc26 100644
--- a/app/assets/javascripts/monitoring/constants.js
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -3,6 +3,11 @@ import { __ } from '~/locale';
export const sidebarAnimationDuration = 300; // milliseconds.
export const chartHeight = 300;
+/**
+ * Valid strings for this regex are
+ * 2019-10-01 and 2019-10-01 01:02:03
+ */
+export const dateTimePickerRegex = /^(\d{4})-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])(?: (0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]))?$/;
export const graphTypes = {
deploymentData: 'scatter',
@@ -28,6 +33,11 @@ export const timeWindows = {
export const dateFormats = {
timeOfDay: 'h:MM TT',
default: 'dd mmm yyyy, h:MMTT',
+ dateTimePicker: {
+ format: 'yyyy-mm-dd hh:mm:ss',
+ ISODate: "yyyy-mm-dd'T'HH:MM:ss'Z'",
+ stringDate: 'yyyy-mm-dd HH:MM:ss',
+ },
};
export const secondsIn = {
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js
index 51cef20455c..6aa1fb5e9c6 100644
--- a/app/assets/javascripts/monitoring/monitoring_bundle.js
+++ b/app/assets/javascripts/monitoring/monitoring_bundle.js
@@ -14,7 +14,6 @@ export default (props = {}) => {
if (gon.features) {
store.dispatch('monitoringDashboard/setFeatureFlags', {
prometheusEndpointEnabled: gon.features.environmentMetricsUsePrometheusEndpoint,
- multipleDashboardsEnabled: gon.features.environmentMetricsShowMultipleDashboards,
additionalPanelTypesEnabled: gon.features.environmentMetricsAdditionalPanelTypes,
});
}
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 0cbad179f17..2cf34ddb45b 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -37,10 +37,9 @@ export const setEndpoints = ({ commit }, endpoints) => {
export const setFeatureFlags = (
{ commit },
- { prometheusEndpointEnabled, multipleDashboardsEnabled, additionalPanelTypesEnabled },
+ { prometheusEndpointEnabled, additionalPanelTypesEnabled },
) => {
commit(types.SET_DASHBOARD_ENABLED, prometheusEndpointEnabled);
- commit(types.SET_MULTIPLE_DASHBOARDS_ENABLED, multipleDashboardsEnabled);
commit(types.SET_ADDITIONAL_PANEL_TYPES_ENABLED, additionalPanelTypesEnabled);
};
@@ -51,13 +50,8 @@ export const setShowErrorBanner = ({ commit }, enabled) => {
export const requestMetricsDashboard = ({ commit }) => {
commit(types.REQUEST_METRICS_DATA);
};
-export const receiveMetricsDashboardSuccess = (
- { state, commit, dispatch },
- { response, params },
-) => {
- if (state.multipleDashboardsEnabled) {
- commit(types.SET_ALL_DASHBOARDS, response.all_dashboards);
- }
+export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response, params }) => {
+ commit(types.SET_ALL_DASHBOARDS, response.all_dashboards);
commit(types.RECEIVE_METRICS_DATA_SUCCESS, response.dashboard.panel_groups);
dispatch('fetchPrometheusMetrics', params);
};
diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js
index 4b1aadbcf05..9c546427c6e 100644
--- a/app/assets/javascripts/monitoring/stores/mutation_types.js
+++ b/app/assets/javascripts/monitoring/stores/mutation_types.js
@@ -10,7 +10,6 @@ export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAIL
export const SET_QUERY_RESULT = 'SET_QUERY_RESULT';
export const SET_TIME_WINDOW = 'SET_TIME_WINDOW';
export const SET_DASHBOARD_ENABLED = 'SET_DASHBOARD_ENABLED';
-export const SET_MULTIPLE_DASHBOARDS_ENABLED = 'SET_MULTIPLE_DASHBOARDS_ENABLED';
export const SET_ADDITIONAL_PANEL_TYPES_ENABLED = 'SET_ADDITIONAL_PANEL_TYPES_ENABLED';
export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS';
export const SET_ENDPOINTS = 'SET_ENDPOINTS';
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
index b19520d6638..320b33d3d69 100644
--- a/app/assets/javascripts/monitoring/stores/mutations.js
+++ b/app/assets/javascripts/monitoring/stores/mutations.js
@@ -1,6 +1,8 @@
import Vue from 'vue';
import * as types from './mutation_types';
-import { normalizeMetrics, sortMetrics, normalizeQueryResult } from './utils';
+import { normalizeMetrics, sortMetrics, normalizeMetric, normalizeQueryResult } from './utils';
+
+const normalizePanel = panel => panel.metrics.map(normalizeMetric);
export default {
[types.REQUEST_METRICS_DATA](state) {
@@ -9,13 +11,19 @@ export default {
},
[types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) {
state.groups = groupData.map(group => {
- let { metrics } = group;
+ let { metrics = [], panels = [] } = group;
+
+ // each panel has metric information that needs to be normalized
+ panels = panels.map(panel => ({
+ ...panel,
+ metrics: normalizePanel(panel),
+ }));
// for backwards compatibility, and to limit Vue template changes:
// for each group alias panels to metrics
// for each panel alias metrics to queries
if (state.useDashboardEndpoint) {
- metrics = group.panels.map(panel => ({
+ metrics = panels.map(panel => ({
...panel,
queries: panel.metrics,
}));
@@ -23,6 +31,7 @@ export default {
return {
...group,
+ panels,
metrics: normalizeMetrics(sortMetrics(metrics)),
};
});
@@ -80,9 +89,6 @@ export default {
[types.SET_DASHBOARD_ENABLED](state, enabled) {
state.useDashboardEndpoint = enabled;
},
- [types.SET_MULTIPLE_DASHBOARDS_ENABLED](state, enabled) {
- state.multipleDashboardsEnabled = enabled;
- },
[types.SET_GETTING_STARTED_EMPTY_STATE](state) {
state.emptyState = 'gettingStarted';
},
diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js
index 440bdc951e0..e894e988f6a 100644
--- a/app/assets/javascripts/monitoring/stores/state.js
+++ b/app/assets/javascripts/monitoring/stores/state.js
@@ -8,7 +8,6 @@ export default () => ({
deploymentsEndpoint: null,
dashboardEndpoint: invalidUrl,
useDashboardEndpoint: false,
- multipleDashboardsEnabled: false,
additionalPanelTypesEnabled: false,
emptyState: 'gettingStarted',
showEmptyState: true,
diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js
index 938ee2f0a9a..a19829f0c65 100644
--- a/app/assets/javascripts/monitoring/stores/utils.js
+++ b/app/assets/javascripts/monitoring/stores/utils.js
@@ -63,6 +63,25 @@ export function groupQueriesByChartInfo(metrics) {
return Object.values(metricsByChart);
}
+export const uniqMetricsId = metric => `${metric.metric_id}_${metric.id}`;
+
+/**
+ * Not to confuse with normalizeMetrics (plural)
+ * 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.
+ * This is hopefully a temporary solution until BE processes metrics before passing to fE
+ * @param {Object} metric - metric
+ * @returns {Object} - normalized metric with a uniqueID
+ */
+export const normalizeMetric = (metric = {}) =>
+ _.omit(
+ {
+ ...metric,
+ metric_id: uniqMetricsId(metric),
+ },
+ 'id',
+ );
+
export const sortMetrics = metrics =>
_.chain(metrics)
.sortBy('title')
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index 46b01f753f8..4c72f5226b7 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -1,4 +1,7 @@
-import { secondsIn, timeWindowsKeyNames } from './constants';
+import dateformat from 'dateformat';
+import { secondsIn, dateTimePickerRegex, dateFormats } from './constants';
+
+const secondsToMilliseconds = seconds => seconds * 1000;
export const getTimeDiff = timeWindow => {
const end = Math.floor(Date.now() / 1000); // convert milliseconds to seconds
@@ -6,18 +9,60 @@ export const getTimeDiff = timeWindow => {
const start = end - difference;
return {
- start: new Date(start * 1000).toISOString(),
- end: new Date(end * 1000).toISOString(),
+ start: new Date(secondsToMilliseconds(start)).toISOString(),
+ end: new Date(secondsToMilliseconds(end)).toISOString(),
};
};
export const getTimeWindow = ({ start, end }) =>
Object.entries(secondsIn).reduce((acc, [timeRange, value]) => {
- if (end - start === value) {
+ if (new Date(end) - new Date(start) === secondsToMilliseconds(value)) {
return timeRange;
}
return acc;
- }, timeWindowsKeyNames.eightHours);
+ }, null);
+
+export const isDateTimePickerInputValid = val => dateTimePickerRegex.test(val);
+
+export const truncateZerosInDateTime = datetime => datetime.replace(' 00:00:00', '');
+
+/**
+ * The URL params start and end need to be validated
+ * before passing them down to other components.
+ *
+ * @param {string} dateString
+ */
+export const isValidDate = dateString => {
+ try {
+ // dateformat throws error that can be caught.
+ // This is better than using `new Date()`
+ if (dateString && dateString.trim()) {
+ dateformat(dateString, 'isoDateTime');
+ return true;
+ }
+ return false;
+ } catch {
+ return false;
+ }
+};
+
+/**
+ * Convert the input in Time picker component to ISO date.
+ *
+ * @param {string} val
+ * @returns {string}
+ */
+export const stringToISODate = val =>
+ dateformat(new Date(val.replace(/-/g, '/')), dateFormats.dateTimePicker.ISODate, true);
+
+/**
+ * Convert the ISO date received from the URL to string
+ * for the Time picker component.
+ *
+ * @param {Date} date
+ * @returns {string}
+ */
+export const ISODateToString = date => dateformat(date, dateFormats.dateTimePicker.stringDate);
/**
* This method is used to validate if the graph data format for a chart component
@@ -43,4 +88,47 @@ export const graphDataValidatorForValues = (isValues, graphData) => {
);
};
+/* eslint-disable @gitlab/i18n/no-non-i18n-strings */
+/**
+ * Checks that element that triggered event is located on cluster health check dashboard
+ * @param {HTMLElement} element to check against
+ * @returns {boolean}
+ */
+const isClusterHealthBoard = () => (document.body.dataset.page || '').includes(':clusters:show');
+
+/**
+ * Tracks snowplow event when user generates link to metric chart
+ * @param {String} chart link that will be sent as a property for the event
+ * @return {Object} config object for event tracking
+ */
+export const generateLinkToChartOptions = chartLink => {
+ const isCLusterHealthBoard = isClusterHealthBoard();
+
+ const category = isCLusterHealthBoard
+ ? 'Cluster Monitoring'
+ : 'Incident Management::Embedded metrics';
+ const action = isCLusterHealthBoard
+ ? 'generate_link_to_cluster_metric_chart'
+ : 'generate_link_to_metrics_chart';
+
+ return { category, action, label: 'Chart link', property: chartLink };
+};
+
+/**
+ * Tracks snowplow event when user downloads CSV of cluster metric
+ * @param {String} chart title that will be sent as a property for the event
+ */
+export const downloadCSVOptions = title => {
+ const isCLusterHealthBoard = isClusterHealthBoard();
+
+ const category = isCLusterHealthBoard
+ ? 'Cluster Monitoring'
+ : 'Incident Management::Embedded metrics';
+ const action = isCLusterHealthBoard
+ ? 'download_csv_of_cluster_metric_chart'
+ : 'download_csv_of_metrics_dashboard_chart';
+
+ return { category, action, label: 'Chart title', property: title };
+};
+
export default {};
diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js
index 4ddbec71ba6..8671f0fd783 100644
--- a/app/assets/javascripts/namespace_select.js
+++ b/app/assets/javascripts/namespace_select.js
@@ -1,6 +1,7 @@
-/* eslint-disable func-names, no-else-return, prefer-template, prefer-arrow-callback */
+/* eslint-disable no-else-return */
import $ from 'jquery';
+import '~/gl_dropdown';
import Api from './api';
import { mergeUrlParams } from './lib/utils/url_utility';
import { parseBoolean } from '~/lib/utils/common_utils';
@@ -23,11 +24,11 @@ export default class NamespaceSelect {
if (selected.id == null) {
return selected.text;
} else {
- return selected.kind + ': ' + selected.full_path;
+ return `${selected.kind}: ${selected.full_path}`;
}
},
data(term, dataCallback) {
- return Api.namespaces(term, function(namespaces) {
+ return Api.namespaces(term, namespaces => {
if (isFilter) {
const anyNamespace = {
text: __('Any namespace'),
@@ -43,7 +44,7 @@ export default class NamespaceSelect {
if (namespace.id == null) {
return namespace.text;
} else {
- return namespace.kind + ': ' + namespace.full_path;
+ return `${namespace.kind}: ${namespace.full_path}`;
}
},
renderRow: this.renderRow,
diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js
index fcfc2570b3d..a0ba2193d90 100644
--- a/app/assets/javascripts/network/branch_graph.js
+++ b/app/assets/javascripts/network/branch_graph.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, one-var, no-loop-func, consistent-return, prefer-template, prefer-arrow-callback, camelcase */
+/* eslint-disable func-names, no-var, one-var, no-loop-func, consistent-return, camelcase */
import $ from 'jquery';
import { __ } from '../locale';
@@ -223,7 +223,7 @@ export default (function() {
shortrefs = commit.refs;
// Truncate if longer than 15 chars
if (shortrefs.length > 17) {
- shortrefs = shortrefs.substr(0, 15) + '…';
+ shortrefs = `${shortrefs.substr(0, 15)}…`;
}
text = r.text(x + 4, y, shortrefs).attr({
'text-anchor': 'start',
@@ -259,9 +259,7 @@ export default (function() {
opacity: 0,
cursor: 'pointer',
})
- .click(function() {
- return window.open(options.commit_url.replace('%s', commit.id), '_blank');
- })
+ .click(() => window.open(options.commit_url.replace('%s', commit.id), '_blank'))
.hover(
function() {
this.tooltip = r.commitTooltip(x + 5, y, commit);
diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js
index 98522c67696..9f9db21d65b 100644
--- a/app/assets/javascripts/new_branch_form.js
+++ b/app/assets/javascripts/new_branch_form.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, one-var, consistent-return, no-return-assign, prefer-arrow-callback, prefer-template, no-shadow, no-else-return, @gitlab/i18n/no-non-i18n-strings */
+/* eslint-disable func-names, no-var, one-var, consistent-return, no-return-assign, no-shadow, no-else-return, @gitlab/i18n/no-non-i18n-strings */
import $ from 'jquery';
import RefSelectDropdown from './ref_select_dropdown';
@@ -63,17 +63,17 @@ export default class NewBranchForm {
};
formatter = function(values, restriction) {
var formatted;
- formatted = values.map(function(value) {
+ formatted = values.map(value => {
switch (false) {
case !/\s/.test(value):
return 'spaces';
case !/\/{2,}/g.test(value):
return 'consecutive slashes';
default:
- return "'" + value + "'";
+ return `'${value}'`;
}
});
- return restriction.prefix + ' ' + formatted.join(restriction.conjunction);
+ return `${restriction.prefix} ${formatted.join(restriction.conjunction)}`;
};
validator = (function(_this) {
return function(errors, restriction) {
diff --git a/app/assets/javascripts/notebook/cells/code.vue b/app/assets/javascripts/notebook/cells/code.vue
index eefc801ed7a..1782e5bfe5a 100644
--- a/app/assets/javascripts/notebook/cells/code.vue
+++ b/app/assets/javascripts/notebook/cells/code.vue
@@ -49,6 +49,7 @@ export default {
v-if="hasOutput"
:count="cell.execution_count"
:outputs="outputs"
+ :metadata="cell.metadata"
:code-css-class="codeCssClass"
/>
</div>
diff --git a/app/assets/javascripts/notebook/cells/code/index.vue b/app/assets/javascripts/notebook/cells/code/index.vue
index 98b6cdd0944..470d8c87d59 100644
--- a/app/assets/javascripts/notebook/cells/code/index.vue
+++ b/app/assets/javascripts/notebook/cells/code/index.vue
@@ -26,6 +26,10 @@ export default {
type: String,
required: true,
},
+ metadata: {
+ type: Object,
+ default: () => ({}),
+ },
},
computed: {
code() {
@@ -36,6 +40,12 @@ export default {
return type.charAt(0).toUpperCase() + type.slice(1);
},
+ cellCssClass() {
+ return {
+ [this.codeCssClass]: true,
+ 'jupyter-notebook-scrolled': this.metadata.scrolled,
+ };
+ },
},
mounted() {
Prism.highlightElement(this.$refs.code);
@@ -46,6 +56,6 @@ export default {
<template>
<div :class="type">
<prompt :type="promptType" :count="count" />
- <pre ref="code" :class="codeCssClass" class="language-python" v-text="code"></pre>
+ <pre ref="code" :class="cellCssClass" class="language-python" v-text="code"></pre>
</div>
</template>
diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue
index b59ddd0d57a..d8b0e099bc4 100644
--- a/app/assets/javascripts/notebook/cells/output/index.vue
+++ b/app/assets/javascripts/notebook/cells/output/index.vue
@@ -19,6 +19,10 @@ export default {
type: Array,
required: true,
},
+ metadata: {
+ type: Object,
+ default: () => ({}),
+ },
},
methods: {
outputType(output) {
@@ -78,6 +82,7 @@ export default {
:count="count"
:index="index"
:raw-code="rawCode(output)"
+ :metadata="metadata"
:code-css-class="codeCssClass"
/>
</div>
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 9cc56b34c75..3715a91d599 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -1,7 +1,7 @@
/* eslint-disable no-restricted-properties, func-names, no-var, camelcase,
no-unused-expressions, one-var, default-case,
-prefer-template, consistent-return, no-alert, no-return-assign,
-no-param-reassign, prefer-arrow-callback, no-else-return, vars-on-top,
+consistent-return, no-alert, no-return-assign,
+no-param-reassign, no-else-return, vars-on-top,
no-shadow, no-useless-escape, class-methods-use-this */
/* global ResolveService */
@@ -490,7 +490,7 @@ export default class Notes {
diffAvatarContainer = row
.prevAll('.line_holder')
.first()
- .find('.js-avatar-container.' + lineType + '_line');
+ .find(`.js-avatar-container.${lineType}_line`);
// is this the first note of discussion?
discussionContainer = $(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`);
if (!discussionContainer.length) {
@@ -506,16 +506,14 @@ export default class Notes {
} else {
// Merge new discussion HTML in
var $notes = $discussion.find(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`);
- var contentContainerClass =
- '.' +
- $notes
- .closest('.notes-content')
- .attr('class')
- .split(' ')
- .join('.');
+ var contentContainerClass = $notes
+ .closest('.notes-content')
+ .attr('class')
+ .split(' ')
+ .join('.');
row
- .find(contentContainerClass + ' .content')
+ .find(`.${contentContainerClass} .content`)
.append($notes.closest('.content').children());
}
} else {
@@ -722,7 +720,7 @@ export default class Notes {
this.revertNoteEditForm($targetNote);
$noteEntityEl.renderGFM();
// Find the note's `li` element by ID and replace it with the updated HTML
- $note_li = $('.note-row-' + noteEntity.id);
+ $note_li = $(`.note-row-${noteEntity.id}`);
$note_li.replaceWith($noteEntityEl);
this.setupNewNote($noteEntityEl);
@@ -1370,7 +1368,7 @@ export default class Notes {
.find('li.system-note')
.has('ul');
- $.each(systemNotes, function(index, systemNote) {
+ $.each(systemNotes, (index, systemNote) => {
const $systemNote = $(systemNote);
const headerMessage = $systemNote
.find('.note-text')
@@ -1461,6 +1459,7 @@ export default class Notes {
getFormData($form) {
const content = $form.find('.js-note-text').val();
return {
+ // eslint-disable-next-line no-jquery/no-serialize
formData: $form.serialize(),
formContent: _.escape(content),
formAction: $form.attr('action'),
diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue
index 6bbf2fa6ee4..fad1bc67be7 100644
--- a/app/assets/javascripts/notes/components/discussion_actions.vue
+++ b/app/assets/javascripts/notes/components/discussion_actions.vue
@@ -58,6 +58,7 @@ export default {
<div class="btn-group">
<resolve-discussion-button
v-if="discussion.resolvable"
+ data-qa-selector="resolve_discussion_button"
:is-resolving="isResolving"
:button-title="resolveButtonTitle"
@onClick="$emit('resolve')"
diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue
index 743684e7046..6b1e3298f9a 100644
--- a/app/assets/javascripts/notes/components/discussion_filter.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter.vue
@@ -1,13 +1,14 @@
<script>
import $ from 'jquery';
import { mapGetters, mapActions } from 'vuex';
-import { getLocationHash } from '../../lib/utils/url_utility';
+import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility';
import Icon from '~/vue_shared/components/icon.vue';
import {
DISCUSSION_FILTERS_DEFAULT_VALUE,
HISTORY_ONLY_FILTER_VALUE,
DISCUSSION_TAB_LABEL,
DISCUSSION_FILTER_TYPES,
+ NOTE_UNDERSCORE,
} from '../constants';
import notesEventHub from '../event_hub';
@@ -28,7 +29,9 @@ export default {
},
data() {
return {
- currentValue: this.selectedValue,
+ currentValue: doesHashExistInUrl(NOTE_UNDERSCORE)
+ ? DISCUSSION_FILTERS_DEFAULT_VALUE
+ : this.selectedValue,
defaultValue: DISCUSSION_FILTERS_DEFAULT_VALUE,
displayFilters: true,
};
@@ -50,7 +53,6 @@ export default {
notesEventHub.$on('dropdownSelect', this.selectFilter);
window.addEventListener('hashchange', this.handleLocationHash);
- this.handleLocationHash();
},
mounted() {
this.toggleCommentsForm();
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index 6cc873359da..89d434a60ba 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -149,9 +149,9 @@ export default {
title="Add reaction"
data-position="right"
>
- <icon css-classes="link-highlight award-control-icon-neutral" name="slight-smile" />
- <icon css-classes="link-highlight award-control-icon-positive" name="smiley" />
- <icon css-classes="link-highlight award-control-icon-super-positive" name="smiley" />
+ <icon class="link-highlight award-control-icon-neutral" name="slight-smile" />
+ <icon class="link-highlight award-control-icon-positive" name="smiley" />
+ <icon class="link-highlight award-control-icon-super-positive" name="smiley" />
</a>
</div>
<reply-button
@@ -168,7 +168,7 @@ export default {
class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button"
@click="onEdit"
>
- <icon name="pencil" css-classes="link-highlight" />
+ <icon name="pencil" class="link-highlight" />
</button>
</div>
<div v-if="showDeleteAction" class="note-actions-item">
@@ -191,7 +191,7 @@ export default {
data-toggle="dropdown"
@click="closeTooltip"
>
- <icon css-classes="icon" name="ellipsis_v" />
+ <icon class="icon" name="ellipsis_v" />
</button>
<ul class="dropdown-menu more-actions-dropdown dropdown-open-left">
<li v-if="canReportAsAbuse">
diff --git a/app/assets/javascripts/notes/components/note_actions/reply_button.vue b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
index 1aeb07d6608..20551279aec 100644
--- a/app/assets/javascripts/notes/components/note_actions/reply_button.vue
+++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
@@ -19,12 +19,14 @@ export default {
<gl-button
ref="button"
v-gl-tooltip
- class="note-action-button js-note-action-reply"
+ class="note-action-button"
+ data-track-event="click_button"
+ data-track-label="reply_comment_button"
variant="transparent"
:title="__('Reply to comment')"
@click="$emit('startReplying')"
>
- <icon name="comment" css-classes="link-highlight" />
+ <icon name="comment" class="link-highlight" />
</gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index ac743d9f4b8..cb1975a8962 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -306,7 +306,11 @@ export default {
<template>
<timeline-entry-item class="note note-discussion">
<div class="timeline-content">
- <div :data-discussion-id="discussion.id" class="discussion js-discussion-container">
+ <div
+ :data-discussion-id="discussion.id"
+ class="discussion js-discussion-container"
+ data-qa-selector="discussion_content"
+ >
<div v-if="shouldRenderDiffs" class="discussion-header note-wrapper">
<div v-once class="timeline-icon align-self-start flex-shrink-0">
<user-avatar-link
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 16a0fb3f33a..c6c97489e5e 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -1,7 +1,7 @@
<script>
import { __ } from '~/locale';
import { mapGetters, mapActions } from 'vuex';
-import { getLocationHash } from '../../lib/utils/url_utility';
+import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility';
import Flash from '../../flash';
import * as constants from '../constants';
import eventHub from '../event_hub';
@@ -69,6 +69,7 @@ export default {
'commentsDisabled',
'getNoteableData',
'userCanReply',
+ 'discussionTabCounter',
]),
noteableType() {
return this.noteableData.noteableType;
@@ -95,13 +96,13 @@ export default {
}
},
allDiscussions() {
- if (this.discussonsCount) {
- this.discussonsCount.textContent = this.allDiscussions.length;
+ if (this.discussionsCount && !this.isLoading) {
+ this.discussionsCount.textContent = this.discussionTabCounter;
}
},
},
created() {
- this.discussonsCount = document.querySelector('.js-discussions-count');
+ this.discussionsCount = document.querySelector('.js-discussions-count');
this.setNotesData(this.notesData);
this.setNoteableData(this.noteableData);
@@ -155,19 +156,17 @@ export default {
this.isFetching = true;
- return this.fetchDiscussions({ path: this.getNotesDataByProp('discussionsPath') })
- .then(() => {
- this.initPolling();
- })
+ return this.fetchDiscussions(this.getFetchDiscussionsConfig())
+ .then(this.initPolling)
.then(() => {
this.setLoadingState(false);
this.setNotesFetchedState(true);
eventHub.$emit('fetchedNotesData');
this.isFetching = false;
})
- .then(() => this.$nextTick())
- .then(() => this.startTaskList())
- .then(() => this.checkLocationHash())
+ .then(this.$nextTick)
+ .then(this.startTaskList)
+ .then(this.checkLocationHash)
.catch(() => {
this.setLoadingState(false);
this.setNotesFetchedState(true);
@@ -198,9 +197,20 @@ export default {
},
startReplying(discussionId) {
return this.convertToDiscussion(discussionId)
- .then(() => this.$nextTick())
+ .then(this.$nextTick)
.then(() => eventHub.$emit('startReplying', discussionId));
},
+ getFetchDiscussionsConfig() {
+ const defaultConfig = { path: this.getNotesDataByProp('discussionsPath') };
+
+ if (doesHashExistInUrl(constants.NOTE_UNDERSCORE)) {
+ return Object.assign({}, defaultConfig, {
+ filter: constants.DISCUSSION_FILTERS_DEFAULT_VALUE,
+ persistFilter: false,
+ });
+ }
+ return defaultConfig;
+ },
},
systemNote: constants.SYSTEM_NOTE,
};
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
index bdfb6b8f105..68c117183a1 100644
--- a/app/assets/javascripts/notes/constants.js
+++ b/app/assets/javascripts/notes/constants.js
@@ -8,8 +8,6 @@ export const OPENED = 'opened';
export const REOPENED = 'reopened';
export const CLOSED = 'closed';
export const MERGED = 'merged';
-export const EMOJI_THUMBSUP = 'thumbsup';
-export const EMOJI_THUMBSDOWN = 'thumbsdown';
export const ISSUE_NOTEABLE_TYPE = 'issue';
export const EPIC_NOTEABLE_TYPE = 'epic';
export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest';
@@ -19,6 +17,7 @@ export const DESCRIPTION_TYPE = 'changed the description';
export const HISTORY_ONLY_FILTER_VALUE = 2;
export const DISCUSSION_FILTERS_DEFAULT_VALUE = 0;
export const DISCUSSION_TAB_LABEL = 'show';
+export const NOTE_UNDERSCORE = 'note_';
export const NOTEABLE_TYPE_MAPPING = {
Issue: ISSUE_NOTEABLE_TYPE,
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index c70c0e4095c..30372103590 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -1,5 +1,4 @@
import Vue from 'vue';
-import initNoteStats from 'ee_else_ce/event_tracking/notes';
import notesApp from './components/notes_app.vue';
import initDiscussionFilters from './discussion_filters';
import createStore from './stores';
@@ -39,9 +38,6 @@ document.addEventListener('DOMContentLoaded', () => {
notesData: JSON.parse(notesDataset.notesData),
};
},
- mounted() {
- initNoteStats();
- },
render(createElement) {
return createElement('notes-app', {
props: {
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 6c236981a24..004035ea1d4 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -251,58 +251,80 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
}
}
- return dispatch(methodToDispatch, postData, { root: true }).then(res => {
+ const processErrors = res => {
const { errors } = res;
- const commandsChanges = res.commands_changes;
+ if (!errors || !Object.keys(errors).length) {
+ return res;
+ }
- if (errors && Object.keys(errors).length) {
- /*
- The following reply means that quick actions have been successfully applied:
+ /*
+ The following reply means that quick actions have been successfully applied:
- {"commands_changes":{},"valid":false,"errors":{"commands_only":["Commands applied"]}}
- */
- if (hasQuickActions) {
- eTagPoll.makeRequest();
+ {"commands_changes":{},"valid":false,"errors":{"commands_only":["Commands applied"]}}
+ */
+ if (hasQuickActions) {
+ eTagPoll.makeRequest();
- $('.js-gfm-input').trigger('clear-commands-cache.atwho');
- Flash(__('Commands applied'), 'notice', noteData.flashContainer);
- } else {
- throw new Error(__('Failed to save comment!'));
- }
+ $('.js-gfm-input').trigger('clear-commands-cache.atwho');
+
+ const { commands_only: message } = errors;
+ Flash(message || __('Commands applied'), 'notice', noteData.flashContainer);
+
+ return res;
}
- if (commandsChanges) {
- if (commandsChanges.emoji_award) {
- const votesBlock = $('.js-awards-block').eq(0);
-
- loadAwardsHandler()
- .then(awardsHandler => {
- awardsHandler.addAwardToEmojiBar(votesBlock, commandsChanges.emoji_award);
- awardsHandler.scrollToAwards();
- })
- .catch(() => {
- Flash(
- __('Something went wrong while adding your award. Please try again.'),
- 'alert',
- noteData.flashContainer,
- );
- });
- }
+ throw new Error(__('Failed to save comment!'));
+ };
- if (commandsChanges.spend_time != null || commandsChanges.time_estimate != null) {
- sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', res);
- }
+ const processEmojiAward = res => {
+ const { commands_changes: commandsChanges } = res;
+ const { emoji_award: emojiAward } = commandsChanges || {};
+ if (!emojiAward) {
+ return res;
}
- if (errors && errors.commands_only) {
- Flash(errors.commands_only, 'notice', noteData.flashContainer);
+ const votesBlock = $('.js-awards-block').eq(0);
+
+ return loadAwardsHandler()
+ .then(awardsHandler => {
+ awardsHandler.addAwardToEmojiBar(votesBlock, emojiAward);
+ awardsHandler.scrollToAwards();
+ })
+ .catch(() => {
+ Flash(
+ __('Something went wrong while adding your award. Please try again.'),
+ 'alert',
+ noteData.flashContainer,
+ );
+ })
+ .then(() => res);
+ };
+
+ const processTimeTracking = res => {
+ const { commands_changes: commandsChanges } = res;
+ const { spend_time: spendTime, time_estimate: timeEstimate } = commandsChanges || {};
+ if (spendTime != null || timeEstimate != null) {
+ sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', {
+ commands_changes: commandsChanges,
+ });
}
+
+ return res;
+ };
+
+ const removePlaceholder = res => {
if (replyId) {
commit(types.REMOVE_PLACEHOLDER_NOTES);
}
return res;
- });
+ };
+
+ return dispatch(methodToDispatch, postData, { root: true })
+ .then(processErrors)
+ .then(processEmojiAward)
+ .then(processTimeTracking)
+ .then(removePlaceholder);
};
const pollSuccessCallBack = (resp, commit, state, getters, dispatch) => {
@@ -430,10 +452,13 @@ export const updateResolvableDiscussionsCounts = ({ commit }) =>
export const submitSuggestion = (
{ commit, dispatch },
{ discussionId, noteId, suggestionId, flashContainer },
-) =>
- Api.applySuggestion(suggestionId)
+) => {
+ const dispatchResolveDiscussion = () =>
+ dispatch('resolveDiscussion', { discussionId }).catch(() => {});
+
+ return Api.applySuggestion(suggestionId)
.then(() => commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId }))
- .then(() => dispatch('resolveDiscussion', { discussionId }).catch(() => {}))
+ .then(dispatchResolveDiscussion)
.catch(err => {
const defaultMessage = __(
'Something went wrong while applying the suggestion. Please try again.',
@@ -442,6 +467,7 @@ export const submitSuggestion = (
Flash(__(flashMessage), 'alert', flashContainer);
});
+};
export const convertToDiscussion = ({ commit }, noteId) =>
commit(types.CONVERT_TO_DISCUSSION, noteId);
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index fa44ef2d057..e70f0238316 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -33,10 +33,11 @@ export default {
},
[types.ADD_NEW_REPLY_TO_DISCUSSION](state, note) {
- const noteObj = utils.findNoteObjectById(state.discussions, note.discussion_id);
+ const discussion = utils.findNoteObjectById(state.discussions, note.discussion_id);
+ const existingNote = discussion && utils.findNoteObjectById(discussion.notes, note.id);
- if (noteObj) {
- noteObj.notes.push(note);
+ if (discussion && !existingNote) {
+ discussion.notes.push(note);
}
},
diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js
index 386a9b2c740..46e80ba72e3 100644
--- a/app/assets/javascripts/pager.js
+++ b/app/assets/javascripts/pager.js
@@ -56,6 +56,10 @@ export default {
$('.content_list').append(html);
if (count > 0) {
this.offset += count;
+
+ if (count < this.limit) {
+ this.disable = true;
+ }
} else {
this.disable = true;
}
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue
index e2fec3c7172..eb03baf4894 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue
+++ b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue
@@ -1,13 +1,13 @@
<script>
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { redirectTo } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
export default {
components: {
- GlModal,
+ GlModal: DeprecatedModal2,
},
props: {
url: {
diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
index e8905b479ee..78aaa9df0ec 100644
--- a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
+++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
@@ -1,37 +1,46 @@
<script>
import _ from 'underscore';
-import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+import { GlModal, GlButton, GlFormInput } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
export default {
components: {
- DeprecatedModal,
+ GlModal,
+ GlButton,
+ GlFormInput,
},
props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ content: {
+ type: String,
+ required: true,
+ },
+ action: {
+ type: String,
+ required: true,
+ },
+ secondaryAction: {
+ type: String,
+ required: true,
+ },
deleteUserUrl: {
type: String,
- required: false,
- default: '',
+ required: true,
},
blockUserUrl: {
type: String,
- required: false,
- default: '',
- },
- deleteContributions: {
- type: Boolean,
- required: false,
- default: false,
+ required: true,
},
username: {
type: String,
- required: false,
- default: '',
+ required: true,
},
csrfToken: {
type: String,
- required: false,
- default: '',
+ required: true,
},
},
data() {
@@ -40,32 +49,12 @@ export default {
};
},
computed: {
- title() {
- const keepContributionsTitle = s__('AdminUsers|Delete User %{username}?');
- const deleteContributionsTitle = s__('AdminUsers|Delete User %{username} and contributions?');
-
- return sprintf(
- this.deleteContributions ? deleteContributionsTitle : keepContributionsTitle,
- {
- username: `'${_.escape(this.username)}'`,
- },
- false,
- );
+ modalTitle() {
+ return sprintf(this.title, { username: this.username });
},
text() {
- const keepContributionsText = s__(`AdminArea|
- You are about to permanently delete the user %{username}.
- Issues, merge requests, and groups linked to them will be transferred to a system-wide "Ghost-user".
- To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead.
- Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered.`);
-
- const deleteContributionsText = s__(`AdminArea|
- You are about to permanently delete the user %{username}.
- This will delete all of the issues, merge requests, and groups linked to them.
- To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead.
- Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered.`);
return sprintf(
- this.deleteContributions ? deleteContributionsText : keepContributionsText,
+ this.content,
{
username: `<strong>${_.escape(this.username)}</strong>`,
strong_start: '<strong>',
@@ -83,12 +72,7 @@ export default {
false,
);
},
- primaryButtonLabel() {
- const keepContributionsLabel = s__('AdminUsers|Delete user');
- const deleteContributionsLabel = s__('AdminUsers|Delete user and contributions');
- return this.deleteContributions ? deleteContributionsLabel : keepContributionsLabel;
- },
secondaryButtonLabel() {
return s__('AdminUsers|Block user');
},
@@ -97,8 +81,12 @@ export default {
},
},
methods: {
+ show() {
+ this.$refs.modal.show();
+ },
onCancel() {
this.enteredUsername = '';
+ this.$refs.modal.hide();
},
onSecondaryAction() {
const { form } = this.$refs;
@@ -117,43 +105,28 @@ export default {
</script>
<template>
- <deprecated-modal
- id="delete-user-modal"
- :title="title"
- :text="text"
- :primary-button-label="primaryButtonLabel"
- :secondary-button-label="secondaryButtonLabel"
- :submit-disabled="!canSubmit"
- kind="danger"
- @submit="onSubmit"
- @cancel="onCancel"
- >
- <template slot="body" slot-scope="props">
- <p v-html="props.text"></p>
+ <gl-modal ref="modal" modal-id="delete-user-modal" :title="modalTitle" kind="danger">
+ <template>
+ <p v-html="text"></p>
<p v-html="confirmationTextLabel"></p>
<form ref="form" :action="deleteUserUrl" method="post">
<input ref="method" type="hidden" name="_method" value="delete" />
<input :value="csrfToken" type="hidden" name="authenticity_token" />
- <input
+ <gl-form-input
v-model="enteredUsername"
+ autofocus
type="text"
name="username"
- class="form-control"
- aria-labelledby="input-label"
autocomplete="off"
/>
</form>
</template>
- <template slot="secondary-button">
- <button
- :disabled="!canSubmit"
- type="button"
- class="btn js-secondary-button btn-warning"
- data-dismiss="modal"
- @click="onSecondaryAction"
- >
- {{ secondaryButtonLabel }}
- </button>
+ <template slot="modal-footer">
+ <gl-button variant="secondary" @click="onCancel">{{ s__('Cancel') }}</gl-button>
+ <gl-button :disabled="!canSubmit" variant="warning" @click="onSecondaryAction">
+ {{ secondaryAction }}
+ </gl-button>
+ <gl-button :disabled="!canSubmit" variant="danger" @click="onSubmit">{{ action }}</gl-button>
</template>
- </deprecated-modal>
+ </gl-modal>
</template>
diff --git a/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue b/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue
new file mode 100644
index 00000000000..a08d32028c3
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue
@@ -0,0 +1,77 @@
+<script>
+export default {
+ props: {
+ modalConfiguration: {
+ required: true,
+ type: Object,
+ },
+ actionModals: {
+ required: true,
+ type: Object,
+ },
+ csrfToken: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ currentModalData: null,
+ };
+ },
+ computed: {
+ activeModal() {
+ if (!this.currentModalData) return null;
+ const { glModalAction: action } = this.currentModalData;
+
+ return this.actionModals[action];
+ },
+
+ modalProps() {
+ const { glModalAction: requestedAction } = this.currentModalData;
+ return {
+ ...this.modalConfiguration[requestedAction],
+ ...this.currentModalData,
+ csrfToken: this.csrfToken,
+ };
+ },
+ },
+
+ mounted() {
+ document.addEventListener('click', this.handleClick);
+ },
+
+ beforeDestroy() {
+ document.removeEventListener('click', this.handleClick);
+ },
+
+ methods: {
+ handleClick(e) {
+ const { glModalAction: action } = e.target.dataset;
+ if (!action) return;
+
+ this.show(e.target.dataset);
+ e.preventDefault();
+ },
+
+ show(modalData) {
+ const { glModalAction: requestedAction } = modalData;
+ if (!this.actionModals[requestedAction]) {
+ throw new Error(`Requested non-existing modal action ${requestedAction}`);
+ }
+ if (!this.modalConfiguration[requestedAction]) {
+ throw new Error(`Modal action ${requestedAction} has no configuration in HTML`);
+ }
+
+ this.currentModalData = modalData;
+
+ return this.$nextTick().then(() => {
+ this.$refs.modal.show();
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div :is="activeModal" v-if="activeModal" ref="modal" v-bind="modalProps" />
+</template>
diff --git a/app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue b/app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue
new file mode 100644
index 00000000000..4c335cfb018
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { sprintf } from '~/locale';
+
+export default {
+ components: {
+ GlModal,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ content: {
+ type: String,
+ required: true,
+ },
+ action: {
+ type: String,
+ required: true,
+ },
+ url: {
+ type: String,
+ required: true,
+ },
+ username: {
+ type: String,
+ required: true,
+ },
+ csrfToken: {
+ type: String,
+ required: true,
+ },
+ method: {
+ type: String,
+ required: false,
+ default: 'put',
+ },
+ },
+ computed: {
+ modalTitle() {
+ return sprintf(this.title, { username: this.username });
+ },
+ },
+ methods: {
+ show() {
+ this.$refs.modal.show();
+ },
+ submit() {
+ this.$refs.form.submit();
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ ref="modal"
+ modal-id="user-operation-modal"
+ :title="modalTitle"
+ ok-variant="warning"
+ :ok-title="action"
+ @ok="submit"
+ >
+ <form ref="form" :action="url" method="post">
+ <span v-html="content"></span>
+ <input ref="method" type="hidden" name="_method" :value="method" />
+ <input :value="csrfToken" type="hidden" name="authenticity_token" />
+ </form>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/pages/admin/users/index.js b/app/assets/javascripts/pages/admin/users/index.js
index 45046688b57..bc96e88351b 100644
--- a/app/assets/javascripts/pages/admin/users/index.js
+++ b/app/assets/javascripts/pages/admin/users/index.js
@@ -1,46 +1,65 @@
-import $ from 'jquery';
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
+import ModalManager from './components/user_modal_manager.vue';
+import DeleteUserModal from './components/delete_user_modal.vue';
+import UserOperationConfirmationModal from './components/user_operation_confirmation_modal.vue';
import csrf from '~/lib/utils/csrf';
-import deleteUserModal from './components/delete_user_modal.vue';
+const MODAL_TEXTS_CONTAINER_SELECTOR = '#modal-texts';
+const MODAL_MANAGER_SELECTOR = '#user-modal';
+const ACTION_MODALS = {
+ deactivate: UserOperationConfirmationModal,
+ block: UserOperationConfirmationModal,
+ delete: DeleteUserModal,
+ 'delete-with-contributions': DeleteUserModal,
+};
+
+function loadModalsConfigurationFromHtml(modalsElement) {
+ const modalsConfiguration = {};
+
+ if (!modalsElement) {
+ /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
+ throw new Error('Modals content element not found!');
+ }
+
+ Array.from(modalsElement.children).forEach(node => {
+ const { modal, ...config } = node.dataset;
+ modalsConfiguration[modal] = {
+ title: node.dataset.title,
+ ...config,
+ content: node.innerHTML,
+ };
+ });
+
+ return modalsConfiguration;
+}
document.addEventListener('DOMContentLoaded', () => {
Vue.use(Translate);
- const deleteUserModalEl = document.getElementById('delete-user-modal');
+ const modalConfiguration = loadModalsConfigurationFromHtml(
+ document.querySelector(MODAL_TEXTS_CONTAINER_SELECTOR),
+ );
- const deleteModal = new Vue({
- el: deleteUserModalEl,
- data: {
- deleteUserUrl: '',
- blockUserUrl: '',
- deleteContributions: '',
- username: '',
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: MODAL_MANAGER_SELECTOR,
+ functional: true,
+ methods: {
+ show(...args) {
+ this.$refs.manager.show(...args);
+ },
},
- render(createElement) {
- return createElement(deleteUserModal, {
+ render(h) {
+ return h(ModalManager, {
+ ref: 'manager',
props: {
- deleteUserUrl: this.deleteUserUrl,
- blockUserUrl: this.blockUserUrl,
- deleteContributions: this.deleteContributions,
- username: this.username,
+ modalConfiguration,
+ actionModals: ACTION_MODALS,
csrfToken: csrf.token,
},
});
},
});
-
- $(document).on('shown.bs.modal', event => {
- if (event.relatedTarget.classList.contains('delete-user-button')) {
- const buttonProps = event.relatedTarget.dataset;
- deleteModal.deleteUserUrl = buttonProps.deleteUserUrl;
- deleteModal.blockUserUrl = buttonProps.blockUserUrl;
- deleteModal.deleteContributions = event.relatedTarget.hasAttribute(
- 'data-delete-contributions',
- );
- deleteModal.username = buttonProps.username;
- }
- });
});
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
index d51d411f3c6..5230bdf9cdd 100644
--- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -1,9 +1,11 @@
/* eslint-disable class-methods-use-this, no-unneeded-ternary */
import $ from 'jquery';
+import '~/gl_dropdown';
import { visitUrl } from '~/lib/utils/url_utility';
import UsersSelect from '~/users_select';
import { isMetaClick } from '~/lib/utils/common_utils';
+import { addDelimiter } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
import flash from '~/flash';
import axios from '~/lib/utils/axios_utils';
@@ -145,8 +147,8 @@ export default class Todos {
updateBadges(data) {
$(document).trigger('todo:toggle', data.count);
- document.querySelector('.todos-pending .badge').innerHTML = data.count;
- document.querySelector('.todos-done .badge').innerHTML = data.done_count;
+ document.querySelector('.todos-pending .badge').innerHTML = addDelimiter(data.count);
+ document.querySelector('.todos-done .badge').innerHTML = addDelimiter(data.done_count);
}
goToTodoUrl(e) {
diff --git a/app/assets/javascripts/pages/groups/registry/repositories/index.js b/app/assets/javascripts/pages/groups/registry/repositories/index.js
new file mode 100644
index 00000000000..b663defad0e
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/registry/repositories/index.js
@@ -0,0 +1,3 @@
+import initRegistryImages from '~/registry';
+
+document.addEventListener('DOMContentLoaded', initRegistryImages);
diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js
index 01ef3f1db2b..37b253d7c48 100644
--- a/app/assets/javascripts/pages/groups/shared/group_details.js
+++ b/app/assets/javascripts/pages/groups/shared/group_details.js
@@ -1,6 +1,6 @@
/* eslint-disable no-new */
-import { getPagePath } from '~/lib/utils/common_utils';
+import { getPagePath, getDashPath } from '~/lib/utils/common_utils';
import { ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED } from '~/groups/constants';
import NewGroupChild from '~/groups/new_group_child';
import notificationsDropdown from '~/notifications_dropdown';
@@ -12,9 +12,8 @@ import GroupTabs from './group_tabs';
export default function initGroupDetails(actionName = 'show') {
const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
const loadableActions = [ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED];
- const paths = window.location.pathname.split('/');
- const subpath = paths[paths.length - 1];
- let action = loadableActions.includes(subpath) ? subpath : getPagePath(1);
+ const dashPath = getDashPath();
+ let action = loadableActions.includes(dashPath) ? dashPath : getPagePath(1);
if (actionName && action === actionName) {
action = 'show'; // 'show' resets GroupTabs to default action through base class
}
diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
index c563514d36b..26adf4cbbe0 100644
--- a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
+++ b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
@@ -1,14 +1,14 @@
<script>
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { s__, sprintf } from '~/locale';
import { visitUrl } from '~/lib/utils/url_utility';
import eventHub from '../event_hub';
export default {
components: {
- GlModal,
+ GlModal: DeprecatedModal2,
},
props: {
milestoneTitle: {
diff --git a/app/assets/javascripts/pages/projects/clusters/new/index.js b/app/assets/javascripts/pages/projects/clusters/new/index.js
index 55aa29c9797..14d5ab21555 100644
--- a/app/assets/javascripts/pages/projects/clusters/new/index.js
+++ b/app/assets/javascripts/pages/projects/clusters/new/index.js
@@ -1,7 +1,13 @@
document.addEventListener('DOMContentLoaded', () => {
if (gon.features.createEksClusters) {
import(/* webpackChunkName: 'eks_cluster' */ '~/create_cluster/eks_cluster')
- .then(({ default: initCreateEKSCluster }) => initCreateEKSCluster())
+ .then(({ default: initCreateEKSCluster }) => {
+ const el = document.querySelector('.js-create-eks-cluster-form-container');
+
+ if (el) {
+ initCreateEKSCluster(el);
+ }
+ })
.catch(() => {});
}
});
diff --git a/app/assets/javascripts/pages/projects/commit/pipelines/index.js b/app/assets/javascripts/pages/projects/commit/pipelines/index.js
index 8cc3cb0a57c..9f08260c3d6 100644
--- a/app/assets/javascripts/pages/projects/commit/pipelines/index.js
+++ b/app/assets/javascripts/pages/projects/commit/pipelines/index.js
@@ -6,6 +6,7 @@ document.addEventListener('DOMContentLoaded', () => {
new MiniPipelineGraph({
container: '.js-commit-pipeline-graph',
}).bindEvents();
+ // eslint-disable-next-line no-jquery/no-load
$('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath);
initPipelines();
});
diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js
index 6fc982967eb..5aa4734244e 100644
--- a/app/assets/javascripts/pages/projects/commit/show/index.js
+++ b/app/assets/javascripts/pages/projects/commit/show/index.js
@@ -21,6 +21,7 @@ document.addEventListener('DOMContentLoaded', () => {
}).bindEvents();
initNotes();
initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight + performanceHeight);
+ // eslint-disable-next-line no-jquery/no-load
$('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath);
fetchCommitMergeRequests();
initDiffNotes();
diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js
index 76613394af6..5b873e6b909 100644
--- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js
+++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, prefer-template, no-return-assign */
+/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, no-return-assign */
import $ from 'jquery';
import _ from 'underscore';
@@ -66,8 +66,8 @@ export default (function() {
class: 'person',
style: 'display: block;',
});
- author_name = $('<h4>' + author.author_name + '</h4>');
- author_email = $('<p class="graph-author-email">' + author.author_email + '</p>');
+ author_name = $(`<h4>${author.author_name}</h4>`);
+ author_email = $(`<p class="graph-author-email">${author.author_email}</p>`);
author_commit_info_span = $('<span/>', {
class: 'commits',
});
diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js
index 506e6075d16..86794800f87 100644
--- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js
+++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-restricted-syntax, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, no-return-assign, prefer-arrow-callback, prefer-template, no-else-return, no-shadow */
+/* eslint-disable func-names, no-restricted-syntax, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, no-return-assign, no-else-return, no-shadow */
import $ from 'jquery';
import _ from 'underscore';
@@ -69,24 +69,18 @@ export const ContributorsGraph = (function() {
ContributorsGraph.set_y_domain = function(data) {
return (ContributorsGraph.prototype.y_domain = [
0,
- d3.max(data, function(d) {
- return (d.commits = d.commits || d.additions || d.deletions);
- }),
+ d3.max(data, d => (d.commits = d.commits || d.additions || d.deletions)),
]);
};
ContributorsGraph.init_x_domain = function(data) {
- return (ContributorsGraph.prototype.x_domain = d3.extent(data, function(d) {
- return d.date;
- }));
+ return (ContributorsGraph.prototype.x_domain = d3.extent(data, d => d.date));
};
ContributorsGraph.init_y_domain = function(data) {
return (ContributorsGraph.prototype.y_domain = [
0,
- d3.max(data, function(d) {
- return (d.commits = d.commits || d.additions || d.deletions);
- }),
+ d3.max(data, d => (d.commits = d.commits || d.additions || d.deletions)),
]);
};
@@ -124,14 +118,11 @@ export const ContributorsGraph = (function() {
};
ContributorsGraph.prototype.draw_x_axis = function() {
- return (
- this.svg
- .append('g')
- .attr('class', 'x axis')
- /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
- .attr('transform', 'translate(0, ' + this.height + ')')
- .call(this.x_axis)
- );
+ return this.svg
+ .append('g')
+ .attr('class', 'x axis')
+ .attr('transform', `translate(0, ${this.height})`)
+ .call(this.x_axis);
};
ContributorsGraph.prototype.draw_y_axis = function() {
@@ -180,9 +171,7 @@ export const ContributorsMasterGraph = (function(superClass) {
ContributorsMasterGraph.prototype.parse_dates = function(data) {
const parseDate = d3.timeParse('%Y-%m-%d');
- return data.forEach(function(d) {
- return (d.date = parseDate(d.date));
- });
+ return data.forEach(d => (d.date = parseDate(d.date)));
};
ContributorsMasterGraph.prototype.create_scale = function() {
@@ -208,19 +197,16 @@ export const ContributorsMasterGraph = (function(superClass) {
.attr('height', this.height + this.MARGIN.top + this.MARGIN.bottom)
.attr('class', 'tint-box')
.append('g')
- /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
- .attr('transform', 'translate(' + this.MARGIN.left + ',' + this.MARGIN.top + ')');
+ .attr('transform', `translate(${this.MARGIN.left},${this.MARGIN.top})`);
return this.svg;
};
ContributorsMasterGraph.prototype.create_area = function(x, y) {
return (this.area = d3
.area()
- .x(function(d) {
- return x(d.date);
- })
+ .x(d => x(d.date))
.y0(this.height)
- .y1(function(d) {
+ .y1(d => {
d.commits = d.commits || d.additions || d.deletions;
return y(d.commits);
}));
@@ -330,7 +316,7 @@ export const ContributorsAuthorGraph = (function(superClass) {
ContributorsAuthorGraph.prototype.create_area = function(x, y) {
return (this.area = d3
.area()
- .x(function(d) {
+ .x(d => {
const parseDate = d3.timeParse('%Y-%m-%d');
return x(parseDate(d));
})
@@ -358,8 +344,7 @@ export const ContributorsAuthorGraph = (function(superClass) {
.attr('height', this.height + this.MARGIN.top + this.MARGIN.bottom)
.attr('class', 'spark')
.append('g')
- /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
- .attr('transform', 'translate(' + this.MARGIN.left + ',' + this.MARGIN.top + ')');
+ .attr('transform', `translate(${this.MARGIN.left},${this.MARGIN.top})`);
return this.svg;
};
diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js
index 505ca938f40..a89a13fe37a 100644
--- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js
+++ b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, no-return-assign, prefer-arrow-callback, consistent-return, no-cond-assign, no-else-return */
+/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, no-return-assign, consistent-return, no-cond-assign, no-else-return */
import _ from 'underscore';
export default {
@@ -76,16 +76,12 @@ export default {
var log, total_data;
log = parsed_log.total;
total_data = this.pick_field(log, field);
- return _.sortBy(total_data, function(d) {
- return d.date;
- });
+ return _.sortBy(total_data, d => d.date);
},
pick_field(log, field) {
var total_data;
total_data = [];
- _.each(log, function(d) {
- return total_data.push(_.pick(d, [field, 'date']));
- });
+ _.each(log, d => total_data.push(_.pick(d, [field, 'date'])));
return total_data;
},
get_author_data(parsed_log, field, date_range) {
@@ -107,9 +103,7 @@ export default {
};
})(this),
);
- return _.sortBy(author_data, function(d) {
- return d[field];
- }).reverse();
+ return _.sortBy(author_data, d => d[field]).reverse();
},
parse_log_entry(log_entry, field, date_range) {
var parsed_entry;
diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js
index 2205a7bafe3..96e47187fed 100644
--- a/app/assets/javascripts/pages/projects/issues/form.js
+++ b/app/assets/javascripts/pages/projects/issues/form.js
@@ -15,7 +15,9 @@ export default () => {
new IssuableForm($('.issue-form'));
new LabelsSelect();
new MilestoneSelect();
- new IssuableTemplateSelectors();
+ new IssuableTemplateSelectors({
+ warnTemplateOverride: true,
+ });
initSuggestions();
};
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
index 0447d1f79fb..28a136a5fa5 100644
--- a/app/assets/javascripts/pages/projects/issues/show.js
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -5,6 +5,7 @@ import ZenMode from '~/zen_mode';
import '~/notes/index';
import initIssueableApp from '~/issue_show';
import initRelatedMergeRequestsApp from '~/related_merge_requests';
+import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle';
export default function() {
initIssueableApp();
@@ -12,5 +13,9 @@ export default function() {
new Issue(); // eslint-disable-line no-new
new ShortcutsIssuable(); // eslint-disable-line no-new
new ZenMode(); // eslint-disable-line no-new
- initIssuableSidebar();
+ if (gon.features && gon.features.vueIssuableSidebar) {
+ initVueIssuableSidebarApp();
+ } else {
+ initIssuableSidebar();
+ }
}
diff --git a/app/assets/javascripts/pages/projects/issues/show/index.js b/app/assets/javascripts/pages/projects/issues/show/index.js
index 7968dfd7a12..ce74a6de11f 100644
--- a/app/assets/javascripts/pages/projects/issues/show/index.js
+++ b/app/assets/javascripts/pages/projects/issues/show/index.js
@@ -3,5 +3,7 @@ import initShow from '../show';
document.addEventListener('DOMContentLoaded', () => {
initShow();
- initSidebarBundle();
+ if (gon.features && !gon.features.vueIssuableSidebar) {
+ initSidebarBundle();
+ }
});
diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
index e723cd3fea9..bb95f33c838 100644
--- a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
+++ b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
@@ -2,14 +2,14 @@
import _ from 'underscore';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { s__, sprintf } from '~/locale';
import { visitUrl } from '~/lib/utils/url_utility';
import eventHub from '../event_hub';
export default {
components: {
- GlModal,
+ GlModal: DeprecatedModal2,
},
props: {
url: {
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
index 8f0dc8554e2..e51ab79a51d 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
@@ -16,5 +16,7 @@ export default () => {
new IssuableForm($('.merge-request-form'));
new LabelsSelect();
new MilestoneSelect();
- new IssuableTemplateSelectors();
+ new IssuableTemplateSelectors({
+ warnTemplateOverride: true,
+ });
};
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
index 7bfb83a2204..fa1de1f13cb 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
@@ -4,11 +4,16 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import { handleLocationHash } from '~/lib/utils/common_utils';
import howToMerge from '~/how_to_merge';
import initPipelines from '~/commit/pipelines/pipelines_bundle';
+import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle';
import initWidget from '../../../vue_merge_request_widget';
export default function() {
new ZenMode(); // eslint-disable-line no-new
- initIssuableSidebar();
+ if (gon.features && gon.features.vueIssuableSidebar) {
+ initVueIssuableSidebarApp();
+ } else {
+ initIssuableSidebar();
+ }
initPipelines();
new ShortcutsIssuable(true); // eslint-disable-line no-new
handleLocationHash();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
index f61f4db78d5..ddc648702f1 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
@@ -4,6 +4,8 @@ import initShow from '../init_merge_request_show';
document.addEventListener('DOMContentLoaded', () => {
initShow();
- initSidebarBundle();
+ if (gon.features && !gon.features.vueIssuableSidebar) {
+ initSidebarBundle();
+ }
initMrNotes();
});
diff --git a/app/assets/javascripts/pages/projects/network/network.js b/app/assets/javascripts/pages/projects/network/network.js
index 226d63f05c4..43417fa9702 100644
--- a/app/assets/javascripts/pages/projects/network/network.js
+++ b/app/assets/javascripts/pages/projects/network/network.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, prefer-template */
+/* eslint-disable func-names, no-var */
import $ from 'jquery';
import BranchGraph from '../../../network/branch_graph';
@@ -14,7 +14,7 @@ export default (function() {
this.branch_graph = new BranchGraph($('.network-graph'), opts);
vph = $(window).height() - 250;
$('.network-graph').css({
- height: vph + 'px',
+ height: `${vph}px`,
});
}
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index b99408e3609..435e8705803 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -1,9 +1,9 @@
-/* eslint-disable func-names, no-var, no-return-assign, vars-on-top */
+/* eslint-disable func-names, no-var, no-return-assign */
import $ from 'jquery';
import Cookies from 'js-cookie';
import { __ } from '~/locale';
-import { visitUrl, mergeUrlParams } from '~/lib/utils/url_utility';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
import { serializeForm } from '~/lib/utils/forms';
import axios from '~/lib/utils/axios_utils';
import flash from '~/flash';
@@ -105,6 +105,10 @@ export default class Project {
var selected = $dropdown.data('selected');
var fieldName = $dropdown.data('fieldName');
var shouldVisit = Boolean($dropdown.data('visit'));
+ var $form = $dropdown.closest('form');
+ var action = $form.attr('action');
+ var linkTarget = mergeUrlParams(serializeForm($form[0]), action);
+
return $dropdown.glDropdown({
data(term, callback) {
axios
@@ -126,21 +130,18 @@ export default class Project {
renderRow(ref) {
var li = refListItem.cloneNode(false);
- if (ref.header != null) {
- li.className = 'dropdown-header';
- li.textContent = ref.header;
- } else {
- var link = refLink.cloneNode(false);
-
- if (ref === selected) {
- link.className = 'is-active';
- }
-
- link.textContent = ref;
- link.dataset.ref = ref;
+ var link = refLink.cloneNode(false);
- li.appendChild(link);
+ if (ref === selected) {
+ link.className = 'is-active';
}
+ link.textContent = ref;
+ link.dataset.ref = ref;
+ if (ref.length > 0 && shouldVisit) {
+ link.href = mergeUrlParams({ [fieldName]: ref }, linkTarget);
+ }
+
+ li.appendChild(link);
return li;
},
@@ -152,15 +153,11 @@ export default class Project {
},
clicked(options) {
const { e } = options;
- e.preventDefault();
- if ($(`input[name="${fieldName}"]`).length) {
- var $form = $dropdown.closest('form');
- var action = $form.attr('action');
-
- if (shouldVisit) {
- visitUrl(mergeUrlParams(serializeForm($form[0]), action));
- }
+ if (!shouldVisit) {
+ e.preventDefault();
}
+ /* The actual process is removed since `link.href` in `RenderRow` contains the full target.
+ * It makes the visitable link can be visited when opening on a new tab of browser */
},
});
});
diff --git a/app/assets/javascripts/pages/projects/releases/edit/index.js b/app/assets/javascripts/pages/projects/releases/edit/index.js
index c70271b09c4..98ec196fc37 100644
--- a/app/assets/javascripts/pages/projects/releases/edit/index.js
+++ b/app/assets/javascripts/pages/projects/releases/edit/index.js
@@ -1,4 +1,7 @@
-import $ from 'jquery';
-import initForm from '~/pages/projects/init_form';
+import ZenMode from '~/zen_mode';
+import initEditRelease from '~/releases/detail';
-document.addEventListener('DOMContentLoaded', () => initForm($('.release-form')));
+document.addEventListener('DOMContentLoaded', () => {
+ new ZenMode(); // eslint-disable-line no-new
+ initEditRelease();
+});
diff --git a/app/assets/javascripts/pages/projects/releases/index/index.js b/app/assets/javascripts/pages/projects/releases/index/index.js
index c183fbb9610..6402023149f 100644
--- a/app/assets/javascripts/pages/projects/releases/index/index.js
+++ b/app/assets/javascripts/pages/projects/releases/index/index.js
@@ -1,3 +1,3 @@
-import initReleases from '~/releases';
+import initReleases from '~/releases/list';
document.addEventListener('DOMContentLoaded', initReleases);
diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js
new file mode 100644
index 00000000000..a33d11f3613
--- /dev/null
+++ b/app/assets/javascripts/pages/registrations/new/index.js
@@ -0,0 +1,9 @@
+import LengthValidator from '~/pages/sessions/new/length_validator';
+import UsernameValidator from '~/pages/sessions/new/username_validator';
+import NoEmojiValidator from '~/emoji/no_emoji_validator';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new UsernameValidator(); // eslint-disable-line no-new
+ new LengthValidator(); // eslint-disable-line no-new
+ new NoEmojiValidator(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/registrations/welcome/index.js b/app/assets/javascripts/pages/registrations/welcome/index.js
new file mode 100644
index 00000000000..2d555fa7977
--- /dev/null
+++ b/app/assets/javascripts/pages/registrations/welcome/index.js
@@ -0,0 +1,7 @@
+import LengthValidator from '~/pages/sessions/new/length_validator';
+import NoEmojiValidator from '~/emoji/no_emoji_validator';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new LengthValidator(); // eslint-disable-line no-new
+ new NoEmojiValidator(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/search/show/search.js b/app/assets/javascripts/pages/search/show/search.js
index 8f6c48ab065..dff9d855b67 100644
--- a/app/assets/javascripts/pages/search/show/search.js
+++ b/app/assets/javascripts/pages/search/show/search.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import '~/gl_dropdown';
import Flash from '~/flash';
import Api from '~/api';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
index a271284dd89..7ce32032ed3 100644
--- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -1,10 +1,13 @@
<script>
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import RequestWarning from './request_warning.vue';
+
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
- GlModal,
+ RequestWarning,
+ GlModal: DeprecatedModal2,
Icon,
},
props: {
@@ -39,6 +42,16 @@ export default {
detailsList() {
return this.metricDetails.details;
},
+ warnings() {
+ return this.metricDetails.warnings || [];
+ },
+ htmlId() {
+ if (this.currentRequest) {
+ return `performance-bar-warning-${this.currentRequest.id}-${this.metric}`;
+ }
+
+ return '';
+ },
},
};
</script>
@@ -105,5 +118,6 @@ export default {
<div slot="footer"></div>
</gl-modal>
{{ title }}
+ <request-warning :html-id="htmlId" :warnings="warnings" />
</div>
</template>
diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
index 9ad6e75b86b..3b07eba02b7 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -1,14 +1,14 @@
<script>
import { glEmojiTag } from '~/emoji';
-import detailedMetric from './detailed_metric.vue';
-import requestSelector from './request_selector.vue';
+import DetailedMetric from './detailed_metric.vue';
+import RequestSelector from './request_selector.vue';
import { s__ } from '~/locale';
export default {
components: {
- detailedMetric,
- requestSelector,
+ DetailedMetric,
+ RequestSelector,
},
props: {
store: {
diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue
index 297507b85af..793aba3189b 100644
--- a/app/assets/javascripts/performance_bar/components/request_selector.vue
+++ b/app/assets/javascripts/performance_bar/components/request_selector.vue
@@ -1,5 +1,12 @@
<script>
+import { glEmojiTag } from '~/emoji';
+import { n__ } from '~/locale';
+import { GlPopover } from '@gitlab/ui';
+
export default {
+ components: {
+ GlPopover,
+ },
props: {
currentRequest: {
type: Object,
@@ -15,6 +22,18 @@ export default {
currentRequestId: this.currentRequest.id,
};
},
+ computed: {
+ requestsWithWarnings() {
+ return this.requests.filter(request => request.hasWarnings);
+ },
+ warningMessage() {
+ return n__(
+ '%d request with warnings',
+ '%d requests with warnings',
+ this.requestsWithWarnings.length,
+ );
+ },
+ },
watch: {
currentRequestId(newRequestId) {
this.$emit('change-current-request', newRequestId);
@@ -31,6 +50,7 @@ export default {
return truncated;
},
+ glEmojiTag,
},
};
</script>
@@ -44,7 +64,16 @@ export default {
class="qa-performance-bar-request"
>
{{ truncatedUrl(request.url) }}
+ <span v-if="request.hasWarnings">(!)</span>
</option>
</select>
+ <span v-if="requestsWithWarnings.length">
+ <span id="performance-bar-request-selector-warning" v-html="glEmojiTag('warning')"></span>
+ <gl-popover
+ target="performance-bar-request-selector-warning"
+ :content="warningMessage"
+ triggers="hover focus"
+ />
+ </span>
</div>
</template>
diff --git a/app/assets/javascripts/performance_bar/components/request_warning.vue b/app/assets/javascripts/performance_bar/components/request_warning.vue
new file mode 100644
index 00000000000..0da3c271214
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/components/request_warning.vue
@@ -0,0 +1,41 @@
+<script>
+import { glEmojiTag } from '~/emoji';
+import { GlPopover } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlPopover,
+ },
+ props: {
+ htmlId: {
+ type: String,
+ required: true,
+ },
+ warnings: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ hasWarnings() {
+ return this.warnings && this.warnings.length;
+ },
+ warningMessage() {
+ if (!this.hasWarnings) {
+ return '';
+ }
+
+ return this.warnings.join('\n');
+ },
+ },
+ methods: {
+ glEmojiTag,
+ },
+};
+</script>
+<template>
+ <span v-if="hasWarnings">
+ <span :id="htmlId" v-html="glEmojiTag('warning')"></span>
+ <gl-popover :target="htmlId" :content="warningMessage" triggers="hover focus" />
+ </span>
+</template>
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
index 29bfb7ee5df..1ae9487f391 100644
--- a/app/assets/javascripts/performance_bar/index.js
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -6,7 +6,7 @@ export default ({ container }) =>
new Vue({
el: container,
components: {
- performanceBarApp: () => import('./components/performance_bar_app.vue'),
+ PerformanceBarApp: () => import('./components/performance_bar_app.vue'),
},
data() {
const performanceBarData = document.querySelector(this.$options.el).dataset;
@@ -41,7 +41,7 @@ export default ({ container }) =>
PerformanceBarService.fetchRequestDetails(this.peekUrl, requestId)
.then(res => {
- this.store.addRequestDetails(requestId, res.data.data);
+ this.store.addRequestDetails(requestId, res.data);
})
.catch(() =>
// eslint-disable-next-line no-console
diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
index 031e774d533..64f4f5e0c76 100644
--- a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
+++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
@@ -3,12 +3,13 @@ export default class PerformanceBarStore {
this.requests = [];
}
- addRequest(requestId, requestUrl, requestDetails) {
+ addRequest(requestId, requestUrl) {
if (!this.findRequest(requestId)) {
this.requests.push({
id: requestId,
url: requestUrl,
- details: requestDetails,
+ details: {},
+ hasWarnings: false,
});
}
@@ -22,7 +23,8 @@ export default class PerformanceBarStore {
addRequestDetails(requestId, requestDetails) {
const request = this.findRequest(requestId);
- request.details = requestDetails;
+ request.details = requestDetails.data;
+ request.hasWarnings = requestDetails.has_warnings;
return request;
}
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index cfc72327ef7..e29509ce074 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -1,20 +1,120 @@
<script>
+import _ from 'underscore';
import { GlLoadingIcon } from '@gitlab/ui';
import StageColumnComponent from './stage_column_component.vue';
import GraphMixin from '../../mixins/graph_component_mixin';
-import GraphWidthMixin from '~/pipelines/mixins/graph_width_mixin';
+import GraphWidthMixin from '../../mixins/graph_width_mixin';
+import LinkedPipelinesColumn from './linked_pipelines_column.vue';
+import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin';
export default {
+ name: 'PipelineGraph',
components: {
StageColumnComponent,
GlLoadingIcon,
+ LinkedPipelinesColumn,
+ },
+ mixins: [GraphMixin, GraphWidthMixin, GraphBundleMixin],
+ props: {
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ isLinkedPipeline: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ mediator: {
+ type: Object,
+ required: true,
+ },
+ type: {
+ type: String,
+ required: false,
+ default: 'main',
+ },
+ },
+ upstream: 'upstream',
+ downstream: 'downstream',
+ data() {
+ return {
+ triggeredTopIndex: 1,
+ };
+ },
+ computed: {
+ hasTriggeredBy() {
+ return (
+ this.type !== this.$options.downstream &&
+ this.triggeredByPipelines &&
+ this.pipeline.triggered_by !== null
+ );
+ },
+ triggeredByPipelines() {
+ return this.pipeline.triggered_by;
+ },
+ hasTriggered() {
+ return (
+ this.type !== this.$options.upstream &&
+ this.triggeredPipelines &&
+ this.pipeline.triggered.length > 0
+ );
+ },
+ triggeredPipelines() {
+ return this.pipeline.triggered;
+ },
+ expandedTriggeredBy() {
+ return (
+ this.pipeline.triggered_by &&
+ _.isArray(this.pipeline.triggered_by) &&
+ this.pipeline.triggered_by.find(el => el.isExpanded)
+ );
+ },
+ expandedTriggered() {
+ return this.pipeline.triggered && this.pipeline.triggered.find(el => el.isExpanded);
+ },
+
+ /**
+ * Calculates the margin top of the clicked downstream pipeline by
+ * adding the height of each linked pipeline and the margin
+ */
+ marginTop() {
+ return `${this.triggeredTopIndex * 52}px`;
+ },
+ pipelineTypeUpstream() {
+ return this.type !== this.$options.downstream && this.expandedTriggeredBy;
+ },
+ pipelineTypeDownstream() {
+ return this.type !== this.$options.upstream && this.expandedTriggered;
+ },
+ },
+ methods: {
+ handleClickedDownstream(pipeline, clickedIndex) {
+ this.triggeredTopIndex = clickedIndex;
+ this.$emit('onClickTriggered', this.pipeline, pipeline);
+ },
+ hasOnlyOneJob(stage) {
+ return stage.groups.length === 1;
+ },
+ hasDownstream(index, length) {
+ return index === length - 1 && this.hasTriggered;
+ },
+ hasUpstream(index) {
+ return index === 0 && this.hasTriggeredBy;
+ },
},
- mixins: [GraphMixin, GraphWidthMixin],
};
</script>
<template>
<div class="build-content middle-block js-pipeline-graph">
- <div class="pipeline-visualization pipeline-graph pipeline-tab-content">
+ <div
+ class="pipeline-visualization pipeline-graph"
+ :class="{ 'pipeline-tab-content': !isLinkedPipeline }"
+ >
<div
:style="{
paddingLeft: `${graphLeftPadding}px`,
@@ -23,21 +123,80 @@ export default {
>
<gl-loading-icon v-if="isLoading" class="m-auto" :size="3" />
- <ul v-if="!isLoading" class="stage-column-list">
+ <pipeline-graph
+ v-if="pipelineTypeUpstream"
+ type="upstream"
+ class="d-inline-block upstream-pipeline"
+ :class="`js-upstream-pipeline-${expandedTriggeredBy.id}`"
+ :is-loading="false"
+ :pipeline="expandedTriggeredBy"
+ :is-linked-pipeline="true"
+ :mediator="mediator"
+ @onClickTriggeredBy="
+ (parentPipeline, pipeline) => clickTriggeredByPipeline(parentPipeline, pipeline)
+ "
+ @refreshPipelineGraph="requestRefreshPipelineGraph"
+ />
+
+ <linked-pipelines-column
+ v-if="hasTriggeredBy"
+ :linked-pipelines="triggeredByPipelines"
+ :column-title="__('Upstream')"
+ graph-position="left"
+ @linkedPipelineClick="
+ linkedPipeline => $emit('onClickTriggeredBy', pipeline, linkedPipeline)
+ "
+ />
+
+ <ul
+ v-if="!isLoading"
+ :class="{
+ 'inline js-has-linked-pipelines': hasTriggered || hasTriggeredBy,
+ }"
+ class="stage-column-list align-top"
+ >
<stage-column-component
v-for="(stage, index) in graph"
:key="stage.name"
:class="{
- 'append-right-48': shouldAddRightMargin(index),
+ 'has-upstream prepend-left-64': hasUpstream(index),
+ 'has-downstream': hasDownstream(index, graph.length),
+ 'has-only-one-job': hasOnlyOneJob(stage),
+ 'append-right-46': shouldAddRightMargin(index),
}"
:title="capitalizeStageName(stage.name)"
:groups="stage.groups"
:stage-connector-class="stageConnectorClass(index, stage)"
:is-first-column="isFirstColumn(index)"
+ :has-triggered-by="hasTriggeredBy"
:action="stage.status.action"
@refreshPipelineGraph="refreshPipelineGraph"
/>
</ul>
+
+ <linked-pipelines-column
+ v-if="hasTriggered"
+ :linked-pipelines="triggeredPipelines"
+ :column-title="__('Downstream')"
+ graph-position="right"
+ @linkedPipelineClick="handleClickedDownstream"
+ />
+
+ <pipeline-graph
+ v-if="pipelineTypeDownstream"
+ type="downstream"
+ class="d-inline-block"
+ :class="`js-downstream-pipeline-${expandedTriggered.id}`"
+ :is-loading="false"
+ :pipeline="expandedTriggered"
+ :is-linked-pipeline="true"
+ :style="{ 'margin-top': marginTop }"
+ :mediator="mediator"
+ @onClickTriggered="
+ (parentPipeline, pipeline) => clickTriggeredPipeline(parentPipeline, pipeline)
+ "
+ @refreshPipelineGraph="requestRefreshPipelineGraph"
+ />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
new file mode 100644
index 00000000000..4e7d77863b9
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlLoadingIcon, GlTooltipDirective, GlButton } from '@gitlab/ui';
+import CiStatus from '~/vue_shared/components/ci_icon.vue';
+
+export default {
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ CiStatus,
+ GlLoadingIcon,
+ GlButton,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ tooltipText() {
+ return `${this.projectName} - ${this.pipelineStatus.label}`;
+ },
+ buttonId() {
+ return `js-linked-pipeline-${this.pipeline.id}`;
+ },
+ pipelineStatus() {
+ return this.pipeline.details.status;
+ },
+ projectName() {
+ return this.pipeline.project.name;
+ },
+ },
+ methods: {
+ onClickLinkedPipeline() {
+ this.$root.$emit('bv::hide::tooltip', this.buttonId);
+ this.$emit('pipelineClicked');
+ },
+ },
+};
+</script>
+
+<template>
+ <li class="linked-pipeline build">
+ <div class="curve"></div>
+ <gl-button
+ :id="buttonId"
+ v-gl-tooltip
+ :title="tooltipText"
+ class="js-linked-pipeline-content linked-pipeline-content"
+ data-qa-selector="linked_pipeline_button"
+ :class="`js-pipeline-expand-${pipeline.id}`"
+ @click="onClickLinkedPipeline"
+ >
+ <gl-loading-icon v-if="pipeline.isLoading" class="js-linked-pipeline-loading d-inline" />
+ <ci-status
+ v-else
+ :status="pipelineStatus"
+ css-classes="position-top-0"
+ class="js-linked-pipeline-status"
+ />
+ <span class="str-truncated align-bottom"> {{ projectName }} &#8226; #{{ pipeline.id }} </span>
+ </gl-button>
+ </li>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
new file mode 100644
index 00000000000..6efdde2b17e
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
@@ -0,0 +1,52 @@
+<script>
+import LinkedPipeline from './linked_pipeline.vue';
+
+export default {
+ components: {
+ LinkedPipeline,
+ },
+ props: {
+ columnTitle: {
+ type: String,
+ required: true,
+ },
+ linkedPipelines: {
+ type: Array,
+ required: true,
+ },
+ graphPosition: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ columnClass() {
+ const positionValues = {
+ right: 'prepend-left-64',
+ left: 'append-right-32',
+ };
+ return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <div :class="columnClass" class="stage-column linked-pipelines-column">
+ <div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div>
+ <div class="cross-project-triangle"></div>
+ <ul>
+ <linked-pipeline
+ v-for="(pipeline, index) in linkedPipelines"
+ :key="pipeline.id"
+ :class="{
+ 'flat-connector-before': index === 0 && graphPosition === 'right',
+ active: pipeline.isExpanded,
+ 'left-connector': pipeline.isExpanded && graphPosition === 'left',
+ }"
+ :pipeline="pipeline"
+ @pipelineClicked="$emit('linkedPipelineClick', pipeline, index)"
+ />
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
index d5c124dc0ca..db7714808fd 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -1,6 +1,6 @@
<script>
import _ from 'underscore';
-import stageColumnMixin from 'ee_else_ce/pipelines/mixins/stage_column_mixin';
+import stageColumnMixin from '../../mixins/stage_column_mixin';
import JobItem from './job_item.vue';
import JobGroupDropdown from './job_group_dropdown.vue';
import ActionComponent from './action_component.vue';
diff --git a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue
index 4cafd147511..2e71b3c637b 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue
@@ -1,6 +1,6 @@
<script>
import _ from 'underscore';
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { GlLink } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
@@ -13,7 +13,7 @@ import { s__, sprintf } from '~/locale';
*/
export default {
components: {
- GlModal,
+ GlModal: DeprecatedModal2,
GlLink,
ClipboardButton,
CiIcon,
diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue
index a08f732dda7..30c830d78f9 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue
@@ -104,7 +104,7 @@ export default {
v-gl-tooltip
:title="
__(
- 'Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more on the documentation for Pipelines for Merged Results.',
+ 'Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for Pipelines for Merged Results.',
)
"
class="js-pipeline-url-detached badge badge-info"
diff --git a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js
index dd79ade5bc9..c76869d90d5 100644
--- a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js
+++ b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js
@@ -1,16 +1,68 @@
-import Flash from '~/flash';
+import flash from '~/flash';
import { __ } from '~/locale';
export default {
methods: {
- clickTriggeredByPipeline() {},
- clickTriggeredPipeline() {},
+ getExpandedPipelines(pipeline) {
+ this.mediator.service
+ .getPipeline(this.mediator.getExpandedParameters())
+ .then(response => {
+ this.mediator.store.toggleLoading(pipeline);
+ this.mediator.store.storePipeline(response.data);
+ this.mediator.poll.enable({ data: this.mediator.getExpandedParameters() });
+ })
+ .catch(() => {
+ this.mediator.store.toggleLoading(pipeline);
+ flash(__('An error occurred while fetching the pipeline.'));
+ });
+ },
+ /**
+ * Called when a linked pipeline is clicked.
+ *
+ * If the pipeline is collapsed we will start polling it & we will reset the other pipelines.
+ * If the pipeline is expanded we will close it.
+ *
+ * @param {String} method Method to fetch the pipeline
+ * @param {String} storeKey Store property that will be updates
+ * @param {String} resetStoreKey Store key for the visible pipeline that will need to be reset
+ * @param {Object} pipeline The clicked pipeline
+ */
+ clickPipeline(parentPipeline, pipeline, openMethod, closeMethod) {
+ if (!pipeline.isExpanded) {
+ this.mediator.store[openMethod](parentPipeline, pipeline);
+ this.mediator.store.toggleLoading(pipeline);
+ this.mediator.poll.stop();
+
+ this.getExpandedPipelines(pipeline);
+ } else {
+ this.mediator.store[closeMethod](pipeline);
+ this.mediator.poll.stop();
+
+ this.mediator.poll.enable({ data: this.mediator.getExpandedParameters() });
+ }
+ },
+ clickTriggeredByPipeline(parentPipeline, pipeline) {
+ this.clickPipeline(
+ parentPipeline,
+ pipeline,
+ 'openTriggeredByPipeline',
+ 'closeTriggeredByPipeline',
+ );
+ },
+ clickTriggeredPipeline(parentPipeline, pipeline) {
+ this.clickPipeline(
+ parentPipeline,
+ pipeline,
+ 'openTriggeredPipeline',
+ 'closeTriggeredPipeline',
+ );
+ },
requestRefreshPipelineGraph() {
// When an action is clicked
// (wether in the dropdown or in the main nodes, we refresh the big graph)
this.mediator
.refreshPipeline()
- .catch(() => Flash(__('An error occurred while making the request.')));
+ .catch(() => flash(__('An error occurred while making the request.')));
},
},
};
diff --git a/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js b/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js
index 64283ed0e58..3f3007ba11a 100644
--- a/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js
+++ b/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js
@@ -1,7 +1,14 @@
export default {
+ props: {
+ hasTriggeredBy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
methods: {
buildConnnectorClass(index) {
- return index === 0 && !this.isFirstColumn ? 'left-connector' : '';
+ return index === 0 && (!this.isFirstColumn || this.hasTriggeredBy) ? 'left-connector' : '';
},
},
};
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index b8976f77bac..b6f8716d37d 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -2,8 +2,8 @@ import Vue from 'vue';
import Flash from '~/flash';
import Translate from '~/vue_shared/translate';
import { __ } from '~/locale';
-import pipelineGraph from 'ee_else_ce/pipelines/components/graph/graph_component.vue';
-import GraphEEMixin from 'ee_else_ce/pipelines/mixins/graph_pipeline_bundle_mixin';
+import pipelineGraph from './components/graph/graph_component.vue';
+import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import PipelinesMediator from './pipeline_details_mediator';
import pipelineHeader from './components/header_component.vue';
import eventHub from './event_hub';
@@ -23,7 +23,7 @@ export default () => {
components: {
pipelineGraph,
},
- mixins: [GraphEEMixin],
+ mixins: [GraphBundleMixin],
data() {
return {
mediator,
diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js
index c8819cf35cf..bf021a0b447 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js
@@ -1,5 +1,5 @@
import Visibility from 'visibilityjs';
-import PipelineStore from 'ee_else_ce/pipelines/stores/pipeline_store';
+import PipelineStore from './stores/pipeline_store';
import Flash from '../flash';
import Poll from '../lib/utils/poll';
import { __ } from '../locale';
diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js
index 259278b6410..441c9f3c25f 100644
--- a/app/assets/javascripts/pipelines/stores/pipeline_store.js
+++ b/app/assets/javascripts/pipelines/stores/pipeline_store.js
@@ -1,10 +1,196 @@
+import Vue from 'vue';
+import _ from 'underscore';
+
export default class PipelineStore {
constructor() {
this.state = {};
this.state.pipeline = {};
+ this.state.expandedPipelines = [];
}
-
+ /**
+ * For the triggered pipelines adds the `isExpanded` key
+ *
+ * For the triggered_by pipeline adds the `isExpanded` key
+ * and saves it as an array
+ *
+ * @param {Object} pipeline
+ */
storePipeline(pipeline = {}) {
- this.state.pipeline = pipeline;
+ const pipelineCopy = Object.assign({}, pipeline);
+
+ if (pipelineCopy.triggered_by) {
+ pipelineCopy.triggered_by = [pipelineCopy.triggered_by];
+
+ const oldTriggeredBy =
+ this.state.pipeline &&
+ this.state.pipeline.triggered_by &&
+ this.state.pipeline.triggered_by[0];
+
+ this.parseTriggeredByPipelines(oldTriggeredBy, pipelineCopy.triggered_by[0]);
+ }
+
+ if (pipelineCopy.triggered && pipelineCopy.triggered.length) {
+ pipelineCopy.triggered.forEach(el => {
+ const oldPipeline =
+ this.state.pipeline &&
+ this.state.pipeline.triggered &&
+ this.state.pipeline.triggered.find(element => element.id === el.id);
+
+ this.parseTriggeredPipelines(oldPipeline, el);
+ });
+ }
+
+ this.state.pipeline = pipelineCopy;
+ }
+
+ /**
+ * Recursiverly parses the triggered by pipelines.
+ *
+ * Sets triggered_by as an array, there is always only 1 triggered_by pipeline.
+ * Adds key `isExpanding`
+ * Keeps old isExpading value due to polling
+ *
+ * @param {Array} parentPipeline
+ * @param {Object} pipeline
+ */
+ parseTriggeredByPipelines(oldPipeline = {}, newPipeline) {
+ // keep old value in case it's opened because we're polling
+
+ Vue.set(newPipeline, 'isExpanded', oldPipeline.isExpanded || false);
+ // add isLoading property
+ Vue.set(newPipeline, 'isLoading', false);
+
+ if (newPipeline.triggered_by) {
+ if (!_.isArray(newPipeline.triggered_by)) {
+ Object.assign(newPipeline, { triggered_by: [newPipeline.triggered_by] });
+ }
+ this.parseTriggeredByPipelines(oldPipeline, newPipeline.triggered_by[0]);
+ }
+ }
+
+ /**
+ * Recursively parses the triggered pipelines
+ * @param {Array} parentPipeline
+ * @param {Object} pipeline
+ */
+ parseTriggeredPipelines(oldPipeline = {}, newPipeline) {
+ // keep old value in case it's opened because we're polling
+ Vue.set(newPipeline, 'isExpanded', oldPipeline.isExpanded || false);
+
+ // add isLoading property
+ Vue.set(newPipeline, 'isLoading', false);
+
+ if (newPipeline.triggered && newPipeline.triggered.length > 0) {
+ newPipeline.triggered.forEach(el => {
+ const oldTriggered =
+ oldPipeline.triggered && oldPipeline.triggered.find(element => element.id === el.id);
+ this.parseTriggeredPipelines(oldTriggered, el);
+ });
+ }
+ }
+
+ /**
+ * Recursively resets all triggered by pipelines
+ *
+ * @param {Object} pipeline
+ */
+ resetTriggeredByPipeline(parentPipeline, pipeline) {
+ parentPipeline.triggered_by.forEach(el => this.closePipeline(el));
+
+ if (pipeline.triggered_by && pipeline.triggered_by) {
+ this.resetTriggeredByPipeline(pipeline, pipeline.triggered_by);
+ }
+ }
+
+ /**
+ * Opens the clicked pipeline and closes all other ones.
+ * @param {Object} pipeline
+ */
+ openTriggeredByPipeline(parentPipeline, pipeline) {
+ // first we need to reset all triggeredBy pipelines
+ this.resetTriggeredByPipeline(parentPipeline, pipeline);
+
+ this.openPipeline(pipeline);
+ }
+
+ /**
+ * On click, will close the given pipeline and all nested triggered by pipelines
+ *
+ * @param {Object} pipeline
+ */
+ closeTriggeredByPipeline(pipeline) {
+ this.closePipeline(pipeline);
+
+ if (pipeline.triggered_by && pipeline.triggered_by.length) {
+ pipeline.triggered_by.forEach(triggeredBy => this.closeTriggeredByPipeline(triggeredBy));
+ }
+ }
+
+ /**
+ * Recursively closes all triggered pipelines for the given one.
+ *
+ * @param {Object} pipeline
+ */
+ resetTriggeredPipelines(parentPipeline, pipeline) {
+ parentPipeline.triggered.forEach(el => this.closePipeline(el));
+
+ if (pipeline.triggered && pipeline.triggered.length) {
+ pipeline.triggered.forEach(el => this.resetTriggeredPipelines(pipeline, el));
+ }
+ }
+
+ /**
+ * Opens the clicked triggered pipeline and closes all other ones.
+ *
+ * @param {Object} pipeline
+ */
+ openTriggeredPipeline(parentPipeline, pipeline) {
+ this.resetTriggeredPipelines(parentPipeline, pipeline);
+
+ this.openPipeline(pipeline);
+ }
+
+ /**
+ * On click, will close the given pipeline and all the nested triggered ones
+ * @param {Object} pipeline
+ */
+ closeTriggeredPipeline(pipeline) {
+ this.closePipeline(pipeline);
+
+ if (pipeline.triggered && pipeline.triggered.length) {
+ pipeline.triggered.forEach(triggered => this.closeTriggeredPipeline(triggered));
+ }
+ }
+
+ /**
+ * Utility function, Closes the given pipeline
+ * @param {Object} pipeline
+ */
+ closePipeline(pipeline) {
+ Vue.set(pipeline, 'isExpanded', false);
+ // remove the pipeline from the parameters
+ this.removeExpandedPipelineToRequestData(pipeline.id);
+ }
+
+ /**
+ * Utility function, Opens the given pipeline
+ * @param {Object} pipeline
+ */
+ openPipeline(pipeline) {
+ Vue.set(pipeline, 'isExpanded', true);
+ // add the pipeline to the parameters
+ this.addExpandedPipelineToRequestData(pipeline.id);
+ }
+ // eslint-disable-next-line class-methods-use-this
+ toggleLoading(pipeline) {
+ Vue.set(pipeline, 'isLoading', !pipeline.isLoading);
+ }
+
+ addExpandedPipelineToRequestData(id) {
+ this.state.expandedPipelines.push(id);
+ }
+
+ removeExpandedPipelineToRequestData(id) {
+ this.state.expandedPipelines.splice(this.state.expandedPipelines.findIndex(el => el === id), 1);
}
}
diff --git a/app/assets/javascripts/privacy_policy_update_callout.js b/app/assets/javascripts/privacy_policy_update_callout.js
index 126b1ee1132..97f41deb30f 100644
--- a/app/assets/javascripts/privacy_policy_update_callout.js
+++ b/app/assets/javascripts/privacy_policy_update_callout.js
@@ -1,7 +1,7 @@
import PersistentUserCallout from '~/persistent_user_callout';
function initPrivacyPolicyUpdateCallout() {
- const callout = document.querySelector('.privacy-policy-update-64341');
+ const callout = document.querySelector('.js-privacy-policy-update');
PersistentUserCallout.factory(callout);
}
diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue
index e1085c0a44d..72867ecd709 100644
--- a/app/assets/javascripts/profile/account/components/update_username.vue
+++ b/app/assets/javascripts/profile/account/components/update_username.vue
@@ -1,13 +1,13 @@
<script>
import _ from 'underscore';
import axios from '~/lib/utils/axios_utils';
-import GlModal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { s__, sprintf } from '~/locale';
import Flash from '~/flash';
export default {
components: {
- GlModal,
+ GlModal: DeprecatedModal2,
},
props: {
actionUrl: {
diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js
index e73a828c0ae..2c375b39c1f 100644
--- a/app/assets/javascripts/project_find_file.js
+++ b/app/assets/javascripts/project_find_file.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, consistent-return, one-var, no-cond-assign, prefer-template, no-return-assign */
+/* eslint-disable func-names, no-var, consistent-return, one-var, no-cond-assign, no-return-assign */
import $ from 'jquery';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
@@ -81,7 +81,7 @@ export default class ProjectFindFile {
// find file
}
- // files pathes load
+ // files paths load
load(url) {
axios
.get(url)
@@ -112,10 +112,13 @@ export default class ProjectFindFile {
if (searchText) {
matches = fuzzaldrinPlus.match(filePath, searchText);
}
- blobItemUrl = this.options.blobUrlTemplate + '/' + encodeURIComponent(filePath);
+ blobItemUrl = `${this.options.blobUrlTemplate}/${encodeURIComponent(filePath)}`;
html = ProjectFindFile.makeHtml(filePath, matches, blobItemUrl);
results.push(this.element.find('.tree-table > tbody').append(html));
}
+
+ this.element.find('.empty-state').toggleClass('hidden', Boolean(results.length));
+
return results;
}
diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js
index 88665ed2ab7..0fbb7e5ca42 100644
--- a/app/assets/javascripts/project_select.js
+++ b/app/assets/javascripts/project_select.js
@@ -5,102 +5,103 @@ import Api from './api';
import ProjectSelectComboButton from './project_select_combo_button';
import { s__ } from './locale';
-export default function projectSelect() {
- import(/* webpackChunkName: 'select2' */ 'select2/select2')
- .then(() => {
- $('.ajax-project-select').each(function(i, select) {
- var placeholder;
- const simpleFilter = $(select).data('simpleFilter') || false;
- this.groupId = $(select).data('groupId');
- this.includeGroups = $(select).data('includeGroups');
- this.allProjects = $(select).data('allProjects') || false;
- this.orderBy = $(select).data('orderBy') || 'id';
- this.withIssuesEnabled = $(select).data('withIssuesEnabled');
- this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled');
- this.withShared =
- $(select).data('withShared') === undefined ? true : $(select).data('withShared');
- this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false;
- this.allowClear = $(select).data('allowClear') || false;
+const projectSelect = () => {
+ $('.ajax-project-select').each(function(i, select) {
+ var placeholder;
+ const simpleFilter = $(select).data('simpleFilter') || false;
+ this.groupId = $(select).data('groupId');
+ this.includeGroups = $(select).data('includeGroups');
+ this.allProjects = $(select).data('allProjects') || false;
+ this.orderBy = $(select).data('orderBy') || 'id';
+ this.withIssuesEnabled = $(select).data('withIssuesEnabled');
+ this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled');
+ this.withShared =
+ $(select).data('withShared') === undefined ? true : $(select).data('withShared');
+ this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false;
+ this.allowClear = $(select).data('allowClear') || false;
- placeholder = s__('ProjectSelect|Search for project');
- if (this.includeGroups) {
- placeholder += s__('ProjectSelect| or group');
- }
+ placeholder = s__('ProjectSelect|Search for project');
+ if (this.includeGroups) {
+ placeholder += s__('ProjectSelect| or group');
+ }
- $(select).select2({
- placeholder,
- minimumInputLength: 0,
- query: (function(_this) {
- return function(query) {
- var finalCallback, projectsCallback;
- finalCallback = function(projects) {
+ $(select).select2({
+ placeholder,
+ minimumInputLength: 0,
+ query: (function(_this) {
+ return function(query) {
+ var finalCallback, projectsCallback;
+ finalCallback = function(projects) {
+ var data;
+ data = {
+ results: projects,
+ };
+ return query.callback(data);
+ };
+ if (_this.includeGroups) {
+ projectsCallback = function(projects) {
+ var groupsCallback;
+ groupsCallback = function(groups) {
var data;
- data = {
- results: projects,
- };
- return query.callback(data);
+ data = groups.concat(projects);
+ return finalCallback(data);
};
- if (_this.includeGroups) {
- projectsCallback = function(projects) {
- var groupsCallback;
- groupsCallback = function(groups) {
- var data;
- data = groups.concat(projects);
- return finalCallback(data);
- };
- return Api.groups(query.term, {}, groupsCallback);
- };
- } else {
- projectsCallback = finalCallback;
- }
- if (_this.groupId) {
- return Api.groupProjects(
- _this.groupId,
- query.term,
- {
- with_issues_enabled: _this.withIssuesEnabled,
- with_merge_requests_enabled: _this.withMergeRequestsEnabled,
- with_shared: _this.withShared,
- include_subgroups: _this.includeProjectsInSubgroups,
- },
- projectsCallback,
- );
- } else {
- return Api.projects(
- query.term,
- {
- order_by: _this.orderBy,
- with_issues_enabled: _this.withIssuesEnabled,
- with_merge_requests_enabled: _this.withMergeRequestsEnabled,
- membership: !_this.allProjects,
- },
- projectsCallback,
- );
- }
+ return Api.groups(query.term, {}, groupsCallback);
};
- })(this),
- id(project) {
- if (simpleFilter) return project.id;
- return JSON.stringify({
- name: project.name,
- url: project.web_url,
- });
- },
- text(project) {
- return project.name_with_namespace || project.name;
- },
+ } else {
+ projectsCallback = finalCallback;
+ }
+ if (_this.groupId) {
+ return Api.groupProjects(
+ _this.groupId,
+ query.term,
+ {
+ with_issues_enabled: _this.withIssuesEnabled,
+ with_merge_requests_enabled: _this.withMergeRequestsEnabled,
+ with_shared: _this.withShared,
+ include_subgroups: _this.includeProjectsInSubgroups,
+ },
+ projectsCallback,
+ );
+ } else {
+ return Api.projects(
+ query.term,
+ {
+ order_by: _this.orderBy,
+ with_issues_enabled: _this.withIssuesEnabled,
+ with_merge_requests_enabled: _this.withMergeRequestsEnabled,
+ membership: !_this.allProjects,
+ },
+ projectsCallback,
+ );
+ }
+ };
+ })(this),
+ id(project) {
+ if (simpleFilter) return project.id;
+ return JSON.stringify({
+ name: project.name,
+ url: project.web_url,
+ });
+ },
+ text(project) {
+ return project.name_with_namespace || project.name;
+ },
- initSelection(el, callback) {
- return Api.project(el.val()).then(({ data }) => callback(data));
- },
+ initSelection(el, callback) {
+ return Api.project(el.val()).then(({ data }) => callback(data));
+ },
- allowClear: this.allowClear,
+ allowClear: this.allowClear,
- dropdownCssClass: 'ajax-project-dropdown',
- });
- if (simpleFilter) return select;
- return new ProjectSelectComboButton(select);
- });
- })
+ dropdownCssClass: 'ajax-project-dropdown',
+ });
+ if (simpleFilter) return select;
+ return new ProjectSelectComboButton(select);
+ });
+};
+
+export default () =>
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(projectSelect)
.catch(() => {});
-}
diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
index 12ee1ce2f0c..60fd3ed5ea7 100644
--- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
+++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
@@ -21,14 +21,6 @@ export default {
type: String,
required: true,
},
- /* This prop can be used to replace some of the `render_commit_status`
- used across GitLab, this way we could use this vue component and add a
- realtime status where it makes sense
- realtime: {
- type: Boolean,
- required: false,
- default: true,
- }, */
},
data() {
return {
@@ -47,6 +39,9 @@ export default {
this.service = new CommitPipelineService(this.endpoint);
this.initPolling();
},
+ beforeDestroy() {
+ this.poll.stop();
+ },
methods: {
successCallback(res) {
const { pipelines } = res.data;
@@ -95,9 +90,6 @@ export default {
.catch(this.errorCallback);
},
},
- destroy() {
- this.poll.stop();
- },
};
</script>
<template>
diff --git a/app/assets/javascripts/ref_select_dropdown.js b/app/assets/javascripts/ref_select_dropdown.js
index 75bac035aca..2e0113271df 100644
--- a/app/assets/javascripts/ref_select_dropdown.js
+++ b/app/assets/javascripts/ref_select_dropdown.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import '~/gl_dropdown';
class RefSelectDropdown {
constructor($dropdownButton, availableRefs) {
diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/components/app.vue
index 346dc470a59..11b2c3b7016 100644
--- a/app/assets/javascripts/registry/components/app.vue
+++ b/app/assets/javascripts/registry/components/app.vue
@@ -2,28 +2,34 @@
import { mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import store from '../stores';
-import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
import CollapsibleContainer from './collapsible_container.vue';
+import ProjectEmptyState from './project_empty_state.vue';
+import GroupEmptyState from './group_empty_state.vue';
import { s__, sprintf } from '../../locale';
export default {
name: 'RegistryListApp',
components: {
- clipboardButton,
CollapsibleContainer,
GlEmptyState,
GlLoadingIcon,
+ ProjectEmptyState,
+ GroupEmptyState,
},
props: {
- endpoint: {
- type: String,
- required: true,
- },
characterError: {
type: Boolean,
required: false,
default: false,
},
+ containersErrorImage: {
+ type: String,
+ required: true,
+ },
+ endpoint: {
+ type: String,
+ required: true,
+ },
helpPagePath: {
type: String,
required: true,
@@ -32,14 +38,30 @@ export default {
type: String,
required: true,
},
- containersErrorImage: {
+ personalAccessTokensHelpLink: {
type: String,
- required: true,
+ required: false,
+ default: null,
+ },
+ registryHostUrlWithPort: {
+ type: String,
+ required: false,
+ default: null,
},
repositoryUrl: {
type: String,
required: true,
},
+ isGroupPage: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ twoFactorAuthHelpLink: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
store,
computed: {
@@ -47,7 +69,7 @@ export default {
dockerConnectionErrorText() {
return sprintf(
s__(`ContainerRegistry|We are having trouble connecting to Docker, which could be due to an
- issue with your project name or path.
+ issue with your project name or path.
%{docLinkStart}More Information%{docLinkEnd}`),
{
docLinkStart: `<a href="${this.helpPagePath}#docker-connection-error" target="_blank">`,
@@ -58,8 +80,8 @@ export default {
},
introText() {
return sprintf(
- s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every
- project can have its own space to store its Docker images.
+ s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every
+ project can have its own space to store its Docker images.
%{docLinkStart}More Information%{docLinkEnd}`),
{
docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`,
@@ -79,17 +101,10 @@ export default {
false,
);
},
- dockerBuildCommand() {
- // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
- return `docker build -t ${this.repositoryUrl} .`;
- },
- dockerPushCommand() {
- // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
- return `docker push ${this.repositoryUrl}`;
- },
},
created() {
this.setMainEndpoint(this.endpoint);
+ this.setIsDeleteDisabled(this.isGroupPage);
},
mounted() {
if (!this.characterError) {
@@ -97,7 +112,7 @@ export default {
}
},
methods: {
- ...mapActions(['setMainEndpoint', 'fetchRepos']),
+ ...mapActions(['setMainEndpoint', 'fetchRepos', 'setIsDeleteDisabled']),
},
};
</script>
@@ -109,7 +124,7 @@ export default {
:svg-path="containersErrorImage"
>
<template #description>
- <p v-html="dockerConnectionErrorText"></p>
+ <p class="js-character-error-text" v-html="dockerConnectionErrorText"></p>
</template>
</gl-empty-state>
@@ -120,46 +135,19 @@ export default {
<p v-html="introText"></p>
<collapsible-container v-for="item in repos" :key="item.id" :repo="item" />
</div>
-
- <gl-empty-state
- v-else
- :title="s__('ContainerRegistry|There are no container images stored for this project')"
- :svg-path="noContainersImage"
- class="container-message"
- >
- <template #description>
- <p class="js-no-container-images-text" v-html="noContainerImagesText"></p>
- <h5>{{ s__('ContainerRegistry|Quick Start') }}</h5>
- <p>
- {{
- s__(
- 'ContainerRegistry|You can add an image to this registry with the following commands:',
- )
- }}
- </p>
-
- <div class="input-group append-bottom-10">
- <input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly />
- <span class="input-group-append">
- <clipboard-button
- :text="dockerBuildCommand"
- :title="s__('ContainerRegistry|Copy build command to clipboard')"
- class="input-group-text"
- />
- </span>
- </div>
-
- <div class="input-group">
- <input :value="dockerPushCommand" type="text" class="form-control monospace" readonly />
- <span class="input-group-append">
- <clipboard-button
- :text="dockerPushCommand"
- :title="s__('ContainerRegistry|Copy push command to clipboard')"
- class="input-group-text"
- />
- </span>
- </div>
- </template>
- </gl-empty-state>
+ <project-empty-state
+ v-else-if="!isGroupPage"
+ :no-containers-image="noContainersImage"
+ :help-page-path="helpPagePath"
+ :repository-url="repositoryUrl"
+ :two-factor-auth-help-link="twoFactorAuthHelpLink"
+ :personal-access-tokens-help-link="personalAccessTokensHelpLink"
+ :registry-host-url-with-port="registryHostUrlWithPort"
+ />
+ <group-empty-state
+ v-else-if="isGroupPage"
+ :no-containers-image="noContainersImage"
+ :help-page-path="helpPagePath"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue
index bfb2305c48c..95f8270b5d0 100644
--- a/app/assets/javascripts/registry/components/collapsible_container.vue
+++ b/app/assets/javascripts/registry/components/collapsible_container.vue
@@ -1,6 +1,13 @@
<script>
-import { mapActions } from 'vuex';
-import { GlLoadingIcon, GlButton, GlTooltipDirective, GlModal, GlModalDirective } from '@gitlab/ui';
+import { mapActions, mapGetters } from 'vuex';
+import {
+ GlLoadingIcon,
+ GlButton,
+ GlTooltipDirective,
+ GlModal,
+ GlModalDirective,
+ GlEmptyState,
+} from '@gitlab/ui';
import createFlash from '../../flash';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
import Icon from '../../vue_shared/components/icon.vue';
@@ -17,6 +24,7 @@ export default {
GlButton,
Icon,
GlModal,
+ GlEmptyState,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -35,9 +43,13 @@ export default {
};
},
computed: {
+ ...mapGetters(['isDeleteDisabled']),
iconName() {
return this.isOpen ? 'angle-up' : 'angle-right';
},
+ canDeleteRepo() {
+ return this.repo.canDelete && !this.isDeleteDisabled;
+ },
},
methods: {
...mapActions(['fetchRepos', 'fetchList', 'deleteItem']),
@@ -49,7 +61,7 @@ export default {
}
},
handleDeleteRepository() {
- this.deleteItem(this.repo)
+ return this.deleteItem(this.repo)
.then(() => {
createFlash(__('This container registry has been scheduled for deletion.'), 'notice');
this.fetchRepos();
@@ -67,7 +79,8 @@ export default {
<div class="container-image">
<div class="container-image-head">
<gl-button class="js-toggle-repo btn-link align-baseline" @click="toggleRepo">
- <icon :name="iconName" /> {{ repo.name }}
+ <icon :name="iconName" />
+ {{ repo.name }}
</gl-button>
<clipboard-button
@@ -79,11 +92,13 @@ export default {
<div class="controls d-none d-sm-block float-right">
<gl-button
- v-if="repo.canDelete"
+ v-if="canDeleteRepo"
v-gl-tooltip
v-gl-modal="modalId"
:title="s__('ContainerRegistry|Remove repository')"
:aria-label="s__('ContainerRegistry|Remove repository')"
+ data-track-event="click_button"
+ data-track-label="registry_repository_delete"
class="js-remove-repo btn-inverted"
variant="danger"
>
@@ -95,11 +110,19 @@ export default {
<gl-loading-icon v-if="repo.isLoading" size="md" class="append-bottom-20" />
<div v-else-if="!repo.isLoading && isOpen" class="container-image-tags">
- <table-registry v-if="repo.list.length" :repo="repo" />
-
- <div v-else class="nothing-here-block">
- {{ s__('ContainerRegistry|No tags in Container Registry for this container image.') }}
- </div>
+ <table-registry v-if="repo.list.length" :repo="repo" :can-delete-repo="canDeleteRepo" />
+ <gl-empty-state
+ v-else
+ :title="s__('ContainerRegistry|This image has no active tags')"
+ :description="
+ s__(
+ `ContainerRegistry|The last tag related to this image was recently removed.
+ This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process.
+ If you have any questions, contact your administrator.`,
+ )
+ "
+ class="mx-auto my-0"
+ />
</div>
<gl-modal :modal-id="modalId" ok-variant="danger" @ok="handleDeleteRepository">
<template v-slot:modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template>
diff --git a/app/assets/javascripts/registry/components/group_empty_state.vue b/app/assets/javascripts/registry/components/group_empty_state.vue
new file mode 100644
index 00000000000..7885fd2146d
--- /dev/null
+++ b/app/assets/javascripts/registry/components/group_empty_state.vue
@@ -0,0 +1,46 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+
+export default {
+ name: 'GroupEmptyState',
+ components: {
+ GlEmptyState,
+ },
+ props: {
+ noContainersImage: {
+ type: String,
+ required: true,
+ },
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ noContainerImagesText() {
+ return sprintf(
+ s__(
+ `ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. %{docLinkStart}More Information%{docLinkEnd}`,
+ ),
+ {
+ docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`,
+ docLinkEnd: '</a>',
+ },
+ false,
+ );
+ },
+ },
+};
+</script>
+<template>
+ <gl-empty-state
+ :title="s__('ContainerRegistry|There are no container images available in this group')"
+ :svg-path="noContainersImage"
+ class="container-message"
+ >
+ <template #description>
+ <p class="js-no-container-images-text" v-html="noContainerImagesText"></p>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/registry/components/project_empty_state.vue b/app/assets/javascripts/registry/components/project_empty_state.vue
new file mode 100644
index 00000000000..80ef31004c8
--- /dev/null
+++ b/app/assets/javascripts/registry/components/project_empty_state.vue
@@ -0,0 +1,133 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { s__, sprintf } from '~/locale';
+
+export default {
+ name: 'ProjectEmptyState',
+ components: {
+ ClipboardButton,
+ GlEmptyState,
+ },
+ props: {
+ noContainersImage: {
+ type: String,
+ required: true,
+ },
+ repositoryUrl: {
+ type: String,
+ required: true,
+ },
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+ twoFactorAuthHelpLink: {
+ type: String,
+ required: true,
+ },
+ personalAccessTokensHelpLink: {
+ type: String,
+ required: true,
+ },
+ registryHostUrlWithPort: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ dockerBuildCommand() {
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
+ return `docker build -t ${this.repositoryUrl} .`;
+ },
+ dockerPushCommand() {
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
+ return `docker push ${this.repositoryUrl}`;
+ },
+ dockerLoginCommand() {
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
+ return `docker login ${this.registryHostUrlWithPort}`;
+ },
+ noContainerImagesText() {
+ return sprintf(
+ s__(`ContainerRegistry|With the Container Registry, every project can have its own space to
+ store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`),
+ {
+ docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`,
+ docLinkEnd: '</a>',
+ },
+ false,
+ );
+ },
+ notLoggedInToRegistryText() {
+ return sprintf(
+ s__(`ContainerRegistry|If you are not already logged in, you need to authenticate to
+ the Container Registry by using your GitLab username and password. If you have
+ %{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a
+ %{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd}
+ instead of a password.`),
+ {
+ twofaDocLinkStart: `<a href="${this.twoFactorAuthHelpLink}" target="_blank">`,
+ twofaDocLinkEnd: '</a>',
+ personalAccessTokensDocLinkStart: `<a href="${this.personalAccessTokensHelpLink}" target="_blank">`,
+ personalAccessTokensDocLinkEnd: '</a>',
+ },
+ false,
+ );
+ },
+ },
+};
+</script>
+<template>
+ <gl-empty-state
+ :title="s__('ContainerRegistry|There are no container images stored for this project')"
+ :svg-path="noContainersImage"
+ class="container-message"
+ >
+ <template #description>
+ <p class="js-no-container-images-text" v-html="noContainerImagesText"></p>
+ <h5>{{ s__('ContainerRegistry|Quick Start') }}</h5>
+ <p class="js-not-logged-in-to-registry-text" v-html="notLoggedInToRegistryText"></p>
+ <div class="input-group append-bottom-10">
+ <input :value="dockerLoginCommand" type="text" class="form-control monospace" readonly />
+ <span class="input-group-append">
+ <clipboard-button
+ :text="dockerLoginCommand"
+ :title="s__('ContainerRegistry|Copy login command')"
+ class="input-group-text"
+ />
+ </span>
+ </div>
+ <p></p>
+ <p>
+ {{
+ s__(
+ 'ContainerRegistry|You can add an image to this registry with the following commands:',
+ )
+ }}
+ </p>
+
+ <div class="input-group append-bottom-10">
+ <input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly />
+ <span class="input-group-append">
+ <clipboard-button
+ :text="dockerBuildCommand"
+ :title="s__('ContainerRegistry|Copy build command')"
+ class="input-group-text"
+ />
+ </span>
+ </div>
+
+ <div class="input-group">
+ <input :value="dockerPushCommand" type="text" class="form-control monospace" readonly />
+ <span class="input-group-append">
+ <clipboard-button
+ :text="dockerPushCommand"
+ :title="s__('ContainerRegistry|Copy push command')"
+ class="input-group-text"
+ />
+ </span>
+ </div>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue
index e9067bc2b56..8470fbc2b59 100644
--- a/app/assets/javascripts/registry/components/table_registry.vue
+++ b/app/assets/javascripts/registry/components/table_registry.vue
@@ -1,5 +1,5 @@
<script>
-import { mapActions } from 'vuex';
+import { mapActions, mapGetters } from 'vuex';
import {
GlButton,
GlFormCheckbox,
@@ -35,9 +35,15 @@ export default {
type: Object,
required: true,
},
+ canDeleteRepo: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
},
data() {
return {
+ selectedItems: [],
itemsToBeDeleted: [],
modalId: `confirm-image-deletion-modal-${this.repo.id}`,
selectAllChecked: false,
@@ -45,16 +51,17 @@ export default {
};
},
computed: {
+ ...mapGetters(['isDeleteDisabled']),
bulkDeletePath() {
return this.repo.tagsPath ? this.repo.tagsPath.replace('?format=json', '/bulk_destroy') : '';
},
shouldRenderPagination() {
return this.repo.pagination.total > this.repo.pagination.perPage;
},
- modalTitle() {
+ modalAction() {
return n__(
- 'ContainerRegistry|Remove image',
- 'ContainerRegistry|Remove images',
+ 'ContainerRegistry|Remove tag',
+ 'ContainerRegistry|Remove tags',
this.itemsToBeDeleted.length === 0 ? 1 : this.itemsToBeDeleted.length,
);
},
@@ -67,16 +74,14 @@ export default {
setModalDescription(itemIndex = -1) {
if (itemIndex === -1) {
this.modalDescription = sprintf(
- s__(`ContainerRegistry|You are about to delete <b>%{count}</b> images. This will
- delete the images and all tags pointing to them.`),
+ s__(`ContainerRegistry|You are about to remove <b>%{count}</b> tags. Are you sure?`),
{ count: this.itemsToBeDeleted.length },
);
} else {
const { tag } = this.repo.list[itemIndex];
this.modalDescription = sprintf(
- s__(`ContainerRegistry|You are about to delete the image <b>%{title}</b>. This will
- delete the image and all tags pointing to this image.`),
+ s__(`ContainerRegistry|You are about to remove <b>%{title}</b>. Are you sure?`),
{ title: `${this.repo.name}:${tag}` },
);
}
@@ -92,6 +97,7 @@ export default {
},
deleteSingleItem(index) {
this.setModalDescription(index);
+ this.itemsToBeDeleted = [index];
this.$refs.deleteModal.$refs.modal.$once('ok', () => {
this.removeModalEvents();
@@ -99,9 +105,10 @@ export default {
});
},
deleteMultipleItems() {
- if (this.itemsToBeDeleted.length === 1) {
+ this.itemsToBeDeleted = [...this.selectedItems];
+ if (this.selectedItems.length === 1) {
this.setModalDescription(this.itemsToBeDeleted[0]);
- } else if (this.itemsToBeDeleted.length > 1) {
+ } else if (this.selectedItems.length > 1) {
this.setModalDescription();
}
@@ -111,6 +118,7 @@ export default {
});
},
handleSingleDelete(itemToDelete) {
+ this.itemsToBeDeleted = [];
this.deleteItem(itemToDelete)
.then(() => this.fetchList({ repo: this.repo }))
.catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY));
@@ -118,6 +126,7 @@ export default {
handleMultipleDelete() {
const { itemsToBeDeleted } = this;
this.itemsToBeDeleted = [];
+ this.selectedItems = [];
if (this.bulkDeletePath) {
this.multiDeleteItems({
@@ -146,27 +155,30 @@ export default {
}
},
selectAll() {
- this.itemsToBeDeleted = this.repo.list.map((x, index) => index);
+ this.selectedItems = this.repo.list.map((x, index) => index);
this.selectAllChecked = true;
},
deselectAll() {
- this.itemsToBeDeleted = [];
+ this.selectedItems = [];
this.selectAllChecked = false;
},
- updateItemsToBeDeleted(index) {
- const delIndex = this.itemsToBeDeleted.findIndex(x => x === index);
+ updateselectedItems(index) {
+ const delIndex = this.selectedItems.findIndex(x => x === index);
if (delIndex > -1) {
- this.itemsToBeDeleted.splice(delIndex, 1);
+ this.selectedItems.splice(delIndex, 1);
this.selectAllChecked = false;
} else {
- this.itemsToBeDeleted.push(index);
+ this.selectedItems.push(index);
- if (this.itemsToBeDeleted.length === this.repo.list.length) {
+ if (this.selectedItems.length === this.repo.list.length) {
this.selectAllChecked = true;
}
}
},
+ canDeleteRow(item) {
+ return item && item.canDelete && !this.isDeleteDisabled;
+ },
},
};
</script>
@@ -177,7 +189,7 @@ export default {
<tr>
<th>
<gl-form-checkbox
- v-if="repo.canDelete"
+ v-if="canDeleteRepo"
class="js-select-all-checkbox"
:checked="selectAllChecked"
@change="onSelectAllChange"
@@ -189,17 +201,20 @@ export default {
<th>{{ s__('ContainerRegistry|Last Updated') }}</th>
<th>
<gl-button
- v-if="repo.canDelete"
+ v-if="canDeleteRepo"
v-gl-tooltip
v-gl-modal="modalId"
- :disabled="!itemsToBeDeleted || itemsToBeDeleted.length === 0"
+ :disabled="!selectedItems || selectedItems.length === 0"
class="js-delete-registry float-right"
+ data-track-event="click_button"
+ data-track-label="bulk_registry_tag_delete"
variant="danger"
- :title="s__('ContainerRegistry|Remove selected images')"
- :aria-label="s__('ContainerRegistry|Remove selected images')"
+ :title="s__('ContainerRegistry|Remove selected tags')"
+ :aria-label="s__('ContainerRegistry|Remove selected tags')"
@click="deleteMultipleItems()"
- ><icon name="remove"
- /></gl-button>
+ >
+ <icon name="remove" />
+ </gl-button>
</th>
</tr>
</thead>
@@ -207,10 +222,10 @@ export default {
<tr v-for="(item, index) in repo.list" :key="item.tag" class="registry-image-row">
<td class="check">
<gl-form-checkbox
- v-if="item.canDelete"
+ v-if="canDeleteRow(item)"
class="js-select-checkbox"
- :checked="itemsToBeDeleted && itemsToBeDeleted.includes(index)"
- @change="updateItemsToBeDeleted(index)"
+ :checked="selectedItems && selectedItems.includes(index)"
+ @change="updateselectedItems(index)"
/>
</td>
<td class="monospace">
@@ -223,9 +238,9 @@ export default {
/>
</td>
<td>
- <span v-gl-tooltip.bottom class="monospace" :title="item.revision">
- {{ item.shortRevision }}
- </span>
+ <span v-gl-tooltip.bottom class="monospace" :title="item.revision">{{
+ item.shortRevision
+ }}</span>
</td>
<td>
{{ formatSize(item.size) }}
@@ -236,17 +251,19 @@ export default {
</td>
<td>
- <span v-gl-tooltip.bottom :title="tooltipTitle(item.createdAt)">
- {{ timeFormated(item.createdAt) }}
- </span>
+ <span v-gl-tooltip.bottom :title="tooltipTitle(item.createdAt)">{{
+ timeFormated(item.createdAt)
+ }}</span>
</td>
<td class="content action-buttons">
<gl-button
- v-if="item.canDelete"
+ v-if="canDeleteRow(item)"
v-gl-modal="modalId"
- :title="s__('ContainerRegistry|Remove image')"
- :aria-label="s__('ContainerRegistry|Remove image')"
+ :title="s__('ContainerRegistry|Remove tag')"
+ :aria-label="s__('ContainerRegistry|Remove tag')"
+ data-track-event="click_button"
+ data-track-label="registry_tag_delete"
variant="danger"
class="js-delete-registry-row float-right btn-inverted btn-border-color btn-icon"
@click="deleteSingleItem(index)"
@@ -262,11 +279,12 @@ export default {
v-if="shouldRenderPagination"
:change="onPageChange"
:page-info="repo.pagination"
+ class="js-registry-pagination"
/>
<gl-modal ref="deleteModal" :modal-id="modalId" ok-variant="danger">
- <template v-slot:modal-title>{{ modalTitle }}</template>
- <template v-slot:modal-ok>{{ s__('ContainerRegistry|Remove image(s) and tags') }}</template>
+ <template v-slot:modal-title>{{ modalAction }}</template>
+ <template v-slot:modal-ok>{{ modalAction }}</template>
<p v-html="modalDescription"></p>
</gl-modal>
</div>
diff --git a/app/assets/javascripts/registry/index.js b/app/assets/javascripts/registry/index.js
index d8daec29fda..18fd360f586 100644
--- a/app/assets/javascripts/registry/index.js
+++ b/app/assets/javascripts/registry/index.js
@@ -13,23 +13,24 @@ export default () =>
data() {
const { dataset } = document.querySelector(this.$options.el);
return {
- endpoint: dataset.endpoint,
- characterError: Boolean(dataset.characterError),
- helpPagePath: dataset.helpPagePath,
- noContainersImage: dataset.noContainersImage,
- containersErrorImage: dataset.containersErrorImage,
- repositoryUrl: dataset.repositoryUrl,
+ registryData: {
+ endpoint: dataset.endpoint,
+ characterError: Boolean(dataset.characterError),
+ helpPagePath: dataset.helpPagePath,
+ noContainersImage: dataset.noContainersImage,
+ containersErrorImage: dataset.containersErrorImage,
+ repositoryUrl: dataset.repositoryUrl,
+ isGroupPage: dataset.isGroupPage,
+ personalAccessTokensHelpLink: dataset.personalAccessTokensHelpLink,
+ registryHostUrlWithPort: dataset.registryHostUrlWithPort,
+ twoFactorAuthHelpLink: dataset.twoFactorAuthHelpLink,
+ },
};
},
render(createElement) {
return createElement('registry-app', {
props: {
- endpoint: this.endpoint,
- characterError: this.characterError,
- helpPagePath: this.helpPagePath,
- noContainersImage: this.noContainersImage,
- containersErrorImage: this.containersErrorImage,
- repositoryUrl: this.repositoryUrl,
+ ...this.registryData,
},
});
},
diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/stores/actions.js
index a2e0130e79e..2121f518a7a 100644
--- a/app/assets/javascripts/registry/stores/actions.js
+++ b/app/assets/javascripts/registry/stores/actions.js
@@ -20,7 +20,6 @@ export const fetchRepos = ({ commit, state }) => {
export const fetchList = ({ commit }, { repo, page }) => {
commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo);
-
return axios
.get(repo.tagsPath, { params: { page } })
.then(response => {
@@ -40,6 +39,7 @@ export const multiDeleteItems = (_, { path, items }) =>
axios.delete(path, { params: { ids: items } });
export const setMainEndpoint = ({ commit }, data) => commit(types.SET_MAIN_ENDPOINT, data);
+export const setIsDeleteDisabled = ({ commit }, data) => commit(types.SET_IS_DELETE_DISABLED, data);
export const toggleLoading = ({ commit }) => commit(types.TOGGLE_MAIN_LOADING);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
diff --git a/app/assets/javascripts/registry/stores/getters.js b/app/assets/javascripts/registry/stores/getters.js
index f4923512578..ac90bde1b2a 100644
--- a/app/assets/javascripts/registry/stores/getters.js
+++ b/app/assets/javascripts/registry/stores/getters.js
@@ -1,5 +1,6 @@
export const isLoading = state => state.isLoading;
export const repos = state => state.repos;
+export const isDeleteDisabled = state => state.isDeleteDisabled;
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/registry/stores/mutation_types.js b/app/assets/javascripts/registry/stores/mutation_types.js
index 2c69bf11807..6740bfede1a 100644
--- a/app/assets/javascripts/registry/stores/mutation_types.js
+++ b/app/assets/javascripts/registry/stores/mutation_types.js
@@ -1,4 +1,5 @@
export const SET_MAIN_ENDPOINT = 'SET_MAIN_ENDPOINT';
+export const SET_IS_DELETE_DISABLED = 'SET_IS_DELETE_DISABLED';
export const SET_REPOS_LIST = 'SET_REPOS_LIST';
export const TOGGLE_MAIN_LOADING = 'TOGGLE_MAIN_LOADING';
diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/stores/mutations.js
index 8ace6657ad1..ea5925247d1 100644
--- a/app/assets/javascripts/registry/stores/mutations.js
+++ b/app/assets/javascripts/registry/stores/mutations.js
@@ -6,6 +6,10 @@ export default {
Object.assign(state, { endpoint });
},
+ [types.SET_IS_DELETE_DISABLED](state, isDeleteDisabled) {
+ Object.assign(state, { isDeleteDisabled });
+ },
+
[types.SET_REPOS_LIST](state, list) {
Object.assign(state, {
repos: list.map(el => ({
@@ -17,6 +21,7 @@ export default {
location: el.location,
name: el.path,
tagsPath: el.tags_path,
+ projectId: el.project_id,
})),
});
},
diff --git a/app/assets/javascripts/registry/stores/state.js b/app/assets/javascripts/registry/stores/state.js
index feeac10cbe1..724c64b4994 100644
--- a/app/assets/javascripts/registry/stores/state.js
+++ b/app/assets/javascripts/registry/stores/state.js
@@ -1,6 +1,7 @@
export default () => ({
isLoading: false,
endpoint: '', // initial endpoint to fetch the repos list
+ isDeleteDisabled: false, // controls the delete buttons in the registry
/**
* Each object in `repos` has the following strucure:
* {
diff --git a/app/assets/javascripts/releases/components/milestone_list.vue b/app/assets/javascripts/releases/components/milestone_list.vue
deleted file mode 100644
index 53416f0ab4d..00000000000
--- a/app/assets/javascripts/releases/components/milestone_list.vue
+++ /dev/null
@@ -1,45 +0,0 @@
-<script>
-import { GlLink, GlTooltipDirective } from '@gitlab/ui';
-import Icon from '~/vue_shared/components/icon.vue';
-import { s__ } from '~/locale';
-
-export default {
- name: 'MilestoneList',
- components: {
- GlLink,
- Icon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- milestones: {
- type: Array,
- required: true,
- },
- },
- computed: {
- labelText() {
- return this.milestones.length === 1 ? s__('Milestone') : s__('Milestones');
- },
- },
-};
-</script>
-<template>
- <div>
- <icon name="flag" class="align-middle" /> <span class="js-label-text">{{ labelText }}</span>
- <template v-for="(milestone, index) in milestones">
- <gl-link
- :key="milestone.id"
- v-gl-tooltip
- :title="milestone.description"
- :href="milestone.web_url"
- >
- {{ milestone.title }}
- </gl-link>
- <template v-if="index !== milestones.length - 1">
- &bull;
- </template>
- </template>
- </div>
-</template>
diff --git a/app/assets/javascripts/releases/detail/components/app.vue b/app/assets/javascripts/releases/detail/components/app.vue
new file mode 100644
index 00000000000..54a441de886
--- /dev/null
+++ b/app/assets/javascripts/releases/detail/components/app.vue
@@ -0,0 +1,156 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
+
+export default {
+ name: 'ReleaseDetailApp',
+ components: {
+ GlFormInput,
+ GlFormGroup,
+ GlButton,
+ MarkdownField,
+ },
+ directives: {
+ autofocusonshow,
+ },
+ computed: {
+ ...mapState([
+ 'isFetchingRelease',
+ 'fetchError',
+ 'markdownDocsPath',
+ 'markdownPreviewPath',
+ 'releasesPagePath',
+ ]),
+ showForm() {
+ return !this.isFetchingRelease && !this.fetchError;
+ },
+ subtitleText() {
+ return sprintf(
+ __(
+ 'Releases are based on Git tags. We recommend naming tags that fit within semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}.',
+ ),
+ {
+ codeStart: '<code>',
+ codeEnd: '</code>',
+ },
+ false,
+ );
+ },
+ tagName() {
+ return this.$store.state.release.tagName;
+ },
+ releaseTitle: {
+ get() {
+ return this.$store.state.release.name;
+ },
+ set(title) {
+ this.updateReleaseTitle(title);
+ },
+ },
+ releaseNotes: {
+ get() {
+ return this.$store.state.release.description;
+ },
+ set(notes) {
+ this.updateReleaseNotes(notes);
+ },
+ },
+ },
+ created() {
+ this.fetchRelease();
+ },
+ methods: {
+ ...mapActions([
+ 'fetchRelease',
+ 'updateRelease',
+ 'updateReleaseTitle',
+ 'updateReleaseNotes',
+ 'navigateToReleasesPage',
+ ]),
+ },
+};
+</script>
+<template>
+ <div class="d-flex flex-column">
+ <p class="pt-3 js-subtitle-text" v-html="subtitleText"></p>
+ <form v-if="showForm" @submit.prevent="updateRelease()">
+ <div class="row">
+ <gl-form-group class="col-md-6 col-lg-5 col-xl-4">
+ <label for="git-ref">{{ __('Tag name') }}</label>
+ <gl-form-input
+ id="git-ref"
+ v-model="tagName"
+ type="text"
+ class="form-control"
+ aria-describedby="tag-name-help"
+ disabled
+ />
+ <div id="tag-name-help" class="form-text text-muted">
+ {{ __('Choose an existing tag, or create a new one') }}
+ </div>
+ </gl-form-group>
+ </div>
+ <gl-form-group>
+ <label for="release-title">{{ __('Release title') }}</label>
+ <gl-form-input
+ id="release-title"
+ ref="releaseTitleInput"
+ v-model="releaseTitle"
+ v-autofocusonshow
+ autofocus
+ type="text"
+ class="form-control"
+ />
+ </gl-form-group>
+ <gl-form-group>
+ <label for="release-notes">{{ __('Release notes') }}</label>
+ <div class="bordered-box pr-3 pl-3">
+ <markdown-field
+ :can-attach-file="true"
+ :markdown-preview-path="markdownPreviewPath"
+ :markdown-docs-path="markdownDocsPath"
+ :add-spacing-classes="false"
+ class="prepend-top-10 append-bottom-10"
+ >
+ <textarea
+ id="release-notes"
+ slot="textarea"
+ v-model="releaseNotes"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ dir="auto"
+ data-supports-quick-actions="false"
+ :aria-label="__('Release notes')"
+ :placeholder="__('Write your release notes or drag your files here…')"
+ @keydown.meta.enter="updateRelease()"
+ @keydown.ctrl.enter="updateRelease()"
+ >
+ </textarea>
+ </markdown-field>
+ </div>
+ </gl-form-group>
+
+ <div class="d-flex pt-3">
+ <gl-button
+ class="mr-auto js-submit-button"
+ variant="success"
+ type="submit"
+ :aria-label="__('Save changes')"
+ >
+ {{ __('Save changes') }}
+ </gl-button>
+ <gl-button
+ class="js-cancel-button"
+ variant="default"
+ type="button"
+ :aria-label="__('Cancel')"
+ @click="navigateToReleasesPage()"
+ >
+ {{ __('Cancel') }}
+ </gl-button>
+ </div>
+ </form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/releases/detail/index.js b/app/assets/javascripts/releases/detail/index.js
new file mode 100644
index 00000000000..3da971e6d90
--- /dev/null
+++ b/app/assets/javascripts/releases/detail/index.js
@@ -0,0 +1,19 @@
+import Vue from 'vue';
+import ReleaseDetailApp from './components/app.vue';
+import createStore from './store';
+
+export default () => {
+ const el = document.getElementById('js-edit-release-page');
+
+ const store = createStore(el.dataset);
+ store.dispatch('setInitialState', el.dataset);
+
+ return new Vue({
+ el,
+ store,
+ components: { ReleaseDetailApp },
+ render(createElement) {
+ return createElement('release-detail-app');
+ },
+ });
+};
diff --git a/app/assets/javascripts/releases/detail/store/actions.js b/app/assets/javascripts/releases/detail/store/actions.js
new file mode 100644
index 00000000000..c9749582f5c
--- /dev/null
+++ b/app/assets/javascripts/releases/detail/store/actions.js
@@ -0,0 +1,62 @@
+import * as types from './mutation_types';
+import api from '~/api';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import { redirectTo } from '~/lib/utils/url_utility';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+export const setInitialState = ({ commit }, initialState) =>
+ commit(types.SET_INITIAL_STATE, initialState);
+
+export const requestRelease = ({ commit }) => commit(types.REQUEST_RELEASE);
+export const receiveReleaseSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_RELEASE_SUCCESS, data);
+export const receiveReleaseError = ({ commit }, error) => {
+ commit(types.RECEIVE_RELEASE_ERROR, error);
+ createFlash(s__('Release|Something went wrong while getting the release details'));
+};
+
+export const fetchRelease = ({ dispatch, state }) => {
+ dispatch('requestRelease');
+
+ return api
+ .release(state.projectId, state.tagName)
+ .then(({ data: release }) => {
+ const camelCasedRelease = convertObjectPropsToCamelCase(release, { deep: true });
+ dispatch('receiveReleaseSuccess', camelCasedRelease);
+ })
+ .catch(error => {
+ dispatch('receiveReleaseError', error);
+ });
+};
+
+export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title);
+export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes);
+
+export const requestUpdateRelease = ({ commit }) => commit(types.REQUEST_UPDATE_RELEASE);
+export const receiveUpdateReleaseSuccess = ({ commit, dispatch }) => {
+ commit(types.RECEIVE_UPDATE_RELEASE_SUCCESS);
+ dispatch('navigateToReleasesPage');
+};
+export const receiveUpdateReleaseError = ({ commit }, error) => {
+ commit(types.RECEIVE_UPDATE_RELEASE_ERROR, error);
+ createFlash(s__('Release|Something went wrong while saving the release details'));
+};
+
+export const updateRelease = ({ dispatch, state }) => {
+ dispatch('requestUpdateRelease');
+
+ return api
+ .updateRelease(state.projectId, state.tagName, {
+ name: state.release.name,
+ description: state.release.description,
+ })
+ .then(() => dispatch('receiveUpdateReleaseSuccess'))
+ .catch(error => {
+ dispatch('receiveUpdateReleaseError', error);
+ });
+};
+
+export const navigateToReleasesPage = ({ state }) => {
+ redirectTo(state.releasesPagePath);
+};
diff --git a/app/assets/javascripts/releases/detail/store/index.js b/app/assets/javascripts/releases/detail/store/index.js
new file mode 100644
index 00000000000..e8623a49356
--- /dev/null
+++ b/app/assets/javascripts/releases/detail/store/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import mutations from './mutations';
+import state from './state';
+
+Vue.use(Vuex);
+
+export default () =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ state,
+ });
diff --git a/app/assets/javascripts/releases/detail/store/mutation_types.js b/app/assets/javascripts/releases/detail/store/mutation_types.js
new file mode 100644
index 00000000000..75e1d78a645
--- /dev/null
+++ b/app/assets/javascripts/releases/detail/store/mutation_types.js
@@ -0,0 +1,12 @@
+export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
+
+export const REQUEST_RELEASE = 'REQUEST_RELEASE';
+export const RECEIVE_RELEASE_SUCCESS = 'RECEIVE_RELEASE_SUCCESS';
+export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR';
+
+export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE';
+export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES';
+
+export const REQUEST_UPDATE_RELEASE = 'REQUEST_UPDATE_RELEASE';
+export const RECEIVE_UPDATE_RELEASE_SUCCESS = 'RECEIVE_UPDATE_RELEASE_SUCCESS';
+export const RECEIVE_UPDATE_RELEASE_ERROR = 'RECEIVE_UPDATE_RELEASE_ERROR';
diff --git a/app/assets/javascripts/releases/detail/store/mutations.js b/app/assets/javascripts/releases/detail/store/mutations.js
new file mode 100644
index 00000000000..d739978d755
--- /dev/null
+++ b/app/assets/javascripts/releases/detail/store/mutations.js
@@ -0,0 +1,42 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_INITIAL_STATE](state, initialState) {
+ Object.keys(state).forEach(key => {
+ state[key] = initialState[key];
+ });
+ },
+
+ [types.REQUEST_RELEASE](state) {
+ state.isFetchingRelease = true;
+ },
+ [types.RECEIVE_RELEASE_SUCCESS](state, data) {
+ state.fetchError = undefined;
+ state.isFetchingRelease = false;
+ state.release = data;
+ },
+ [types.RECEIVE_RELEASE_ERROR](state, error) {
+ state.fetchError = error;
+ state.isFetchingRelease = false;
+ state.release = undefined;
+ },
+
+ [types.UPDATE_RELEASE_TITLE](state, title) {
+ state.release.name = title;
+ },
+ [types.UPDATE_RELEASE_NOTES](state, notes) {
+ state.release.description = notes;
+ },
+
+ [types.REQUEST_UPDATE_RELEASE](state) {
+ state.isUpdatingRelease = true;
+ },
+ [types.RECEIVE_UPDATE_RELEASE_SUCCESS](state) {
+ state.updateError = undefined;
+ state.isUpdatingRelease = false;
+ },
+ [types.RECEIVE_UPDATE_RELEASE_ERROR](state, error) {
+ state.updateError = error;
+ state.isUpdatingRelease = false;
+ },
+};
diff --git a/app/assets/javascripts/releases/detail/store/state.js b/app/assets/javascripts/releases/detail/store/state.js
new file mode 100644
index 00000000000..ff98e2bed78
--- /dev/null
+++ b/app/assets/javascripts/releases/detail/store/state.js
@@ -0,0 +1,15 @@
+export default () => ({
+ projectId: null,
+ tagName: null,
+ releasesPagePath: null,
+ markdownDocsPath: null,
+ markdownPreviewPath: null,
+
+ release: null,
+
+ isFetchingRelease: false,
+ fetchError: null,
+
+ isUpdatingRelease: false,
+ updateError: null,
+});
diff --git a/app/assets/javascripts/releases/components/app.vue b/app/assets/javascripts/releases/list/components/app.vue
index 5a06c4fec58..5a06c4fec58 100644
--- a/app/assets/javascripts/releases/components/app.vue
+++ b/app/assets/javascripts/releases/list/components/app.vue
diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/list/components/release_block.vue
index 2dacd8549ad..8d4b32e9dc0 100644
--- a/app/assets/javascripts/releases/components/release_block.vue
+++ b/app/assets/javascripts/releases/list/components/release_block.vue
@@ -1,26 +1,29 @@
<script>
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import _ from 'underscore';
-import { GlTooltipDirective, GlLink, GlBadge } from '@gitlab/ui';
+import { GlTooltipDirective, GlLink, GlBadge, GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
-import MilestoneList from './milestone_list.vue';
-import { __, sprintf } from '../../locale';
+import { __, n__, sprintf } from '~/locale';
+import { slugify } from '~/lib/utils/text_utility';
+import { getLocationHash } from '~/lib/utils/url_utility';
+import { scrollToElement } from '~/lib/utils/common_utils';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'ReleaseBlock',
components: {
GlLink,
GlBadge,
+ GlButton,
Icon,
UserAvatarLink,
- MilestoneList,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [timeagoMixin],
+ mixins: [timeagoMixin, glFeatureFlagsMixin()],
props: {
release: {
type: Object,
@@ -28,7 +31,15 @@ export default {
default: () => ({}),
},
},
+ data() {
+ return {
+ isHighlighted: false,
+ };
+ },
computed: {
+ id() {
+ return slugify(this.release.tag_name);
+ },
releasedTimeAgo() {
return sprintf(__('released %{time}'), {
time: this.timeFormated(this.release.released_at),
@@ -42,6 +53,12 @@ export default {
commit() {
return this.release.commit || {};
},
+ commitUrl() {
+ return this.release.commit_path;
+ },
+ tagUrl() {
+ return this.release.tag_path;
+ },
assets() {
return this.release.assets || {};
},
@@ -51,49 +68,90 @@ export default {
hasAuthor() {
return !_.isEmpty(this.author);
},
- milestones() {
- // At the moment, a release can only be associated to
- // one milestone. This will be expanded to be many-to-many
- // in the near future, so we pass the milestone as an
- // array here in anticipation of this change.
- return [this.release.milestone];
- },
shouldRenderMilestones() {
- // Similar to the `milestones` computed above,
- // this check will need to be updated once
- // the API begins sending an array of milestones
- // instead of just a single object.
- return Boolean(this.release.milestone);
+ return !_.isEmpty(this.release.milestones);
+ },
+ labelText() {
+ return n__('Milestone', 'Milestones', this.release.milestones.length);
+ },
+ shouldShowEditButton() {
+ return Boolean(
+ this.glFeatures.releaseEditPage && this.release._links && this.release._links.edit,
+ );
},
},
+ mounted() {
+ const hash = getLocationHash();
+ if (hash && slugify(hash) === this.id) {
+ this.isHighlighted = true;
+ setTimeout(() => {
+ this.isHighlighted = false;
+ }, 2000);
+
+ scrollToElement(this.$el);
+ }
+ },
};
</script>
<template>
- <div :id="release.tag_name" class="card">
+ <div :id="id" :class="{ 'bg-line-target-blue': isHighlighted }" class="card release-block">
<div class="card-body">
- <h2 class="card-title mt-0">
- {{ release.name }}
- <gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{
- __('Upcoming Release')
- }}</gl-badge>
- </h2>
+ <div class="d-flex align-items-start">
+ <h2 class="card-title mt-0 mr-auto">
+ {{ release.name }}
+ <gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{
+ __('Upcoming Release')
+ }}</gl-badge>
+ </h2>
+ <gl-link
+ v-if="shouldShowEditButton"
+ v-gl-tooltip
+ class="btn btn-default js-edit-button ml-2"
+ :title="__('Edit this release')"
+ :href="release._links.edit"
+ >
+ <icon name="pencil" />
+ </gl-link>
+ </div>
<div class="card-subtitle d-flex flex-wrap text-secondary">
<div class="append-right-8">
<icon name="commit" class="align-middle" />
- <span v-gl-tooltip.bottom :title="commit.title">{{ commit.short_id }}</span>
+ <gl-link v-if="commitUrl" v-gl-tooltip.bottom :title="commit.title" :href="commitUrl">
+ {{ commit.short_id }}
+ </gl-link>
+ <span v-else v-gl-tooltip.bottom :title="commit.title">{{ commit.short_id }}</span>
</div>
<div class="append-right-8">
<icon name="tag" class="align-middle" />
- <span v-gl-tooltip.bottom :title="__('Tag')">{{ release.tag_name }}</span>
+ <gl-link v-if="tagUrl" v-gl-tooltip.bottom :title="__('Tag')" :href="tagUrl">
+ {{ release.tag_name }}
+ </gl-link>
+ <span v-else v-gl-tooltip.bottom :title="__('Tag')">{{ release.tag_name }}</span>
</div>
- <milestone-list
- v-if="shouldRenderMilestones"
- class="append-right-4 js-milestone-list"
- :milestones="milestones"
- />
+ <template v-if="shouldRenderMilestones">
+ <div class="js-milestone-list-label">
+ <icon name="flag" class="align-middle" />
+ <span class="js-label-text">{{ labelText }}</span>
+ </div>
+
+ <template v-for="(milestone, index) in release.milestones">
+ <gl-link
+ :key="milestone.id"
+ v-gl-tooltip
+ :title="milestone.description"
+ :href="milestone.web_url"
+ class="append-right-4 prepend-left-4 js-milestone-link"
+ >
+ {{ milestone.title }}
+ </gl-link>
+ <template v-if="index !== release.milestones.length - 1">
+ &bull;
+ </template>
+ </template>
+ </template>
<div class="append-right-4">
&bull;
diff --git a/app/assets/javascripts/releases/index.js b/app/assets/javascripts/releases/list/index.js
index adbed3cb8e2..adbed3cb8e2 100644
--- a/app/assets/javascripts/releases/index.js
+++ b/app/assets/javascripts/releases/list/index.js
diff --git a/app/assets/javascripts/releases/store/actions.js b/app/assets/javascripts/releases/list/store/actions.js
index e0a922d5ef6..e0a922d5ef6 100644
--- a/app/assets/javascripts/releases/store/actions.js
+++ b/app/assets/javascripts/releases/list/store/actions.js
diff --git a/app/assets/javascripts/releases/store/index.js b/app/assets/javascripts/releases/list/store/index.js
index 968b94f0e0d..968b94f0e0d 100644
--- a/app/assets/javascripts/releases/store/index.js
+++ b/app/assets/javascripts/releases/list/store/index.js
diff --git a/app/assets/javascripts/releases/store/mutation_types.js b/app/assets/javascripts/releases/list/store/mutation_types.js
index a74bf15c515..a74bf15c515 100644
--- a/app/assets/javascripts/releases/store/mutation_types.js
+++ b/app/assets/javascripts/releases/list/store/mutation_types.js
diff --git a/app/assets/javascripts/releases/store/mutations.js b/app/assets/javascripts/releases/list/store/mutations.js
index b97dc6cb0ab..b97dc6cb0ab 100644
--- a/app/assets/javascripts/releases/store/mutations.js
+++ b/app/assets/javascripts/releases/list/store/mutations.js
diff --git a/app/assets/javascripts/releases/store/state.js b/app/assets/javascripts/releases/list/store/state.js
index bf25e651c99..bf25e651c99 100644
--- a/app/assets/javascripts/releases/store/state.js
+++ b/app/assets/javascripts/releases/list/store/state.js
diff --git a/app/assets/javascripts/reports/components/modal.vue b/app/assets/javascripts/reports/components/modal.vue
index cb9c1642608..6019af2dfe0 100644
--- a/app/assets/javascripts/reports/components/modal.vue
+++ b/app/assets/javascripts/reports/components/modal.vue
@@ -1,13 +1,13 @@
<script>
// import { sprintf, __ } from '~/locale';
-import Modal from '~/vue_shared/components/gl_modal.vue';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import CodeBlock from '~/vue_shared/components/code_block.vue';
import { fieldTypes } from '../constants';
export default {
components: {
- Modal,
+ Modal: DeprecatedModal2,
LoadingButton,
CodeBlock,
},
diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue
index 24612c8681a..45c890769a0 100644
--- a/app/assets/javascripts/reports/components/report_section.vue
+++ b/app/assets/javascripts/reports/components/report_section.vue
@@ -179,7 +179,7 @@ export default {
<button
v-if="isCollapsible"
type="button"
- class="js-collapse-btn btn float-right btn-sm qa-expand-report-button"
+ class="js-collapse-btn btn float-right btn-sm align-self-start qa-expand-report-button"
@click="toggleCollapsed"
>
{{ collapseText }}
diff --git a/app/assets/javascripts/reports/store/utils.js b/app/assets/javascripts/reports/store/utils.js
index 10560d0ae8e..7381f038eaf 100644
--- a/app/assets/javascripts/reports/store/utils.js
+++ b/app/assets/javascripts/reports/store/utils.js
@@ -11,7 +11,7 @@ const textBuilder = results => {
const { failed, resolved, total } = results;
const failedString = failed
- ? n__('%d failed test result', '%d failed test results', failed)
+ ? n__('%d failed/error test result', '%d failed/error test results', failed)
: null;
const resolvedString = resolved
? n__('%d fixed test result', '%d fixed test results', resolved)
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index e2060d4aeec..19a2db2db25 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { GlTooltipDirective, GlLink, GlButton, GlLoadingIcon } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import Icon from '../../vue_shared/components/icon.vue';
@@ -113,7 +112,7 @@ export default {
>
{{ commit.author.name }}
</gl-link>
- authored
+ {{ s__('LastCommit|authored') }}
<timeago-tooltip :time="commit.authoredDate" tooltip-placement="bottom" />
</div>
<pre
@@ -125,6 +124,7 @@ export default {
</pre>
</div>
<div class="commit-actions flex-row">
+ <div v-if="commit.signatureHtml" v-html="commit.signatureHtml"></div>
<gl-link
v-if="commit.latestPipeline"
v-gl-tooltip
@@ -144,7 +144,7 @@ export default {
</div>
<clipboard-button
:text="commit.sha"
- :title="__('Copy commit SHA to clipboard')"
+ :title="__('Copy commit SHA')"
tooltip-placement="bottom"
/>
</div>
diff --git a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
index 3bdfd979fa4..71c1bf12749 100644
--- a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
+++ b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
@@ -13,6 +13,7 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) {
avatarUrl
webUrl
}
+ signatureHtml
latestPipeline {
detailedStatus {
detailsPath
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 0cc7a22325b..87454ee056f 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, consistent-return, one-var, prefer-template, no-else-return, no-param-reassign */
+/* eslint-disable func-names, no-var, consistent-return, one-var, no-else-return, no-param-reassign */
import $ from 'jquery';
import _ from 'underscore';
@@ -247,7 +247,7 @@ Sidebar.prototype.isOpen = function() {
};
Sidebar.prototype.getBlock = function(name) {
- return this.sidebar.find('.block.' + name);
+ return this.sidebar.find(`.block.${name}`);
};
export default Sidebar;
diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
index 2f37dcec197..f6722ff7bca 100644
--- a/app/assets/javascripts/search_autocomplete.js
+++ b/app/assets/javascripts/search_autocomplete.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-return-assign, one-var, no-var, consistent-return, prefer-template, class-methods-use-this, no-lonely-if, vars-on-top */
+/* eslint-disable no-return-assign, one-var, no-var, consistent-return, class-methods-use-this, no-lonely-if, vars-on-top */
import $ from 'jquery';
import { escape, throttle } from 'underscore';
@@ -416,7 +416,7 @@ export class SearchAutocomplete {
inputs = Object.keys(this.originalState);
for (i = 0, len = inputs.length; i < len; i += 1) {
input = inputs[i];
- this.getElement('#' + input).val(this.originalState[input]);
+ this.getElement(`#${input}`).val(this.originalState[input]);
}
}
@@ -426,7 +426,7 @@ export class SearchAutocomplete {
results = [];
for (i = 0, len = inputs.length; i < len; i += 1) {
input = inputs[i];
- results.push(this.getElement('#' + input).val(''));
+ results.push(this.getElement(`#${input}`).val(''));
}
return results;
}
diff --git a/app/assets/javascripts/serverless/components/url.vue b/app/assets/javascripts/serverless/components/url.vue
index e47a03f1939..5e30c8d614e 100644
--- a/app/assets/javascripts/serverless/components/url.vue
+++ b/app/assets/javascripts/serverless/components/url.vue
@@ -23,7 +23,7 @@ export default {
<div class="url-text-field label label-monospace monospace">{{ uri }}</div>
<clipboard-button
:text="uri"
- :title="s__('ServerlessURL|Copy URL to clipboard')"
+ :title="s__('ServerlessURL|Copy URL')"
class="input-group-text js-clipboard-btn"
/>
<gl-button
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 35eba266625..df950e79690 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
@@ -163,7 +163,7 @@ export default {
:ok-title="s__('SetStatusModal|Set status')"
:cancel-title="s__('SetStatusModal|Remove status')"
ok-variant="success"
- class="set-user-status-modal"
+ modal-class="set-user-status-modal"
@shown="setupEmojiListAndAutocomplete"
@hide="hideEmojiMenu"
@ok="setStatus"
@@ -194,9 +194,9 @@ export default {
v-show="noEmoji"
class="js-no-emoji-placeholder no-emoji-placeholder position-relative"
>
- <icon name="slight-smile" css-classes="award-control-icon-neutral" />
- <icon name="smiley" css-classes="award-control-icon-positive" />
- <icon name="smile" css-classes="award-control-icon-super-positive" />
+ <icon name="slight-smile" class="award-control-icon-neutral" />
+ <icon name="smiley" class="award-control-icon-positive" />
+ <icon name="smile" class="award-control-icon-super-positive" />
</span>
</button>
</span>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
index 71a1fc31315..052bb3dcb53 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
@@ -42,6 +42,7 @@ export default {
:width="imgSize"
:class="`s${imgSize}`"
class="avatar avatar-inline m-0"
+ data-qa-selector="avatar_image"
/>
<i v-if="hasMergeIcon" aria-hidden="true" class="fa fa-exclamation-triangle merge-icon"></i>
</span>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
index 63b93a80ead..b107e9789a7 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
@@ -1,6 +1,5 @@
<script>
import { n__ } from '~/locale';
-import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar';
export default {
name: 'AssigneeTitle',
@@ -30,22 +29,20 @@ export default {
return n__('Assignee', `%d Assignees`, assignees);
},
},
- methods: {
- trackEdit() {
- trackEvent('click_edit_button', 'assignee');
- },
- },
};
</script>
<template>
- <div class="title hide-collapsed">
+ <div class="title hide-collapsed" data-qa-selector="assignee_title">
{{ assigneeTitle }}
<i v-if="loading" aria-hidden="true" class="fa fa-spinner fa-spin block-loading"></i>
<a
v-if="editable"
class="js-sidebar-dropdown-toggle edit-link float-right"
href="#"
- @click.prevent="trackEdit"
+ data-qa-selector="assignee_edit_link"
+ data-track-event="click_edit_button"
+ data-track-label="right_sidebar"
+ data-track-property="assignee"
>
{{ __('Edit') }}
</a>
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 3a4623121f4..3d112bba668 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -85,7 +85,12 @@ export default {
</div>
</div>
<div v-if="renderShowMoreSection" class="user-list-more">
- <button type="button" class="btn-link" @click="toggleShowLess">
+ <button
+ type="button"
+ class="btn-link"
+ data-qa-selector="more_assignees_link"
+ @click="toggleShowLess"
+ >
<template v-if="showLess">
{{ hiddenAssigneesLabel }}
</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
index 1c75b6148e8..5b3c3642290 100644
--- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
@@ -5,7 +5,7 @@ import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '~/sidebar/event_hub';
import editForm from './edit_form.vue';
-import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar';
+import recaptchaModalImplementor from '~/vue_shared/mixins/recaptcha_modal_implementor';
export default {
components: {
@@ -15,6 +15,7 @@ export default {
directives: {
tooltip,
},
+ mixins: [recaptchaModalImplementor],
props: {
isConfidential: {
required: true,
@@ -52,17 +53,17 @@ export default {
toggleForm() {
this.edit = !this.edit;
},
- onEditClick() {
- this.toggleForm();
-
- trackEvent('click_edit_button', 'confidentiality');
- },
updateConfidentialAttribute(confidential) {
this.service
.update('issue', { confidential })
+ .then(({ data }) => this.checkForSpam(data))
.then(() => window.location.reload())
- .catch(() => {
- Flash(__('Something went wrong trying to change the confidentiality of this issue'));
+ .catch(error => {
+ if (error.name === 'SpamError') {
+ this.openRecaptcha();
+ } else {
+ Flash(__('Something went wrong trying to change the confidentiality of this issue'));
+ }
});
},
},
@@ -72,6 +73,7 @@ export default {
<template>
<div class="block issuable-sidebar-item confidentiality">
<div
+ ref="collapseIcon"
v-tooltip
:title="tooltipLabel"
class="sidebar-collapsed-icon"
@@ -86,9 +88,13 @@ export default {
{{ __('Confidentiality') }}
<a
v-if="isEditable"
+ ref="editLink"
class="float-right confidential-edit"
href="#"
- @click.prevent="onEditClick"
+ data-track-event="click_edit_button"
+ data-track-label="right_sidebar"
+ data-track-property="confidentiality"
+ @click.prevent="toggleForm"
>
{{ __('Edit') }}
</a>
@@ -113,5 +119,7 @@ export default {
{{ __('This issue is confidential') }}
</div>
</div>
+
+ <recaptcha-modal v-if="showRecaptcha" :html="recaptchaHTML" @close="closeRecaptcha" />
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue
index ec2a7b93a98..c7c5e0e20f1 100644
--- a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue
+++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue
@@ -6,7 +6,6 @@ import issuableMixin from '~/vue_shared/mixins/issuable';
import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '~/sidebar/event_hub';
import editForm from './edit_form.vue';
-import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar';
export default {
components: {
@@ -66,11 +65,6 @@ export default {
toggleForm() {
this.mediator.store.isLockDialogOpen = !this.mediator.store.isLockDialogOpen;
},
- onEditClick() {
- this.toggleForm();
-
- trackEvent('click_edit_button', 'lock_issue');
- },
updateLockedAttribute(locked) {
this.mediator.service
.update(this.issuableType, {
@@ -114,7 +108,10 @@ export default {
v-if="isEditable"
class="float-right lock-edit"
type="button"
- @click.prevent="onEditClick"
+ data-track-event="click_edit_button"
+ data-track-label="right_sidebar"
+ data-track-property="lock_issue"
+ @click.prevent="toggleForm"
>
{{ __('Edit') }}
</button>
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
index 1f5f19d1931..ea5edb3ce3f 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
@@ -1,10 +1,10 @@
<script>
import { __ } from '~/locale';
+import Tracking from '~/tracking';
import icon from '~/vue_shared/components/icon.vue';
import toggleButton from '~/vue_shared/components/toggle_button.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import eventHub from '../../event_hub';
-import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar';
const ICON_ON = 'notifications';
const ICON_OFF = 'notifications-off';
@@ -19,6 +19,7 @@ export default {
icon,
toggleButton,
},
+ mixins: [Tracking.mixin({ label: 'right_sidebar' })],
props: {
loading: {
type: Boolean,
@@ -65,7 +66,10 @@ export default {
// Component event emission.
this.$emit('toggleSubscription', this.id);
- trackEvent('toggle_button', 'notifications', this.subscribed ? 0 : 1);
+ this.track('toggle_button', {
+ property: 'notifications',
+ value: this.subscribed ? 0 : 1,
+ });
},
onClickCollapsedIcon() {
this.$emit('toggleSidebar');
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
index 24d5b14ded9..65ecd5be05d 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
@@ -1,6 +1,5 @@
<script>
import { __, sprintf } from '~/locale';
-import { abbreviateTime } from '~/lib/utils/datetime_utility';
import icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
@@ -41,12 +40,6 @@ export default {
},
},
computed: {
- timeSpent() {
- return this.abbreviateTime(this.timeSpentHumanReadable);
- },
- timeEstimate() {
- return this.abbreviateTime(this.timeEstimateHumanReadable);
- },
divClass() {
if (this.showComparisonState) {
return 'compare';
@@ -73,11 +66,11 @@ export default {
},
text() {
if (this.showComparisonState) {
- return `${this.timeSpent} / ${this.timeEstimate}`;
+ return `${this.timeSpentHumanReadable} / ${this.timeEstimateHumanReadable}`;
} else if (this.showEstimateOnlyState) {
- return `-- / ${this.timeEstimate}`;
+ return `-- / ${this.timeEstimateHumanReadable}`;
} else if (this.showSpentOnlyState) {
- return `${this.timeSpent} / --`;
+ return `${this.timeSpentHumanReadable} / --`;
} else if (this.showNoTimeTrackingState) {
return __('None');
}
@@ -100,11 +93,6 @@ export default {
return this.showNoTimeTrackingState ? __('Time tracking') : this.timeTrackedTooltipText;
},
},
- methods: {
- abbreviateTime(timeStr) {
- return abbreviateTime(timeStr);
- },
- },
};
</script>
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
index e6f2fe2b5fc..3d96405896d 100644
--- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
@@ -82,11 +82,7 @@ export default {
data-boundary="viewport"
@click="handleButtonClick"
>
- <icon
- v-show="collapsed"
- :css-classes="collapsedButtonIconClasses"
- :name="collapsedButtonIcon"
- />
+ <icon v-show="collapsed" :class="collapsedButtonIconClasses" :name="collapsedButtonIcon" />
<span v-show="!collapsed" class="issuable-todo-inner"> {{ buttonLabel }} </span>
<gl-loading-icon v-show="isActionActive" :inline="true" />
</button>
diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
index 110175a6779..66d1fed7d31 100644
--- a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
+++ b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import '~/gl_dropdown';
import _ from 'underscore';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index b70e384fae5..de4a7f89449 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, prefer-arrow-callback, consistent-return, */
+/* eslint-disable consistent-return */
import $ from 'jquery';
import { __ } from './locale';
@@ -40,12 +40,9 @@ export default class SingleFileDiff {
this.$toggleIcon.addClass('fa-caret-down');
}
- $('.js-file-title, .click-to-expand', this.file).on(
- 'click',
- function(e) {
- this.toggleDiff($(e.target));
- }.bind(this),
- );
+ $('.js-file-title, .click-to-expand', this.file).on('click', e => {
+ this.toggleDiff($(e.target));
+ });
}
toggleDiff($target, cb) {
diff --git a/app/assets/javascripts/snippet/snippet_embed.js b/app/assets/javascripts/snippet/snippet_embed.js
index fe08d2c7ebb..6606271c4fa 100644
--- a/app/assets/javascripts/snippet/snippet_embed.js
+++ b/app/assets/javascripts/snippet/snippet_embed.js
@@ -1,25 +1,30 @@
import { __ } from '~/locale';
export default () => {
- const { protocol, host, pathname } = window.location;
const shareBtn = document.querySelector('.js-share-btn');
- const embedBtn = document.querySelector('.js-embed-btn');
- const snippetUrlArea = document.querySelector('.js-snippet-url-area');
- const embedAction = document.querySelector('.js-embed-action');
- const url = `${protocol}//${host + pathname}`;
- shareBtn.addEventListener('click', () => {
- shareBtn.classList.add('is-active');
- embedBtn.classList.remove('is-active');
- snippetUrlArea.value = url;
- embedAction.innerText = __('Share');
- });
+ if (shareBtn) {
+ const { protocol, host, pathname } = window.location;
- embedBtn.addEventListener('click', () => {
- embedBtn.classList.add('is-active');
- shareBtn.classList.remove('is-active');
- const scriptTag = `<script src="${url}.js"></script>`;
- snippetUrlArea.value = scriptTag;
- embedAction.innerText = __('Embed');
- });
+ const embedBtn = document.querySelector('.js-embed-btn');
+
+ const snippetUrlArea = document.querySelector('.js-snippet-url-area');
+ const embedAction = document.querySelector('.js-embed-action');
+ const url = `${protocol}//${host + pathname}`;
+
+ shareBtn.addEventListener('click', () => {
+ shareBtn.classList.add('is-active');
+ embedBtn.classList.remove('is-active');
+ snippetUrlArea.value = url;
+ embedAction.innerText = __('Share');
+ });
+
+ embedBtn.addEventListener('click', () => {
+ embedBtn.classList.add('is-active');
+ shareBtn.classList.remove('is-active');
+ const scriptTag = `<script src="${url}.js"></script>`;
+ snippetUrlArea.value = scriptTag;
+ embedAction.innerText = __('Embed');
+ });
+ }
};
diff --git a/app/assets/javascripts/templates/issuable_template_selector.js b/app/assets/javascripts/templates/issuable_template_selector.js
index 78609ce0610..10ad4170930 100644
--- a/app/assets/javascripts/templates/issuable_template_selector.js
+++ b/app/assets/javascripts/templates/issuable_template_selector.js
@@ -8,10 +8,13 @@ import { __ } from '~/locale';
export default class IssuableTemplateSelector extends TemplateSelector {
constructor(...args) {
super(...args);
+
this.projectPath = this.dropdown.data('projectPath');
this.namespacePath = this.dropdown.data('namespacePath');
this.issuableType = this.$dropdownContainer.data('issuableType');
this.titleInput = $(`#${this.issuableType}_title`);
+ this.templateWarningEl = $('.js-issuable-template-warning');
+ this.warnTemplateOverride = args[0].warnTemplateOverride;
const initialQuery = {
name: this.dropdown.data('selected'),
@@ -24,14 +27,62 @@ export default class IssuableTemplateSelector extends TemplateSelector {
});
$('.no-template', this.dropdown.parent()).on('click', () => {
- this.currentTemplate.content = '';
- this.setInputValueToTemplateContent();
- $('.dropdown-toggle-text', this.dropdown).text(__('Choose a template'));
+ this.reset();
+ });
+
+ this.templateWarningEl.find('.js-close-btn').on('click', () => {
+ // Explicitly check against 0 value
+ if (this.previousSelectedIndex !== undefined) {
+ this.dropdown.data('glDropdown').selectRowAtIndex(this.previousSelectedIndex);
+ } else {
+ this.reset();
+ }
+
+ this.templateWarningEl.addClass('hidden');
});
+
+ this.templateWarningEl.find('.js-override-template').on('click', () => {
+ this.requestFile(this.overridingTemplate);
+ this.setSelectedIndex();
+
+ this.templateWarningEl.addClass('hidden');
+ this.overridingTemplate = null;
+ });
+ }
+
+ reset() {
+ if (this.currentTemplate) {
+ this.currentTemplate.content = '';
+ }
+
+ this.setInputValueToTemplateContent();
+ $('.dropdown-toggle-text', this.dropdown).text(__('Choose a template'));
+ this.previousSelectedIndex = null;
+ }
+
+ setSelectedIndex() {
+ this.previousSelectedIndex = this.dropdown.data('glDropdown').selectedIndex;
+ }
+
+ onDropdownClicked(query) {
+ const content = this.getEditorContent();
+ const isContentUnchanged =
+ content === '' || (this.currentTemplate && content === this.currentTemplate.content);
+
+ if (!this.warnTemplateOverride || isContentUnchanged) {
+ super.onDropdownClicked(query);
+ this.setSelectedIndex();
+
+ return;
+ }
+
+ this.overridingTemplate = query.selectedObj;
+ this.templateWarningEl.removeClass('hidden');
}
requestFile(query) {
this.startLoadingSpinner();
+
Api.issueTemplate(
this.namespacePath,
this.projectPath,
@@ -59,6 +110,7 @@ export default class IssuableTemplateSelector extends TemplateSelector {
} else {
this.setEditorContent(this.currentTemplate, { skipFocus: false });
}
+
return;
}
}
diff --git a/app/assets/javascripts/templates/issuable_template_selectors.js b/app/assets/javascripts/templates/issuable_template_selectors.js
index 50e58ec5c46..443b3084113 100644
--- a/app/assets/javascripts/templates/issuable_template_selectors.js
+++ b/app/assets/javascripts/templates/issuable_template_selectors.js
@@ -4,7 +4,7 @@ import $ from 'jquery';
import IssuableTemplateSelector from './issuable_template_selector';
export default class IssuableTemplateSelectors {
- constructor({ $dropdowns, editor } = {}) {
+ constructor({ $dropdowns, editor, warnTemplateOverride } = {}) {
this.$dropdowns = $dropdowns || $('.js-issuable-selector');
this.editor = editor || this.initEditor();
@@ -16,6 +16,7 @@ export default class IssuableTemplateSelectors {
wrapper: $dropdown.closest('.js-issuable-selector-wrap'),
dropdown: $dropdown,
editor: this.editor,
+ warnTemplateOverride,
});
});
}
diff --git a/app/assets/javascripts/test_utils/index.js b/app/assets/javascripts/test_utils/index.js
index 1e75ee60671..1a1f3e8d0a8 100644
--- a/app/assets/javascripts/test_utils/index.js
+++ b/app/assets/javascripts/test_utils/index.js
@@ -1,8 +1,10 @@
import 'core-js/es/map';
import 'core-js/es/set';
+import { Sortable } from 'sortablejs';
import simulateDrag from './simulate_drag';
import simulateInput from './simulate_input';
// Export to global space for rspec to use
window.simulateDrag = simulateDrag;
window.simulateInput = simulateInput;
+window.Sortable = Sortable;
diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js
index 1b4ca1d5741..7c0097fbe37 100644
--- a/app/assets/javascripts/tracking.js
+++ b/app/assets/javascripts/tracking.js
@@ -1,4 +1,4 @@
-import $ from 'jquery';
+import _ from 'underscore';
const DEFAULT_SNOWPLOW_OPTIONS = {
namespace: 'gl',
@@ -14,18 +14,31 @@ const DEFAULT_SNOWPLOW_OPTIONS = {
linkClickTracking: false,
};
-const extractData = (el, opts = {}) => {
- const { trackEvent, trackLabel = '', trackProperty = '' } = el.dataset;
- let trackValue = el.dataset.trackValue || el.value || '';
- if (el.type === 'checkbox' && !el.checked) trackValue = false;
- return [
- trackEvent + (opts.suffix || ''),
- {
- label: trackLabel,
- property: trackProperty,
- value: trackValue,
- },
- ];
+const eventHandler = (e, func, opts = {}) => {
+ const el = e.target.closest('[data-track-event]');
+ const action = el && el.dataset.trackEvent;
+ if (!action) return;
+
+ let value = el.dataset.trackValue || el.value || undefined;
+ if (el.type === 'checkbox' && !el.checked) value = false;
+
+ const data = {
+ label: el.dataset.trackLabel,
+ property: el.dataset.trackProperty,
+ value,
+ context: el.dataset.trackContext,
+ };
+
+ func(opts.category, action + (opts.suffix || ''), _.omit(data, _.isUndefined));
+};
+
+const eventHandlers = (category, func) => {
+ const handler = opts => e => eventHandler(e, func, { ...{ category }, ...opts });
+ const handlers = [];
+ handlers.push({ name: 'click', func: handler() });
+ handlers.push({ name: 'show.bs.dropdown', func: handler({ suffix: '_show' }) });
+ handlers.push({ name: 'hide.bs.dropdown', func: handler({ suffix: '_hide' }) });
+ return handlers;
};
export default class Tracking {
@@ -39,49 +52,43 @@ export default class Tracking {
return typeof window.snowplow === 'function' && this.trackable();
}
- static event(category = document.body.dataset.page, event = 'generic', data = {}) {
+ static event(category = document.body.dataset.page, action = 'generic', data = {}) {
if (!this.enabled()) return false;
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
if (!category) throw new Error('Tracking: no category provided for tracking.');
- return window.snowplow(
- 'trackStructEvent',
- category,
- event,
- Object.assign({}, { label: '', property: '', value: '' }, data),
- );
+ const { label, property, value, context } = data;
+ const contexts = context ? [context] : undefined;
+ return window.snowplow('trackStructEvent', category, action, label, property, value, contexts);
}
- constructor(category = document.body.dataset.page) {
- this.category = category;
- }
-
- bind(container = document) {
- if (!this.constructor.enabled()) return;
- container.querySelectorAll(`[data-track-event]`).forEach(el => {
- if (this.customHandlingFor(el)) return;
- // jquery is required for select2, so we use it always
- // see: https://github.com/select2/select2/issues/4686
- $(el).on('click', this.eventHandler(this.category));
- });
- }
+ static bindDocument(category = document.body.dataset.page, documentOverride = null) {
+ const el = documentOverride || document;
+ if (!this.enabled() || el.trackingBound) return [];
- customHandlingFor(el) {
- const classes = el.classList;
+ el.trackingBound = true;
- // bootstrap dropdowns
- if (classes.contains('dropdown')) {
- $(el).on('show.bs.dropdown', this.eventHandler(this.category, { suffix: '_show' }));
- $(el).on('hide.bs.dropdown', this.eventHandler(this.category, { suffix: '_hide' }));
- return true;
- }
-
- return false;
+ const handlers = eventHandlers(category, (...args) => this.event(...args));
+ handlers.forEach(event => el.addEventListener(event.name, event.func));
+ return handlers;
}
- eventHandler(category = null, opts = {}) {
- return e => {
- this.constructor.event(category || this.category, ...extractData(e.currentTarget, opts));
+ static mixin(opts) {
+ return {
+ data() {
+ return {
+ tracking: {
+ // eslint-disable-next-line no-underscore-dangle
+ category: this.$options.name || this.$options._componentTag,
+ },
+ };
+ },
+ methods: {
+ track(action, data) {
+ const category = opts.category || data.category || this.tracking.category;
+ Tracking.event(category || 'unspecified', action, { ...opts, ...this.tracking, ...data });
+ },
+ },
};
}
}
@@ -89,7 +96,7 @@ export default class Tracking {
export function initUserTracking() {
if (!Tracking.enabled()) return;
- const opts = Object.assign({}, DEFAULT_SNOWPLOW_OPTIONS, window.snowplowOptions);
+ const opts = { ...DEFAULT_SNOWPLOW_OPTIONS, ...window.snowplowOptions };
window.snowplow('newTracker', opts.namespace, opts.hostname, opts);
window.snowplow('enableActivityTracking', 30, 30);
@@ -97,4 +104,6 @@ export function initUserTracking() {
if (opts.formTracking) window.snowplow('enableFormTracking');
if (opts.linkClickTracking) window.snowplow('enableLinkClickTracking');
+
+ Tracking.bindDocument();
}
diff --git a/app/assets/javascripts/tree.js b/app/assets/javascripts/tree.js
index 3e659c9e7ea..69b3d20914a 100644
--- a/app/assets/javascripts/tree.js
+++ b/app/assets/javascripts/tree.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, consistent-return, no-var, one-var, no-else-return, prefer-arrow-callback, class-methods-use-this */
+/* eslint-disable func-names, consistent-return, no-var, one-var, no-else-return, class-methods-use-this */
import $ from 'jquery';
import { visitUrl } from './lib/utils/url_utility';
@@ -29,7 +29,7 @@ export default class TreeView {
var li, liSelected;
li = $('tr.tree-item');
liSelected = null;
- return $('body').keydown(function(e) {
+ return $('body').keydown(e => {
var next, path;
if ($('input:focus').length > 0 && (e.which === 38 || e.which === 40)) {
return false;
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index 948f4d5e631..c0b7587be10 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -63,7 +63,7 @@ const handleUserPopoverMouseOver = event => {
UsersCache.retrieveById(userId)
.then(userData => {
if (!userData) {
- return;
+ return undefined;
}
Object.assign(user, {
@@ -76,19 +76,16 @@ const handleUserPopoverMouseOver = event => {
loaded: true,
});
- UsersCache.retrieveStatusById(userId)
- .then(status => {
- if (!status) {
- return;
- }
+ return UsersCache.retrieveStatusById(userId);
+ })
+ .then(status => {
+ if (!status) {
+ return;
+ }
- Object.assign(user, {
- status,
- });
- })
- .catch(() => {
- throw new Error(`User status for "${userId}" could not be retrieved!`);
- });
+ Object.assign(user, {
+ status,
+ });
})
.catch(() => {
renderedPopover.$destroy();
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index e78ca56be0e..da1a7c290f8 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, one-var, no-var, prefer-rest-params, vars-on-top, prefer-arrow-callback, consistent-return, no-shadow, no-else-return, no-self-compare, prefer-template, no-unused-expressions, yoda, prefer-spread, camelcase, no-param-reassign */
+/* eslint-disable func-names, one-var, no-var, prefer-rest-params, vars-on-top, consistent-return, no-shadow, no-else-return, no-self-compare, no-unused-expressions, yoda, prefer-spread, camelcase, no-param-reassign */
/* global Issuable */
/* global emitSidebarEvent */
@@ -7,6 +7,7 @@ import _ from 'underscore';
import axios from './lib/utils/axios_utils';
import { s__, __, sprintf } from './locale';
import ModalStore from './boards/stores/modal_store';
+import { parseBoolean } from './lib/utils/common_utils';
// TODO: remove eventHub hack after code splitting refactor
window.emitSidebarEvent = window.emitSidebarEvent || $.noop;
@@ -250,16 +251,12 @@ function UsersSelect(currentUser, els, options = {}) {
return $dropdown.glDropdown({
showMenuAbove,
data(term, callback) {
- return _this.users(
- term,
- options,
- function(users) {
- // GitLabDropdownFilter returns this.instance
- // GitLabDropdownRemote returns this.options.instance
- const glDropdown = this.instance || this.options.instance;
- glDropdown.options.processData(term, users, callback);
- }.bind(this),
- );
+ return _this.users(term, options, users => {
+ // GitLabDropdownFilter returns this.instance
+ // GitLabDropdownRemote returns this.options.instance
+ const glDropdown = this.instance || this.options.instance;
+ glDropdown.options.processData(term, users, callback);
+ });
},
processData(term, data, callback) {
let users = data;
@@ -279,12 +276,13 @@ function UsersSelect(currentUser, els, options = {}) {
})
.map(input => {
const userId = parseInt(input.value, 10);
- const { avatarUrl, avatar_url, name, username } = input.dataset;
+ const { avatarUrl, avatar_url, name, username, canMerge } = input.dataset;
return {
avatar_url: avatarUrl || avatar_url,
id: userId,
name,
username,
+ can_merge: parseBoolean(canMerge),
};
});
@@ -432,8 +430,7 @@ function UsersSelect(currentUser, els, options = {}) {
const isActive = $el.hasClass('is-active');
const previouslySelected = $dropdown
.closest('.selectbox')
- /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
- .find("input[name='" + $dropdown.data('fieldName') + "'][value!=0]");
+ .find(`input[name='${$dropdown.data('fieldName')}'][value!=0]`);
// Enables support for limiting the number of users selected
// Automatically removes the first on the list if more users are selected
@@ -452,7 +449,7 @@ function UsersSelect(currentUser, els, options = {}) {
// Remove unassigned selection (if it was previously selected)
const unassignedSelected = $dropdown
.closest('.selectbox')
- .find("input[name='" + $dropdown.data('fieldName') + "'][value=0]");
+ .find(`input[name='${$dropdown.data('fieldName')}'][value=0]`);
if (unassignedSelected) {
unassignedSelected.remove();
@@ -506,7 +503,7 @@ function UsersSelect(currentUser, els, options = {}) {
} else if (!$dropdown.hasClass('js-multiselect')) {
selected = $dropdown
.closest('.selectbox')
- .find("input[name='" + $dropdown.data('fieldName') + "']")
+ .find(`input[name='${$dropdown.data('fieldName')}']`)
.val();
return assignTo(selected);
}
@@ -548,7 +545,7 @@ function UsersSelect(currentUser, els, options = {}) {
updateLabel: $dropdown.data('dropdownTitle'),
renderRow(user) {
var avatar, img, username;
- username = user.username ? '@' + user.username : '';
+ username = user.username ? `@${user.username}` : '';
avatar = user.avatar_url ? user.avatar_url : gon.default_avatar_url;
let selected = false;
@@ -559,7 +556,7 @@ function UsersSelect(currentUser, els, options = {}) {
const { fieldName } = this;
const field = $dropdown
.closest('.selectbox')
- .find("input[name='" + fieldName + "'][value='" + user.id + "']");
+ .find(`input[name='${fieldName}'][value='${user.id}']`);
if (field.length) {
selected = true;
@@ -575,7 +572,7 @@ function UsersSelect(currentUser, els, options = {}) {
)}</a></li>`;
} else {
// 0 margin, because it's now handled by a wrapper
- img = "<img src='" + avatar + "' class='avatar avatar-inline m-0' width='32' />";
+ img = `<img src='${avatar}' class='avatar avatar-inline m-0' width='32' />`;
}
return _this.renderRow(options.issuableType, user, selected, username, img);
@@ -606,7 +603,7 @@ function UsersSelect(currentUser, els, options = {}) {
multiple: $(select).hasClass('multiselect'),
minimumInputLength: 0,
query(query) {
- return _this.users(query.term, options, function(users) {
+ return _this.users(query.term, options, users => {
var anyUser, data, emailUser, index, len, name, nullUser, obj, ref;
data = {
results: users,
@@ -719,7 +716,7 @@ UsersSelect.prototype.formatResult = function(user) {
${_.escape(user.name)}
</div>
<div class='user-username dropdown-menu-user-username text-secondary'>
- ${!user.invite ? '@' + _.escape(user.username) : ''}
+ ${!user.invite ? `@${_.escape(user.username)}` : ''}
</div>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list.vue b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list.vue
new file mode 100644
index 00000000000..dc766176617
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list.vue
@@ -0,0 +1,40 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlLink,
+ },
+ props: {
+ artifacts: {
+ type: Array,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <table class="table m-0">
+ <thead class="thead-white text-nowrap">
+ <tr class="d-none d-sm-table-row">
+ <th class="w-0"></th>
+ <th>{{ __('Artifact') }}</th>
+ <th class="w-50"></th>
+ <th>{{ __('Job') }}</th>
+ </tr>
+ </thead>
+
+ <tbody>
+ <tr v-for="item in artifacts" :key="item.text">
+ <td class="w-0"></td>
+ <td>
+ <gl-link :href="item.url" target="_blank">{{ item.text }}</gl-link>
+ </td>
+ <td class="w-0"></td>
+ <td>
+ <gl-link :href="item.job_path">{{ item.job_name }}</gl-link>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue
new file mode 100644
index 00000000000..730e67761be
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/artifacts_list_app.vue
@@ -0,0 +1,36 @@
+<script>
+import { mapActions, mapState, mapGetters } from 'vuex';
+import ArtifactsList from './artifacts_list.vue';
+import MrCollapsibleExtension from './mr_collapsible_extension.vue';
+import createStore from '../stores/artifacts_list';
+
+export default {
+ store: createStore(),
+ components: {
+ ArtifactsList,
+ MrCollapsibleExtension,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['artifacts', 'isLoading', 'hasError']),
+ ...mapGetters(['title']),
+ },
+ created() {
+ this.setEndpoint(this.endpoint);
+ this.fetchArtifacts();
+ },
+ methods: {
+ ...mapActions(['setEndpoint', 'fetchArtifacts']),
+ },
+};
+</script>
+<template>
+ <mr-collapsible-extension :title="title" :is-loading="isLoading" :has-error="hasError">
+ <artifacts-list :artifacts="artifacts" />
+ </mr-collapsible-extension>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
index bb6921225c2..1873e09c370 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
@@ -211,7 +211,7 @@ export default {
<template v-else>
<review-app-link
:link="deploymentExternalUrl"
- css-class="js-deploy-url js-deploy-url-feature-flag deploy-link btn btn-default btn-sm inline"
+ css-class="js-deploy-url deploy-link btn btn-default btn-sm inline"
/>
</template>
<visual-review-app-link
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
new file mode 100644
index 00000000000..36f291e995c
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
@@ -0,0 +1,88 @@
+<script>
+import { GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ GlButton,
+ GlLink,
+ GlLoadingIcon,
+ Icon,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ hasError: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ isCollapsed: true,
+ };
+ },
+
+ computed: {
+ arrowIconName() {
+ return this.isCollapsed ? 'angle-right' : 'angle-down';
+ },
+ ariaLabel() {
+ return this.isCollapsed ? __('Expand') : __('Collapse');
+ },
+ },
+ methods: {
+ toggleCollapsed() {
+ this.isCollapsed = !this.isCollapsed;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div class="mr-widget-extension d-flex align-items-center pl-3">
+ <div v-if="hasError" class="ci-widget media">
+ <div class="media-body">
+ <span class="gl-font-size-small mr-widget-margin-left gl-line-height-24 js-error-state">{{
+ title
+ }}</span>
+ </div>
+ </div>
+
+ <template v-else>
+ <gl-button
+ class="btn-blank btn s32 square append-right-default"
+ :aria-label="ariaLabel"
+ :disabled="isLoading"
+ @click="toggleCollapsed"
+ >
+ <gl-loading-icon v-if="isLoading" />
+ <icon v-else :name="arrowIconName" class="js-icon" />
+ </gl-button>
+ <gl-button
+ variant="link"
+ class="js-title"
+ :disabled="isLoading"
+ :class="{ 'border-0': isLoading }"
+ @click="toggleCollapsed"
+ >
+ <template v-if="isCollapsed">{{ title }}</template>
+ <template v-else>{{ __('Collapse') }}</template>
+ </gl-button>
+ </template>
+ </div>
+
+ <div v-if="!isCollapsed" class="border-top js-slot-container">
+ <slot></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
index fb826be19f5..2aaba6e1c8a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
@@ -90,7 +90,7 @@ export default {
v-html="mr.sourceBranchLink"
/><clipboard-button
:text="branchNameClipboardData"
- :title="__('Copy branch name to clipboard')"
+ :title="__('Copy branch name')"
css-class="btn-default btn-transparent btn-clipboard"
/>
{{ s__('mrWidget|into') }}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index 52acd1de666..7c5f35579b8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -110,9 +110,15 @@ export default {
<div class="ci-widget-container d-flex">
<div class="ci-widget-content">
<div class="media-body">
- <div class="font-weight-bold js-pipeline-info-container">
+ <div
+ class="font-weight-bold js-pipeline-info-container"
+ data-qa-selector="merge_request_pipeline_info_content"
+ >
{{ pipeline.details.name }}
- <gl-link :href="pipeline.path" class="pipeline-id font-weight-normal pipeline-number"
+ <gl-link
+ :href="pipeline.path"
+ class="pipeline-id font-weight-normal pipeline-number"
+ data-qa-selector="pipeline_link"
>#{{ pipeline.id }}</gl-link
>
{{ pipeline.details.status.label }}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
index 8fdf61a6b8d..ffc3e0967d4 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
@@ -1,5 +1,6 @@
<script>
import _ from 'underscore';
+import ArtifactsApp from './artifacts_list_app.vue';
import Deployment from './deployment.vue';
import MrWidgetContainer from './mr_widget_container.vue';
import MrWidgetPipeline from './mr_widget_pipeline.vue';
@@ -15,6 +16,7 @@ import MrWidgetPipeline from './mr_widget_pipeline.vue';
export default {
name: 'MrWidgetPipelineContainer',
components: {
+ ArtifactsApp,
Deployment,
MrWidgetContainer,
MrWidgetPipeline,
@@ -79,6 +81,9 @@ export default {
:troubleshooting-docs-path="mr.troubleshootingDocsPath"
/>
<template v-slot:footer>
+ <div v-if="mr.exposedArtifactsPath" class="js-exposed-artifacts">
+ <artifacts-app :endpoint="mr.exposedArtifactsPath" />
+ </div>
<div v-if="deployments.length" class="mr-widget-extension">
<deployment
v-for="deployment in deployments"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
index 457a71cab95..75f557d05dd 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
@@ -19,6 +19,6 @@ export default {
</script>
<template>
<a :href="link" target="_blank" rel="noopener noreferrer nofollow" :class="cssClass">
- {{ __('View app') }} <icon css-classes="fgray" name="external-link" />
+ {{ __('View app') }} <icon class="fgray" name="external-link" />
</a>
</template>
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 fb07c03e34d..a2b5a79af36 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
@@ -170,7 +170,7 @@ export default {
>
</a>
<clipboard-button
- :title="__('Copy commit SHA to clipboard')"
+ :title="__('Copy commit SHA')"
:text="mr.mergeCommitSha"
css-class="btn-default btn-transparent btn-clipboard js-mr-merged-copy-sha"
/>
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 91c0b40a0b5..8132b1a944b 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
@@ -1,5 +1,7 @@
<script>
import $ from 'jquery';
+import { __ } from '~/locale';
+import createFlash from '~/flash';
import statusIcon from '../mr_widget_status_icon.vue';
import tooltip from '../../../vue_shared/directives/tooltip';
import eventHub from '../../event_hub';
@@ -29,12 +31,12 @@ export default {
.then(res => res.data)
.then(data => {
eventHub.$emit('UpdateWidgetData', data);
- new window.Flash('The merge request can now be merged.', 'notice'); // eslint-disable-line
+ createFlash(__('The merge request can now be merged.'), 'notice');
$('.merge-request .detail-page-description .title').text(this.mr.title);
})
.catch(() => {
this.isMakingRequest = false;
- new window.Flash('Something went wrong. Please try again.'); // eslint-disable-line
+ createFlash(__('Something went wrong. Please try again.'));
});
},
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js
new file mode 100644
index 00000000000..3648db795f5
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js
@@ -0,0 +1,74 @@
+import Visibility from 'visibilityjs';
+import axios from '~/lib/utils/axios_utils';
+import Poll from '~/lib/utils/poll';
+import httpStatusCodes from '~/lib/utils/http_status';
+
+import * as types from './mutation_types';
+
+export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint);
+
+export const requestArtifacts = ({ commit }) => commit(types.REQUEST_ARTIFACTS);
+
+let eTagPoll;
+
+export const clearEtagPoll = () => {
+ eTagPoll = null;
+};
+
+export const stopPolling = () => {
+ if (eTagPoll) eTagPoll.stop();
+};
+
+export const restartPolling = () => {
+ if (eTagPoll) eTagPoll.restart();
+};
+
+export const fetchArtifacts = ({ state, dispatch }) => {
+ dispatch('requestArtifacts');
+
+ eTagPoll = new Poll({
+ resource: {
+ getArtifacts(endpoint) {
+ return axios.get(endpoint);
+ },
+ },
+ data: state.endpoint,
+ method: 'getArtifacts',
+ successCallback: ({ data, status }) => {
+ dispatch('receiveArtifactsSuccess', {
+ data,
+ status,
+ });
+ },
+ errorCallback: () => dispatch('receiveArtifactsError'),
+ });
+
+ if (!Visibility.hidden()) {
+ eTagPoll.makeRequest();
+ } else {
+ axios
+ .get(state.endpoint)
+ .then(({ data, status }) => dispatch('receiveArtifactsSuccess', { data, status }))
+ .catch(() => dispatch('receiveArtifactsError'));
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ dispatch('restartPolling');
+ } else {
+ dispatch('stopPolling');
+ }
+ });
+};
+
+export const receiveArtifactsSuccess = ({ commit }, response) => {
+ // With 204 we keep polling and don't update the state
+ if (response.status === httpStatusCodes.OK) {
+ commit(types.RECEIVE_ARTIFACTS_SUCCESS, response.data);
+ }
+};
+
+export const receiveArtifactsError = ({ commit }) => commit(types.RECEIVE_ARTIFACTS_ERROR);
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/getters.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/getters.js
new file mode 100644
index 00000000000..8921637b93b
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/getters.js
@@ -0,0 +1,16 @@
+import { s__, n__ } from '~/locale';
+
+export const title = state => {
+ if (state.isLoading) {
+ return s__('BuildArtifacts|Loading artifacts');
+ }
+
+ if (state.hasError) {
+ return s__('BuildArtifacts|An error occurred while fetching the artifacts');
+ }
+
+ return n__('View exposed artifact', 'View %d exposed artifacts', state.artifacts.length);
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/index.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/index.js
new file mode 100644
index 00000000000..f8abbc99f0f
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/index.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import mutations from './mutations';
+import * as getters from './getters';
+import state from './state';
+
+Vue.use(Vuex);
+
+export default () =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ getters,
+ state: state(),
+ });
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutation_types.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutation_types.js
new file mode 100644
index 00000000000..282faf6f8a4
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutation_types.js
@@ -0,0 +1,5 @@
+export const SET_ENDPOINT = 'SET_ENDPOINT';
+
+export const REQUEST_ARTIFACTS = 'REQUEST_ARTIFACTS';
+export const RECEIVE_ARTIFACTS_SUCCESS = 'RECEIVE_ARTIFACTS_SUCCESS';
+export const RECEIVE_ARTIFACTS_ERROR = 'RECEIVE_ARTIFACTS_ERROR';
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutations.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutations.js
new file mode 100644
index 00000000000..95a091f1bd6
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/mutations.js
@@ -0,0 +1,22 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_ENDPOINT](state, endpoint) {
+ state.endpoint = endpoint;
+ },
+ [types.REQUEST_ARTIFACTS](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_ARTIFACTS_SUCCESS](state, response) {
+ state.hasError = false;
+ state.isLoading = false;
+
+ state.artifacts = response;
+ },
+ [types.RECEIVE_ARTIFACTS_ERROR](state) {
+ state.isLoading = false;
+ state.hasError = true;
+
+ state.artifacts = [];
+ },
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/state.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/state.js
new file mode 100644
index 00000000000..92dad171b1b
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/state.js
@@ -0,0 +1,8 @@
+export default () => ({
+ endpoint: null,
+
+ isLoading: false,
+ hasError: false,
+
+ artifacts: [],
+});
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 699d41494bf..f51d0fa4f52 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
@@ -100,6 +100,7 @@ export default class MergeRequestStore {
this.isPipelineBlocked = pipelineStatus ? pipelineStatus.group === 'manual' : false;
this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null;
this.testResultsPath = data.test_reports_path;
+ this.exposedArtifactsPath = data.exposed_artifacts_path;
this.cancelAutoMergePath = data.cancel_auto_merge_path;
this.canCancelAutomaticMerge = Boolean(data.cancel_auto_merge_path);
@@ -168,6 +169,7 @@ export default class MergeRequestStore {
this.mergeRequestPipelinesHelpPath = data.merge_request_pipelines_docs_path;
this.conflictsDocsPath = data.conflicts_docs_path;
this.ciEnvironmentsStatusPath = data.ci_environments_status_path;
+ this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path;
}
get isNothingToMergeState() {
diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
index a97538d813a..75c3c544c77 100644
--- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
@@ -70,7 +70,13 @@ export default {
return undefined;
},
showIcon() {
- return this.file.changed || this.file.tempFile || this.file.staged || this.file.deleted;
+ return (
+ this.file.changed ||
+ this.file.tempFile ||
+ this.file.staged ||
+ this.file.deleted ||
+ this.file.prevPath
+ );
},
},
};
@@ -83,7 +89,7 @@ export default {
:class="{ 'ml-auto': isCentered }"
class="file-changed-icon d-inline-block"
>
- <icon v-if="showIcon" :name="changedIcon" :size="size" :css-classes="changedIconClass" />
+ <icon v-if="showIcon" :name="changedIcon" :size="size" :class="changedIconClass" />
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue
index 5d373e179b2..162cfc02959 100644
--- a/app/assets/javascripts/vue_shared/components/ci_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -66,5 +66,5 @@ export default {
};
</script>
<template>
- <span :class="cssClass"> <icon :name="icon" :size="size" :css-classes="cssClasses" /> </span>
+ <span :class="cssClass"> <icon :name="icon" :size="size" :class="cssClasses" /> </span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
index a620f560b52..9f498037185 100644
--- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue
+++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
@@ -7,7 +7,7 @@
*
* @example
* <clipboard-button
- * title="Copy to clipboard"
+ * title="Copy"
* text="Content to be copied"
* css-class="btn-transparent"
* />
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
index c6d61d6ee62..fe1a2a092ad 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue
@@ -40,7 +40,7 @@ export default {
</template>
</p>
<gl-link :href="path" class="btn btn-default" rel="nofollow" download target="_blank">
- <icon :size="16" name="download" css-classes="float-left append-right-8" />
+ <icon :size="16" name="download" class="float-left append-right-8" />
{{ __('Download') }}
</gl-link>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
index d5558d93219..3f55f43edbb 100644
--- a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
@@ -132,6 +132,7 @@ export default {
type="button"
class="btn js-primary-button"
data-dismiss="modal"
+ data-qa-selector="save_changes_button"
@click="emitSubmit($event)"
>
{{ primaryButtonLabel }}
diff --git a/app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue b/app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue
new file mode 100644
index 00000000000..543547b37fe
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/deprecated_modal_2.vue
@@ -0,0 +1,118 @@
+<script>
+import $ from 'jquery';
+
+const buttonVariants = ['danger', 'primary', 'success', 'warning'];
+const sizeVariants = ['sm', 'md', 'lg', 'xl'];
+
+export default {
+ name: 'DeprecatedModal2', // use GlModal instead
+
+ props: {
+ id: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ modalSize: {
+ type: String,
+ required: false,
+ default: 'md',
+ validator: value => sizeVariants.includes(value),
+ },
+ headerTitleText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ footerPrimaryButtonVariant: {
+ type: String,
+ required: false,
+ default: 'primary',
+ validator: value => buttonVariants.includes(value),
+ },
+ footerPrimaryButtonText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ modalSizeClass() {
+ return this.modalSize === 'md' ? '' : `modal-${this.modalSize}`;
+ },
+ },
+ mounted() {
+ $(this.$el)
+ .on('shown.bs.modal', this.opened)
+ .on('hidden.bs.modal', this.closed);
+ },
+ beforeDestroy() {
+ $(this.$el)
+ .off('shown.bs.modal', this.opened)
+ .off('hidden.bs.modal', this.closed);
+ },
+ methods: {
+ emitCancel(event) {
+ this.$emit('cancel', event);
+ },
+ emitSubmit(event) {
+ this.$emit('submit', event);
+ },
+ opened() {
+ this.$emit('open');
+ },
+ closed() {
+ this.$emit('closed');
+ },
+ },
+};
+</script>
+
+<template>
+ <div :id="id" class="modal fade" tabindex="-1" role="dialog">
+ <div :class="modalSizeClass" class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <slot name="header">
+ <h4 class="modal-title">
+ <slot name="title"> {{ headerTitleText }} </slot>
+ </h4>
+ <button
+ :aria-label="s__('Modal|Close')"
+ type="button"
+ class="close js-modal-close-action"
+ data-dismiss="modal"
+ @click="emitCancel($event)"
+ >
+ <span aria-hidden="true">&times;</span>
+ </button>
+ </slot>
+ </div>
+
+ <div class="modal-body"><slot></slot></div>
+
+ <div class="modal-footer">
+ <slot name="footer">
+ <button
+ type="button"
+ class="btn js-modal-cancel-action qa-modal-cancel-button"
+ data-dismiss="modal"
+ @click="emitCancel($event)"
+ >
+ {{ s__('Modal|Cancel') }}
+ </button>
+ <button
+ :class="`btn-${footerPrimaryButtonVariant}`"
+ type="button"
+ class="btn js-modal-primary-action qa-modal-primary-button"
+ data-dismiss="modal"
+ @click="emitSubmit($event)"
+ >
+ {{ footerPrimaryButtonText }}
+ </button>
+ </slot>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue
index b69ecc1dce6..952ffa1fa0e 100644
--- a/app/assets/javascripts/vue_shared/components/file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/file_icon.vue
@@ -75,7 +75,7 @@ export default {
<svg v-if="!loading && !folder" :class="[iconSizeClass, cssClasses]">
<use v-bind="{ 'xlink:href': spriteHref }" />
</svg>
- <icon v-if="!loading && folder" :name="folderIconName" :size="size" css-classes="folder-icon" />
+ <icon v-if="!loading && folder" :name="folderIconName" :size="size" class="folder-icon" />
<gl-loading-icon v-if="loading" :inline="true" />
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index f49e69c473b..341c9534763 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -131,7 +131,7 @@ export default {
</script>
<template>
- <div v-if="!file.moved">
+ <div>
<file-header v-if="file.isHeader" :path="file.path" />
<div
v-else
diff --git a/app/assets/javascripts/vue_shared/components/gl_modal.vue b/app/assets/javascripts/vue_shared/components/gl_modal.vue
index 438851e5ac7..4b91d4c00e3 100644
--- a/app/assets/javascripts/vue_shared/components/gl_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_modal.vue
@@ -1,117 +1,6 @@
<script>
-import $ from 'jquery';
+// This file was only introduced to not break master and shall be delete soon.
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
-const buttonVariants = ['danger', 'primary', 'success', 'warning'];
-const sizeVariants = ['sm', 'md', 'lg', 'xl'];
-
-export default {
- name: 'GlModal',
- props: {
- id: {
- type: String,
- required: false,
- default: null,
- },
- modalSize: {
- type: String,
- required: false,
- default: 'md',
- validator: value => sizeVariants.includes(value),
- },
- headerTitleText: {
- type: String,
- required: false,
- default: '',
- },
- footerPrimaryButtonVariant: {
- type: String,
- required: false,
- default: 'primary',
- validator: value => buttonVariants.includes(value),
- },
- footerPrimaryButtonText: {
- type: String,
- required: false,
- default: '',
- },
- },
- computed: {
- modalSizeClass() {
- return this.modalSize === 'md' ? '' : `modal-${this.modalSize}`;
- },
- },
- mounted() {
- $(this.$el)
- .on('shown.bs.modal', this.opened)
- .on('hidden.bs.modal', this.closed);
- },
- beforeDestroy() {
- $(this.$el)
- .off('shown.bs.modal', this.opened)
- .off('hidden.bs.modal', this.closed);
- },
- methods: {
- emitCancel(event) {
- this.$emit('cancel', event);
- },
- emitSubmit(event) {
- this.$emit('submit', event);
- },
- opened() {
- this.$emit('open');
- },
- closed() {
- this.$emit('closed');
- },
- },
-};
+export default DeprecatedModal2;
</script>
-
-<template>
- <div :id="id" class="modal fade" tabindex="-1" role="dialog">
- <div :class="modalSizeClass" class="modal-dialog" role="document">
- <div class="modal-content">
- <div class="modal-header">
- <slot name="header">
- <h4 class="modal-title">
- <slot name="title"> {{ headerTitleText }} </slot>
- </h4>
- <button
- :aria-label="s__('Modal|Close')"
- type="button"
- class="close js-modal-close-action"
- data-dismiss="modal"
- @click="emitCancel($event)"
- >
- <span aria-hidden="true">&times;</span>
- </button>
- </slot>
- </div>
-
- <div class="modal-body"><slot></slot></div>
-
- <div class="modal-footer">
- <slot name="footer">
- <button
- type="button"
- class="btn js-modal-cancel-action qa-modal-cancel-button"
- data-dismiss="modal"
- @click="emitCancel($event)"
- >
- {{ s__('Modal|Cancel') }}
- </button>
- <button
- :class="`btn-${footerPrimaryButtonVariant}`"
- type="button"
- class="btn js-modal-primary-action qa-modal-primary-button"
- data-dismiss="modal"
- @click="emitSubmit($event)"
- >
- {{ footerPrimaryButtonText }}
- </button>
- </slot>
- </div>
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue
index fa89473da62..73f4dfef062 100644
--- a/app/assets/javascripts/vue_shared/components/icon.vue
+++ b/app/assets/javascripts/vue_shared/components/icon.vue
@@ -27,7 +27,7 @@ if (process.env.NODE_ENV !== 'production') {
* <icon
* name="retry"
* :size="32"
- * css-classes="top"
+ * class="top"
* />
*/
export default {
@@ -42,45 +42,7 @@ export default {
type: Number,
required: false,
default: 16,
- validator(value) {
- return validSizes.includes(value);
- },
- },
-
- cssClasses: {
- type: String,
- required: false,
- default: '',
- },
-
- width: {
- type: Number,
- required: false,
- default: null,
- },
-
- height: {
- type: Number,
- required: false,
- default: null,
- },
-
- y: {
- type: Number,
- required: false,
- default: null,
- },
-
- x: {
- type: Number,
- required: false,
- default: null,
- },
-
- tabIndex: {
- type: String,
- required: false,
- default: null,
+ validator: value => validSizes.includes(value),
},
},
@@ -99,15 +61,7 @@ export default {
</script>
<template>
- <svg
- :class="[iconSizeClass, iconTestClass, cssClasses]"
- :width="width"
- :height="height"
- :x="x"
- :y="y"
- :tabindex="tabIndex"
- aria-hidden="true"
- >
+ <svg :class="[iconSizeClass, iconTestClass]" aria-hidden="true">
<use v-bind="{ 'xlink:href': spriteHref }" />
</svg>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
index b76679960ca..5d7e9557aff 100644
--- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
@@ -63,7 +63,7 @@ export default {
<icon
v-if="hasState"
ref="iconElementXL"
- :css-classes="iconClass"
+ :class="iconClass"
:name="iconName"
:size="16"
:title="stateTitle"
@@ -100,7 +100,7 @@ export default {
<span ref="iconElement">
<icon
v-if="hasState"
- :css-classes="iconClass"
+ :class="iconClass"
:name="iconName"
:title="stateTitle"
:aria-label="state"
@@ -159,7 +159,8 @@ export default {
v-gl-tooltip
:disabled="removeDisabled"
type="button"
- class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button qa-remove-issue-button mr-xl-0 align-self-xl-center"
+ class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button mr-xl-0 align-self-xl-center"
+ data-qa-selector="remove_related_issue_button"
:title="__('Remove')"
:aria-label="__('Remove')"
@click="onRemoveRequest"
diff --git a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue
index 1e2d4ffa7e3..e89638130f5 100644
--- a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue
+++ b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue
@@ -27,8 +27,7 @@ export default {
/**
pageInfo will come from the headers of the API call
- in the `.then` clause of the VueResource API call
- there should be a function that contructs the pageInfo for this component
+ there should be a function that constructs the pageInfo for this component
This is an example:
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
index 7f0345c7ec0..478e44d104c 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
@@ -52,7 +52,7 @@ export default {
this.$emit('projectClicked', project);
},
isSelected(project) {
- return Boolean(_.findWhere(this.selectedProjects, { id: project.id }));
+ return Boolean(_.find(this.selectedProjects, { id: project.id }));
},
onInput: _.debounce(function debouncedOnInput() {
this.$emit('searched', this.searchQuery);
diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js b/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js
new file mode 100644
index 00000000000..a4e004c3341
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js
@@ -0,0 +1,21 @@
+import Vue from 'vue';
+
+// see recaptcha_tags in app/views/shared/_recaptcha_form.html.haml
+export const callbackName = 'recaptchaDialogCallback';
+
+export const eventHub = new Vue();
+
+const throwDuplicateCallbackError = () => {
+ throw new Error(`${callbackName} is already defined!`);
+};
+
+if (window[callbackName]) {
+ throwDuplicateCallbackError();
+}
+
+const callback = () => eventHub.$emit('submit');
+
+Object.defineProperty(window, callbackName, {
+ get: () => callback,
+ set: throwDuplicateCallbackError,
+});
diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
index f0aae20477b..25701df33f3 100644
--- a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
@@ -1,5 +1,6 @@
<script>
import DeprecatedModal from './deprecated_modal.vue';
+import { eventHub } from './recaptcha_eventhub';
export default {
name: 'RecaptchaModal',
@@ -30,14 +31,15 @@ export default {
},
mounted() {
- if (window.recaptchaDialogCallback) {
- throw new Error('recaptchaDialogCallback is already defined!');
+ eventHub.$on('submit', this.submit);
+
+ if (this.html) {
+ this.appendRecaptchaScript();
}
- window.recaptchaDialogCallback = this.submit.bind(this);
},
beforeDestroy() {
- window.recaptchaDialogCallback = null;
+ eventHub.$off('submit', this.submit);
},
methods: {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
index 9c258c4651f..13795eff714 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
@@ -167,7 +167,7 @@ dropdown-menu-labels dropdown-menu-selectable"
<div class="dropdown-page-one">
<dropdown-header v-if="showCreate" />
<dropdown-search-input />
- <div class="dropdown-content"></div>
+ <div class="dropdown-content" data-qa-selector="labels_dropdown_content"></div>
<div class="dropdown-loading"><gl-loading-icon /></div>
<dropdown-footer
v-if="showCreate"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue
index cb53273c786..574b63cf8a6 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue
@@ -14,7 +14,11 @@ export default {
{{ __('Labels') }}
<template v-if="canEdit">
<i aria-hidden="true" class="fa fa-spinner fa-spin block-loading" data-hidden="true"> </i>
- <button type="button" class="edit-link btn btn-blank float-right js-sidebar-dropdown-toggle">
+ <button
+ type="button"
+ class="edit-link btn btn-blank float-right js-sidebar-dropdown-toggle"
+ data-qa-selector="labels_edit_button"
+ >
{{ __('Edit') }}
</button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/toggle_button.vue b/app/assets/javascripts/vue_shared/components/toggle_button.vue
index de70fa2182b..1de866bed37 100644
--- a/app/assets/javascripts/vue_shared/components/toggle_button.vue
+++ b/app/assets/javascripts/vue_shared/components/toggle_button.vue
@@ -74,7 +74,7 @@ export default {
@click="toggleFeature"
>
<gl-loading-icon class="loading-icon" />
- <span class="toggle-icon"> <icon :name="toggleIcon" css-classes="toggle-icon-svg" /> </span>
+ <span class="toggle-icon"> <icon :name="toggleIcon" class="toggle-icon-svg" /> </span>
</button>
</label>
</template>
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 a60d5eb491e..7c7d46ee759 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
@@ -71,15 +71,11 @@ export default {
</div>
<div class="text-secondary">
<div v-if="user.bio" class="js-bio d-flex mb-1">
- <icon name="profile" css-classes="category-icon flex-shrink-0" />
+ <icon name="profile" class="category-icon flex-shrink-0" />
<span class="ml-1">{{ user.bio }}</span>
</div>
<div v-if="user.organization" class="js-organization d-flex mb-1">
- <icon
- v-show="!jobInfoIsLoading"
- name="work"
- css-classes="category-icon flex-shrink-0"
- />
+ <icon v-show="!jobInfoIsLoading" name="work" class="category-icon flex-shrink-0" />
<span class="ml-1">{{ user.organization }}</span>
</div>
<gl-skeleton-loading
@@ -92,7 +88,7 @@ export default {
<icon
v-show="!locationIsLoading && user.location"
name="location"
- css-classes="category-icon flex-shrink-0"
+ class="category-icon flex-shrink-0"
/>
<span class="ml-1">{{ user.location }}</span>
<gl-skeleton-loading
diff --git a/app/assets/javascripts/vue_shared/directives/track_event.js b/app/assets/javascripts/vue_shared/directives/track_event.js
new file mode 100644
index 00000000000..d1c05c5c267
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/directives/track_event.js
@@ -0,0 +1,20 @@
+import Tracking from '~/tracking';
+
+export default {
+ bind(el, binding) {
+ el.dataset.trackingOptions = JSON.stringify(binding.value || {});
+
+ el.addEventListener('click', () => {
+ const { category, action, label, property, value } = JSON.parse(el.dataset.trackingOptions);
+ if (!category || !action) {
+ return;
+ }
+ Tracking.event(category, action, { label, property, value });
+ });
+ },
+ update(el, binding) {
+ if (binding.value !== binding.oldValue) {
+ el.dataset.trackingOptions = JSON.stringify(binding.value || {});
+ }
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js
new file mode 100644
index 00000000000..3488a44bd0f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js
@@ -0,0 +1,7 @@
+export default Vue => {
+ Vue.mixin({
+ provide: {
+ glFeatures: { ...((window.gon && window.gon.features) || {}) },
+ },
+ });
+};
diff --git a/app/assets/javascripts/vue_shared/mixins/gl_feature_flags_mixin.js b/app/assets/javascripts/vue_shared/mixins/gl_feature_flags_mixin.js
new file mode 100644
index 00000000000..dc8a63f26ac
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/mixins/gl_feature_flags_mixin.js
@@ -0,0 +1,8 @@
+export default () => ({
+ inject: {
+ glFeatures: {
+ from: 'glFeatures',
+ default: () => ({}),
+ },
+ },
+});
diff --git a/app/assets/javascripts/vue_shared/plugins/global_toast.js b/app/assets/javascripts/vue_shared/plugins/global_toast.js
index c0de1cdc615..7a2e5d80a5d 100644
--- a/app/assets/javascripts/vue_shared/plugins/global_toast.js
+++ b/app/assets/javascripts/vue_shared/plugins/global_toast.js
@@ -2,7 +2,8 @@ import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
Vue.use(GlToast);
+const instance = new Vue();
export default function showGlobalToast(...args) {
- return Vue.toasted.show(...args);
+ return instance.$toast.show(...args);
}
diff --git a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js
deleted file mode 100644
index 754025207c8..00000000000
--- a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import Vue from 'vue';
-import VueResource from 'vue-resource';
-import csrf from '../lib/utils/csrf';
-
-Vue.use(VueResource);
-
-// Maintain a global counter for active requests
-// see: spec/support/wait_for_requests.rb
-Vue.http.interceptors.push((request, next) => {
- window.activeVueResources = window.activeVueResources || 0;
- window.activeVueResources += 1;
-
- next(() => {
- window.activeVueResources -= 1;
- });
-});
-
-// Inject CSRF token and parse headers.
-// New Vue Resource version uses Headers, we are expecting a plain object to render pagination
-// and polling.
-Vue.http.interceptors.push((request, next) => {
- request.headers.set(csrf.headerKey, csrf.token);
-
- next(response => {
- // Headers object has a `forEach` property that iterates through all values.
- const headers = {};
-
- response.headers.forEach((value, key) => {
- headers[key] = value;
- });
- // eslint-disable-next-line no-param-reassign
- response.headers = headers;
- });
-});
diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js
index 5438572eadf..7a60ab1380f 100644
--- a/app/assets/javascripts/zen_mode.js
+++ b/app/assets/javascripts/zen_mode.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, prefer-arrow-callback, consistent-return, camelcase, class-methods-use-this */
+/* eslint-disable func-names, consistent-return, camelcase, class-methods-use-this */
// Zen Mode (full screen) textarea
//
@@ -39,11 +39,11 @@ export default class ZenMode {
constructor() {
this.active_backdrop = null;
this.active_textarea = null;
- $(document).on('click', '.js-zen-enter', function(e) {
+ $(document).on('click', '.js-zen-enter', e => {
e.preventDefault();
return $(e.currentTarget).trigger('zen_mode:enter');
});
- $(document).on('click', '.js-zen-leave', function(e) {
+ $(document).on('click', '.js-zen-leave', e => {
e.preventDefault();
return $(e.currentTarget).trigger('zen_mode:leave');
});
@@ -67,7 +67,7 @@ export default class ZenMode {
};
})(this),
);
- $(document).on('keydown', function(e) {
+ $(document).on('keydown', e => {
// Esc
if (e.keyCode === 27) {
e.preventDefault();
diff --git a/app/assets/stylesheets/components/release_block.scss b/app/assets/stylesheets/components/release_block.scss
new file mode 100644
index 00000000000..7e82d0960d7
--- /dev/null
+++ b/app/assets/stylesheets/components/release_block.scss
@@ -0,0 +1,3 @@
+.release-block {
+ transition: background-color 1s linear;
+}
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 82b4ec750ff..56a88ca44db 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -28,6 +28,7 @@
@import 'framework/issue_box';
@import 'framework/lists';
@import 'framework/logo';
+@import 'framework/job_log';
@import 'framework/markdown_area';
@import 'framework/media_object';
@import 'framework/modal';
@@ -67,3 +68,4 @@
@import 'framework/flex_grid';
@import 'framework/system_messages';
@import "framework/spinner";
+@import 'framework/card';
diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss
index 6f5a2e561af..d222fc4aefe 100644
--- a/app/assets/stylesheets/framework/animations.scss
+++ b/app/assets/stylesheets/framework/animations.scss
@@ -11,25 +11,10 @@
@include webkit-prefix(animation-duration, 1s);
@include webkit-prefix(animation-fill-mode, both);
- &.infinite {
- @include webkit-prefix(animation-iteration-count, infinite);
- }
-
&.once {
@include webkit-prefix(animation-iteration-count, 1);
}
- &.hinge {
- @include webkit-prefix(animation-duration, 2s);
- }
-
- &.flipOutX,
- &.flipOutY,
- &.bounceIn,
- &.bounceOut {
- @include webkit-prefix(animation-duration, 0.75s);
- }
-
&.short {
@include webkit-prefix(animation-duration, 321ms);
@include webkit-prefix(animation-fill-mode, none);
diff --git a/app/assets/stylesheets/framework/blank.scss b/app/assets/stylesheets/framework/blank.scss
index cbd390e7145..7dd7ab339dd 100644
--- a/app/assets/stylesheets/framework/blank.scss
+++ b/app/assets/stylesheets/framework/blank.scss
@@ -14,13 +14,12 @@
.blank-state-row {
display: flex;
flex-wrap: wrap;
- justify-content: space-around;
- height: 100%;
+ justify-content: space-between;
}
.blank-state-welcome {
text-align: center;
- padding: 20px 0 40px;
+ padding: $gl-padding 0 ($gl-padding * 2);
.blank-state-welcome-title {
font-size: 24px;
@@ -32,23 +31,9 @@
}
.blank-state-link {
- display: block;
color: $gl-text-color;
- flex: 0 0 100%;
margin-bottom: 15px;
- @include media-breakpoint-up(sm) {
- flex: 0 0 49%;
-
- &:nth-child(odd) {
- margin-right: 5px;
- }
-
- &:nth-child(even) {
- margin-left: 5px;
- }
- }
-
&:hover {
background-color: $gray-light;
text-decoration: none;
@@ -63,15 +48,25 @@
}
.blank-state {
- padding: 20px;
+ display: flex;
+ align-items: center;
+ padding: 20px 50px;
border: 1px solid $border-color;
border-radius: $border-radius-default;
+ min-height: 240px;
+ margin-bottom: $gl-padding;
+ width: calc(50% - #{$gl-padding-8});
+
+ @include media-breakpoint-down(sm) {
+ width: 100%;
+ flex-direction: column;
+ justify-content: center;
+ padding: 50px 20px;
+
+ .column-small & {
+ width: 100%;
+ }
- @include media-breakpoint-up(sm) {
- display: flex;
- height: 100%;
- align-items: center;
- padding: 50px 30px;
}
}
@@ -90,7 +85,7 @@
}
.blank-state-body {
- @include media-breakpoint-down(xs) {
+ @include media-breakpoint-down(sm) {
text-align: center;
margin-top: 20px;
}
@@ -121,9 +116,3 @@
}
}
}
-
-@include media-breakpoint-down(xs) {
- .blank-state-icon svg {
- width: 315px;
- }
-}
diff --git a/app/assets/stylesheets/framework/card.scss b/app/assets/stylesheets/framework/card.scss
new file mode 100644
index 00000000000..9911b926cbb
--- /dev/null
+++ b/app/assets/stylesheets/framework/card.scss
@@ -0,0 +1,8 @@
+.card-header {
+ &:first-child {
+ // intended use case: card with only a header (for example empty related issues)
+ &:last-child {
+ @include border-radius($card-inner-border-radius);
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index b95978b6966..4b89a2f2b04 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -55,6 +55,10 @@
background-color: $gray-light;
}
+.bg-line-target-blue {
+ background: $line-target-blue;
+}
+
.text-break-word {
word-break: break-all;
}
@@ -210,18 +214,26 @@ li.note {
@mixin message($background-color, $border-color, $text-color) {
border-left: 4px solid $border-color;
color: $text-color;
- padding: 10px;
- margin-bottom: 10px;
- background: $background-color;
- padding-left: 20px;
+ padding: $gl-padding $gl-padding-24;
+ margin-bottom: $gl-padding-12;
+ background-color: $background-color;
&.centered {
text-align: center;
}
+
+ .close {
+ svg {
+ width: $gl-font-size-large;
+ height: $gl-font-size-large;
+ }
+
+ color: inherit;
+ }
}
.warning_message {
- @include message($orange-100, $orange-200, $orange-700);
+ @include message($orange-100, $orange-200, $orange-800);
}
.danger_message {
@@ -387,6 +399,7 @@ img.emoji {
.prepend-top-16 { margin-top: 16px; }
.prepend-top-20 { margin-top: 20px; }
.prepend-top-32 { margin-top: 32px; }
+.prepend-left-2 { margin-left: 2px; }
.prepend-left-4 { margin-left: 4px; }
.prepend-left-5 { margin-left: 5px; }
.prepend-left-8 { margin-left: 8px; }
@@ -427,6 +440,7 @@ img.emoji {
.flex-no-shrink { flex-shrink: 0; }
.ws-initial { white-space: initial; }
.ws-normal { white-space: normal; }
+.ws-pre-wrap { white-space: pre-wrap; }
.overflow-auto { overflow: auto; }
.d-flex-center {
@@ -439,6 +453,8 @@ img.emoji {
.w-0 { width: 0; }
.w-8em { width: 8em; }
.w-3rem { width: 3rem; }
+.w-15p { width: 15%; }
+.w-70p { width: 70%; }
.h-12em { height: 12em; }
.h-32-px { height: 32px;}
@@ -544,3 +560,6 @@ img.emoji {
}
}
}
+
+.gl-font-size-small { font-size: $gl-font-size-small; }
+.gl-line-height-24 { line-height: $gl-line-height-24; }
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index 3238b01c6c0..0e29b0b7dda 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -141,7 +141,7 @@
}
.sidebar-top-level-items > li > a {
- min-height: 44px;
+ min-height: 45px;
}
.fly-out-top-item {
@@ -177,7 +177,7 @@
transition: padding $sidebar-transition-duration;
display: flex;
align-items: center;
- padding: 12px 15px;
+ padding: 12px $gl-padding;
color: $gl-text-color-secondary;
}
@@ -341,7 +341,7 @@
> a {
margin-left: 4px;
// Subtract width of left border on active element
- padding-left: 11px;
+ padding-left: $gl-padding-12;
}
.badge.badge-pill {
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 29f63e9578d..ce74aa6ed02 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -326,8 +326,9 @@
}
.dropdown-header {
- color: $gl-text-color-secondary;
+ color: $black;
font-size: 13px;
+ font-weight: $gl-font-weight-bold;
line-height: $gl-line-height;
padding: $dropdown-item-padding-y $dropdown-item-padding-x;
}
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 536a26a6ffe..487fbf0fcff 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -108,12 +108,14 @@
background: $white-light;
&.image_file,
+ &.audio,
&.video {
background: $gray-darker;
text-align: center;
padding: 30px;
img,
+ audio,
video {
max-width: 80%;
}
@@ -479,3 +481,8 @@ span.idiff {
padding: $gl-padding;
}
}
+
+.jupyter-notebook-scrolled {
+ overflow-y: auto;
+ max-height: 20rem;
+}
diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss
index 7e7b08797b2..8fc2fd5f53b 100644
--- a/app/assets/stylesheets/framework/flash.scss
+++ b/app/assets/stylesheets/framework/flash.scss
@@ -1,7 +1,6 @@
$notification-box-shadow-color: rgba(0, 0, 0, 0.25);
.flash-container {
- cursor: pointer;
margin: 0;
margin-bottom: $gl-padding;
font-size: 14px;
@@ -12,19 +11,24 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25);
position: sticky;
position: -webkit-sticky;
top: $flash-container-top;
- z-index: 200;
+ z-index: 251;
.flash-content {
box-shadow: 0 2px 4px 0 $notification-box-shadow-color;
}
}
- .close-icon {
- width: 16px;
- height: 16px;
+ .close-icon-wrapper {
+ padding: ($gl-btn-padding + $gl-padding-4) $gl-padding $gl-btn-padding;
position: absolute;
- right: $gl-padding;
- top: $gl-padding;
+ right: 0;
+ top: 0;
+ cursor: pointer;
+
+ .close-icon {
+ width: 16px;
+ height: 16px;
+ }
}
.flash-notice,
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index ca737c53318..1195e467192 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -523,6 +523,7 @@
margin-top: 4px;
color: $gl-text-color;
left: auto;
+ max-height: $dropdown-max-height-lg;
li.current-user {
padding: $dropdown-item-padding-y $dropdown-item-padding-x;
diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss
index 7332c4981d2..a53f5d85949 100644
--- a/app/assets/stylesheets/framework/icons.scss
+++ b/app/assets/stylesheets/framework/icons.scss
@@ -31,7 +31,16 @@
}
}
-.ci-status-icon-preparing,
+.ci-status-icon-preparing {
+ svg {
+ fill: $gray-500;
+ }
+
+ &.add-border {
+ @include borderless-status-icon($gray-500);
+ }
+}
+
.ci-status-icon-running {
svg {
fill: $blue-400;
diff --git a/app/assets/stylesheets/framework/job_log.scss b/app/assets/stylesheets/framework/job_log.scss
index 074b2405217..4a57a458c50 100644
--- a/app/assets/stylesheets/framework/job_log.scss
+++ b/app/assets/stylesheets/framework/job_log.scss
@@ -12,21 +12,22 @@
}
.log-line {
- padding: 1px $gl-padding 1px $job-log-line-padding;
+ padding: 1px $gl-padding-8 1px $job-log-line-padding;
+ min-height: $gl-line-height-20;
}
.line-number {
- color: $gl-text-color-inverted;
+ color: $gl-gray-500;
padding: 0 $gl-padding-8;
min-width: $job-line-number-width;
- margin-left: -$job-line-number-width;
+ margin-left: -$job-line-number-margin;
padding-right: 1em;
&:hover,
&:active,
&:visited {
text-decoration: underline;
- color: $gl-text-color-inverted;
+ color: $gl-gray-500;
}
}
diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss
index 81ccea1e01f..2289f0a7011 100644
--- a/app/assets/stylesheets/framework/selects.scss
+++ b/app/assets/stylesheets/framework/selects.scss
@@ -245,7 +245,7 @@
.select2-highlighted {
.group-result {
.group-path {
- color: $white-light;
+ color: $gray-800;
}
}
}
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index 43d0e51e4c9..b9cfcf6ce5c 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -171,7 +171,7 @@
position: absolute;
top: $gl-padding;
bottom: $gl-padding;
- left: map-get($spacers, 2) - 1px;
+ left: map-get($spacers, 2) - px-to-rem(1px);
}
&-row {
@@ -187,7 +187,7 @@
* 2px extra is to give a little more height than needed
* to hide timeline line before/after the element starts/ends
*/
- height: map-get($spacers, 4) + 2px;
+ height: map-get($spacers, 4) + px-to-rem(2px);
z-index: 1;
position: relative;
top: -3px;
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index ba123ff9a67..3876d1c10d4 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -69,14 +69,6 @@
details {
margin-bottom: $gl-padding;
-
- summary {
- margin-bottom: $gl-padding;
- }
-
- *:first-child:not(summary) {
- margin-top: $gl-padding;
- }
}
// Single code lines should wrap
@@ -198,6 +190,141 @@
border-color: $gl-gray-200;
}
}
+
+ &.grid-none {
+ > thead > tr {
+ > th {
+ border-bottom-width: 0;
+ border-right-width: 0;
+ border-left-width: 0;
+
+ &:first-child {
+ border-left-width: 1px;
+ }
+
+ &:last-child {
+ border-right-width: 1px;
+ }
+ }
+ }
+
+ > tbody {
+ > tr > td {
+ border-width: 0;
+
+ &:first-child {
+ border-left-width: 1px;
+ }
+
+ &:last-child {
+ border-right-width: 1px;
+ }
+ }
+
+ > tr:last-child > td {
+ border-bottom-width: 1px;
+ }
+ }
+ }
+
+
+ &.grid-rows {
+ > thead > tr > th,
+ > tbody > tr > td {
+ border-right-width: 0;
+ border-left-width: 0;
+ }
+
+ > thead > tr {
+ > th:first-child {
+ border-left-width: 1px;
+ }
+
+ > th:last-child {
+ border-right-width: 1px;
+ }
+ }
+
+ > tbody > tr {
+ > td {
+ border-left-width: 0;
+ border-right-width: 0;
+ }
+
+ > td:first-child {
+ border-left-width: 1px;
+ }
+
+ > td:last-child {
+ border-right-width: 1px;
+ }
+ }
+ }
+
+ &.grid-cols {
+ > thead > tr > th {
+ border-bottom-width: 0;
+ }
+
+ > tbody > tr > td {
+ border-top-width: 0;
+ border-bottom-width: 0;
+ }
+
+ > tbody > tr:last-child > td {
+ border-bottom-width: 1px;
+ }
+ }
+
+ &.frame-sides {
+ > thead > tr > th {
+ border-top-width: 0;
+ }
+
+ > tbody > tr:last-child > td {
+ border-bottom-width: 0;
+ }
+ }
+
+ &.frame-topbot,
+ &.frame-ends {
+ > thead > tr > th:first-child,
+ > tbody > tr > td:first-child {
+ border-left-width: 0;
+ }
+
+ > thead > tr > th:last-child,
+ > tbody > tr > td:last-child {
+ border-right-width: 0;
+ }
+ }
+
+ &.frame-none {
+ > thead > tr > th {
+ border-top-width: 0;
+ }
+
+ > tbody > tr:last-child > td {
+ border-bottom-width: 0;
+ }
+
+ > thead > tr > th:first-child,
+ > tbody > tr > td:first-child {
+ border-left-width: 0;
+ }
+
+ > thead > tr > th:last-child,
+ > tbody > tr > td:last-child {
+ border-right-width: 0;
+ }
+ }
+
+ &.stripes-all tr,
+ &.stripes-odd tr:nth-of-type(odd),
+ &.stripes-even tr:nth-of-type(even),
+ &.stripes-hover tr:hover {
+ background: $gray-light;
+ }
}
table:dir(rtl) th {
@@ -397,7 +524,7 @@
}
}
- .prometheus-graph-embed {
+ .metrics-embed {
h3.popover-header {
/** Override <h3> .popover-header
* as embed metrics do not follow the same
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index e77527ac130..dfc39d8e03b 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -606,9 +606,10 @@ $blame-blue: #254e77;
*/
$builds-trace-bg: #111;
$job-log-highlight-height: 18px;
-$job-log-line-padding: 62px;
-$job-line-number-width: 40px;
-$job-arrow-margin: 50px;
+$job-log-line-padding: 55px;
+$job-line-number-width: 50px;
+$job-line-number-margin: 43px;
+$job-arrow-margin: 55px;
/*
* Commit Page
@@ -832,6 +833,7 @@ Merge Requests
*/
$mr-tabs-height: 48px;
$mr-version-controls-height: 56px;
+$mr-widget-margin-left: 40px;
/*
Compare Branches
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index d540a347dde..2a7a53d8bd7 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -245,6 +245,7 @@
box-shadow: 0 1px 2px $issue-boards-card-shadow;
line-height: $gl-padding;
list-style: none;
+ position: relative;
&:not(:last-child) {
margin-bottom: $gl-padding-8;
@@ -255,6 +256,11 @@
background-color: $blue-50;
}
+ &.multi-select {
+ border-color: $blue-200;
+ background-color: $blue-50;
+ }
+
.badge {
border: 0;
outline: 0;
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index 73166940146..c7d51a2093a 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -124,26 +124,6 @@
float: left;
padding-left: $gl-padding-8;
}
-
- .section-start {
- display: inline;
- }
-
- .section-start,
- .section-header {
- &:hover {
- cursor: pointer;
-
- &::after {
- content: '';
- background-color: rgba($white-light, 0.2);
- left: 0;
- right: 0;
- position: absolute;
- height: $job-log-highlight-height;
- }
- }
- }
}
.build-header {
@@ -308,12 +288,8 @@
}
a {
- display: block;
padding: $gl-padding 10px $gl-padding 40px;
width: 270px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
&:hover {
color: $gl-text-color;
diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss
index 655b297295a..65d0ce8c52e 100644
--- a/app/assets/stylesheets/pages/editor.scss
+++ b/app/assets/stylesheets/pages/editor.scss
@@ -47,14 +47,19 @@
margin-right: 10px;
}
- .new-file-name {
+ .new-file-name,
+ .new-file-path {
display: inline-block;
- max-width: 420px;
+ max-width: 250px;
float: left;
@media(max-width: map-get($grid-breakpoints, lg)-1) {
width: 180px;
}
+
+ @media (max-width: 1360px) {
+ width: auto;
+ }
}
.file-buttons {
@@ -98,13 +103,14 @@
}
-@include media-breakpoint-down(sm) {
+@include media-breakpoint-down(md) {
.file-editor {
.file-title {
display: block;
}
- .new-file-name {
+ .new-file-name,
+ .new-file-path {
max-width: none;
width: 100%;
margin-bottom: 3px;
@@ -146,20 +152,17 @@
vertical-align: top;
display: inline-block;
- @media(max-width: map-get($grid-breakpoints, md)-1) {
+ @media(max-width: map-get($grid-breakpoints, lg)-1) {
display: block;
margin: 19px 0 12px;
}
}
.template-selectors-menu {
- display: inline-block;
+ display: flex;
vertical-align: top;
- margin: 14px 0 0 16px;
- padding: 0 0 0 14px;
- border-left: 1px solid $border-color;
- @media(max-width: map-get($grid-breakpoints, md)-1) {
+ @media(max-width: map-get($grid-breakpoints, lg)-1) {
display: block;
width: 100%;
margin: 5px 0;
@@ -168,24 +171,11 @@
}
}
-.templates-selectors-label {
- display: inline-block;
- vertical-align: top;
- margin-top: 6px;
- line-height: 21px;
-
- @media(max-width: map-get($grid-breakpoints, md)-1) {
- display: block;
- margin: 5px 0;
- }
-}
-
.template-selector-dropdowns-wrap {
display: inline-block;
- margin: 5px 0 0 8px;
vertical-align: top;
- @media(max-width: map-get($grid-breakpoints, md)-1) {
+ @media(max-width: map-get($grid-breakpoints, lg)-1) {
display: block;
width: 100%;
margin: 0 0 16px;
@@ -199,9 +189,8 @@
display: inline-block;
vertical-align: top;
font-family: $regular_font;
- margin-top: -5px;
- @media(max-width: map-get($grid-breakpoints, md)-1) {
+ @media(max-width: map-get($grid-breakpoints, lg)-1) {
display: block;
width: 100%;
margin: 5px 0;
@@ -212,30 +201,22 @@
}
.dropdown-menu-toggle {
- width: 250px;
+ width: 200px;
vertical-align: top;
- @media(max-width: map-get($grid-breakpoints, md)-1) {
+ @media (max-width: map-get($grid-breakpoints, xl)-1) {
+ width: auto;
+ }
+
+ @media(max-width: map-get($grid-breakpoints, lg)-1) {
display: block;
width: 100%;
margin: 5px 0;
}
}
-
}
}
-.template-selectors-undo-menu {
- display: inline-block;
- margin: 7px 0 0 10px;
-
- @media(max-width: map-get($grid-breakpoints, md)-1) {
- display: block;
- width: 100%;
- margin: 20px 0;
- }
-
- button {
- margin: -4px 0 0 15px;
- }
+.editor-title-row {
+ margin-bottom: 20px;
}
diff --git a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss
new file mode 100644
index 00000000000..8b1ec1ced35
--- /dev/null
+++ b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss
@@ -0,0 +1,51 @@
+.signup-page {
+ .page-wrap {
+ background-color: $gray-light;
+ }
+
+ .gitlab-logo {
+ width: 80px;
+ height: 80px;
+ }
+
+ .signup-box-container {
+ max-width: 900px;
+
+ &.navless-container {
+ // overriding .devise-layout-html.navless-container to support the sticky footer
+ // without having a header on size xs
+ @include media-breakpoint-down(xs) {
+ padding: 65px $gl-padding; // height of footer
+ padding-top: $gl-padding;
+ }
+ }
+ }
+
+ .signup-heading h2 {
+ font-weight: $gl-font-weight-bold;
+
+ @include media-breakpoint-down(md) {
+ font-size: $gl-font-size-large;
+ }
+ }
+
+ .signup-box {
+ background-color: $white-light;
+ box-shadow: 0 0 0 1px $border-color;
+ border-radius: $border-radius;
+ }
+
+ .form-control {
+ &:active,
+ &:focus {
+ background-color: $white-light;
+ }
+ }
+
+ .devise-errors {
+ h2 {
+ font-size: $gl-font-size;
+ color: $red-700;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/help.scss b/app/assets/stylesheets/pages/help.scss
index ef872e693e0..ab281bc7f23 100644
--- a/app/assets/stylesheets/pages/help.scss
+++ b/app/assets/stylesheets/pages/help.scss
@@ -37,6 +37,7 @@
.documentation {
padding: 7px;
+ font-size: $gl-font-size-large;
}
.card.links-card {
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss
index d8aabecc036..7488a5b16a2 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -215,6 +215,12 @@
body {
// offset height of fixed header + 1 to avoid scroll
height: calc(100% - 51px);
+
+ // offset without the header
+ &.navless {
+ height: calc(100% - 11px);
+ }
+
margin: 0;
padding: 0;
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index e6feded1d4f..971f3b2c308 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -19,6 +19,8 @@
border-top: 1px solid $border-color;
}
+.mr-widget-margin-left { margin-left: $mr-widget-margin-left; }
+
.media-section {
@include media-breakpoint-down(md) {
align-items: flex-start;
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index d67a0f83aa2..21a9f143039 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -245,14 +245,6 @@ $note-form-margin-left: 72px;
}
}
- .note-header {
- @include notes-media('max', map-get($grid-breakpoints, xs)) {
- .inline {
- display: block;
- }
- }
- }
-
.note-emoji-button {
position: relative;
line-height: 1;
@@ -363,10 +355,13 @@ $note-form-margin-left: 72px;
}
}
+ .timeline-icon {
+ float: left;
+ }
+
.system-note,
.discussion-filter-note {
.timeline-icon {
- float: left;
display: flex;
align-items: center;
background-color: $white-light;
@@ -635,10 +630,6 @@ $note-form-margin-left: 72px;
.note-headline-light {
display: inline;
-
- @include notes-media('max', map-get($grid-breakpoints, xs)) {
- display: block;
- }
}
.note-headline-light,
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 801e9e7204c..b2c1d0b6dc5 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -877,11 +877,16 @@ pre.light-well {
flex-direction: column;
// Disable Flexbox for admin page
- &.admin-projects {
+ &.admin-projects,
+ &.group-settings-projects {
display: block;
.project-row {
display: block;
+
+ .description > p {
+ margin-bottom: 0;
+ }
}
}
@@ -996,6 +1001,14 @@ pre.light-well {
}
}
+ &:not(.with-pipeline-status) {
+ .icon-wrapper:first-of-type {
+ @include media-breakpoint-up(lg) {
+ margin-left: $gl-padding-32;
+ }
+ }
+ }
+
.ci-status-link {
display: inline-flex;
}
diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss
index 72f1b5307ec..154e505f7a4 100644
--- a/app/assets/stylesheets/pages/prometheus.scss
+++ b/app/assets/stylesheets/pages/prometheus.scss
@@ -15,6 +15,51 @@
}
}
+.draggable {
+ &.draggable-enabled {
+ .draggable-panel {
+ border: $gray-200 1px solid;
+ border-radius: $border-radius-default;
+ margin: -1px;
+ cursor: grab;
+ }
+
+ .prometheus-graph {
+ // Make dragging easier by disabling use of chart
+ pointer-events: none;
+ }
+ }
+
+ &.sortable-chosen .draggable-panel {
+ background: $white-light;
+ box-shadow: 0 0 4px $gray-500;
+ }
+
+ .draggable-remove {
+ z-index: 1;
+
+ .draggable-remove-link {
+ cursor: pointer;
+ color: $gray-600;
+ background-color: $white-light;
+ }
+ }
+}
+
+.prometheus-graphs-header {
+ .time-window-dropdown-menu {
+ padding: $gl-padding $gl-padding 0 $gl-padding-12;
+ }
+
+ .time-window-dropdown-menu-container {
+ width: 360px;
+ }
+
+ .custom-time-range-form-group > label {
+ padding-bottom: $gl-padding;
+ }
+}
+
.prometheus-panel {
margin-top: 20px;
}
@@ -22,11 +67,11 @@
.prometheus-graph-group {
display: flex;
flex-wrap: wrap;
- padding: $gl-padding / 2;
+ margin-top: $gl-padding-8;
}
.prometheus-graph {
- padding: $gl-padding / 2;
+ padding: $gl-padding-8;
}
.prometheus-graph-embed {
@@ -39,11 +84,14 @@
align-items: center;
justify-content: space-between;
margin-bottom: $gl-padding-8;
+}
- h5 {
- font-size: $gl-font-size-large;
- margin: 0;
- }
+.prometheus-graph-title {
+ font-size: $gl-font-size-large;
+}
+
+.alert-current-setting {
+ max-width: 240px;
}
.prometheus-graph-cursor {
diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss
index 613f643af3a..5d6a4b7cd13 100644
--- a/app/assets/stylesheets/pages/status.scss
+++ b/app/assets/stylesheets/pages/status.scss
@@ -37,6 +37,10 @@
}
}
+ &.ci-preparing {
+ @include status-color($gray-100, $gray-500, $gray-600);
+ }
+
&.ci-pending,
&.ci-failed-with-warnings,
&.ci-success-with-warnings {
@@ -44,7 +48,6 @@
}
&.ci-info,
- &.ci-preparing,
&.ci-running {
@include status-color($blue-100, $blue-500, $blue-600);
}
diff --git a/app/assets/stylesheets/pages/tags.scss b/app/assets/stylesheets/pages/tags.scss
new file mode 100644
index 00000000000..a6d30522ff7
--- /dev/null
+++ b/app/assets/stylesheets/pages/tags.scss
@@ -0,0 +1,3 @@
+.tag-release-link {
+ color: $blue-600 !important;
+}
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index fbf63997b15..f7e33c09928 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -6,9 +6,9 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
before_action :set_application_setting
before_action :whitelist_query_limiting, only: [:usage_data]
- VALID_SETTING_PANELS = %w(general integrations repository templates
+ VALID_SETTING_PANELS = %w(general integrations repository
ci_cd reporting metrics_and_profiling
- network geo preferences).freeze
+ network preferences).freeze
VALID_SETTING_PANELS.each do |action|
define_method(action) { perform_update if submitted? }
@@ -145,10 +145,15 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
end
def render_update_error
- action = VALID_SETTING_PANELS.include?(action_name) ? action_name : :general
+ action = valid_setting_panels.include?(action_name) ? action_name : :general
render action
end
+
+ # overridden in EE
+ def valid_setting_panels
+ VALID_SETTING_PANELS
+ end
end
Admin::ApplicationSettingsController.prepend_if_ee('EE::Admin::ApplicationSettingsController')
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index c36bbaab23b..f24ce9b5d03 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -2,6 +2,7 @@
class Admin::DashboardController < Admin::ApplicationController
include CountHelper
+ helper_method :show_license_breakdown?
COUNTED_ITEMS = [Project, User, Group].freeze
@@ -13,6 +14,10 @@ class Admin::DashboardController < Admin::ApplicationController
@groups = Group.order_id_desc.with_route.limit(10)
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ def show_license_breakdown?
+ false
+ end
end
Admin::DashboardController.prepend_if_ee('EE::Admin::DashboardController')
diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb
new file mode 100644
index 00000000000..1f946e41995
--- /dev/null
+++ b/app/controllers/admin/sessions_controller.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class Admin::SessionsController < ApplicationController
+ include InternalRedirect
+
+ before_action :user_is_admin!
+
+ def new
+ # Renders a form in which the admin can enter their password
+ end
+
+ def create
+ if current_user_mode.enable_admin_mode!(password: params[:password])
+ redirect_location = stored_location_for(:redirect) || admin_root_path
+ redirect_to safe_redirect_path(redirect_location)
+ else
+ flash.now[:alert] = _('Invalid Login or password')
+ render :new
+ end
+ end
+
+ def destroy
+ current_user_mode.disable_admin_mode!
+
+ redirect_to root_path, status: :found, notice: _('Admin mode disabled')
+ end
+
+ private
+
+ def user_is_admin!
+ render_404 unless current_user&.admin?
+ end
+end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 61d36d1efc2..4c1ac8f206a 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -58,6 +58,22 @@ class Admin::UsersController < Admin::ApplicationController
end
end
+ def activate
+ return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user must be unblocked to be activated")) if user.blocked?
+
+ user.activate
+ redirect_back_or_admin_user(notice: _("Successfully activated"))
+ end
+
+ def deactivate
+ return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user cannot be deactivated")) if user.blocked?
+ return redirect_back_or_admin_user(notice: _("Successfully deactivated")) if user.deactivated?
+ return redirect_back_or_admin_user(notice: _("The user you are trying to deactivate has been active in the past %{minimum_inactive_days} days and cannot be deactivated") % { minimum_inactive_days: ::User::MINIMUM_INACTIVE_DAYS }) unless user.can_be_deactivated?
+
+ user.deactivate
+ redirect_back_or_admin_user(notice: _("Successfully deactivated"))
+ end
+
def block
if update_user { |user| user.block }
redirect_back_or_admin_user(notice: _("Successfully blocked"))
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 9a7859fc687..1443a71f6b1 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -13,6 +13,8 @@ class ApplicationController < ActionController::Base
include WithPerformanceBar
include SessionlessAuthentication
include ConfirmEmailWarning
+ include Gitlab::Tracking::ControllerConcern
+ include Gitlab::Experimentation::ControllerConcern
before_action :authenticate_user!
before_action :enforce_terms!, if: :should_enforce_terms?
@@ -24,8 +26,10 @@ class ApplicationController < ActionController::Base
before_action :add_gon_variables, unless: [:peek_request?, :json_request?]
before_action :configure_permitted_parameters, if: :devise_controller?
before_action :require_email, unless: :devise_controller?
+ before_action :active_user_check, unless: :devise_controller?
before_action :set_usage_stats_consent_flag
before_action :check_impersonation_availability
+ before_action :require_role
around_action :set_locale
around_action :set_session_storage
@@ -36,6 +40,7 @@ class ApplicationController < ActionController::Base
protect_from_forgery with: :exception, prepend: true
helper_method :can?
+ helper_method :current_user_mode
helper_method :import_sources_enabled?, :github_import_enabled?,
:gitea_import_enabled?, :github_import_configured?,
:gitlab_import_enabled?, :gitlab_import_configured?,
@@ -286,13 +291,19 @@ class ApplicationController < ActionController::Base
def check_password_expiration
return if session[:impersonator_id] || !current_user&.allow_password_authentication?
- password_expires_at = current_user&.password_expires_at
-
- if password_expires_at && password_expires_at < Time.now
+ if current_user&.password_expired?
return redirect_to new_profile_password_path
end
end
+ def active_user_check
+ return unless current_user && current_user.deactivated?
+
+ sign_out current_user
+ flash[:alert] = _("Your account has been deactivated by your administrator. Please log back in to reactivate your account.")
+ redirect_to new_user_session_path
+ end
+
def ldap_security_check
if current_user && current_user.requires_ldap_check?
return unless current_user.try_obtain_ldap_lease
@@ -533,6 +544,20 @@ class ApplicationController < ActionController::Base
yield
end
end
+
+ def current_user_mode
+ @current_user_mode ||= Gitlab::Auth::CurrentUserMode.new(current_user)
+ end
+
+ # A user requires a role when they are part of the experimental signup flow (executed by the Growth team). Users
+ # are redirected to the welcome page when their role is required and the experiment is enabled for the current user.
+ def require_role
+ return unless current_user && current_user.role_required? && experiment_enabled?(:signup_flow)
+
+ store_location_for :user, request.fullpath
+
+ redirect_to users_sign_up_welcome_path
+ end
end
ApplicationController.prepend_if_ee('EE::ApplicationController')
diff --git a/app/controllers/boards/application_controller.rb b/app/controllers/boards/application_controller.rb
index eab908ba5ed..15ef6698472 100644
--- a/app/controllers/boards/application_controller.rb
+++ b/app/controllers/boards/application_controller.rb
@@ -13,7 +13,7 @@ module Boards
end
def board_parent
- @board_parent ||= board.parent
+ @board_parent ||= board.resource_parent
end
def record_not_found(exception)
diff --git a/app/controllers/boards/lists_controller.rb b/app/controllers/boards/lists_controller.rb
index d0f4904c34e..880f7500708 100644
--- a/app/controllers/boards/lists_controller.rb
+++ b/app/controllers/boards/lists_controller.rb
@@ -9,13 +9,15 @@ module Boards
skip_before_action :authenticate_user!, only: [:index]
def index
- lists = Boards::Lists::ListService.new(board.parent, current_user).execute(board)
+ lists = Boards::Lists::ListService.new(board.resource_parent, current_user).execute(board)
+
+ List.preload_preferences_for_user(lists, current_user)
render json: serialize_as_json(lists)
end
def create
- list = Boards::Lists::CreateService.new(board.parent, current_user, create_list_params).execute(board)
+ list = Boards::Lists::CreateService.new(board.resource_parent, current_user, create_list_params).execute(board)
if list.valid?
render json: serialize_as_json(list)
@@ -51,7 +53,10 @@ module Boards
service = Boards::Lists::GenerateService.new(board_parent, current_user)
if service.execute(board)
- lists = board.lists.movable.preload_associations(current_user)
+ lists = board.lists.movable.preload_associations
+
+ List.preload_preferences_for_user(lists, current_user)
+
render json: serialize_as_json(lists)
else
head :unprocessable_entity
@@ -64,12 +69,16 @@ module Boards
%i[label_id]
end
+ def list_update_attrs
+ %i[collapsed position]
+ end
+
def create_list_params
params.require(:list).permit(list_creation_attrs)
end
def update_list_params
- params.require(:list).permit(:collapsed, :position)
+ params.require(:list).permit(list_update_attrs)
end
def serialize_as_json(resource)
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index 15f1e8284ff..993aba661f3 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -170,6 +170,7 @@ class Clusters::ClustersController < Clusters::BaseController
:zone,
:num_nodes,
:machine_type,
+ :cloud_run,
:legacy_abac
]).merge(
provider_type: :gcp,
diff --git a/app/controllers/concerns/cycle_analytics_params.rb b/app/controllers/concerns/cycle_analytics_params.rb
index b970bdc544e..1645af695be 100644
--- a/app/controllers/concerns/cycle_analytics_params.rb
+++ b/app/controllers/concerns/cycle_analytics_params.rb
@@ -3,8 +3,20 @@
module CycleAnalyticsParams
extend ActiveSupport::Concern
+ def cycle_analytics_project_params
+ return {} unless params[:cycle_analytics].present?
+
+ params[:cycle_analytics].permit(:start_date, :created_after, :created_before, :branch_name)
+ end
+
+ def cycle_analytics_group_params
+ return {} unless params[:cycle_analytics].present?
+
+ params[:cycle_analytics].permit(:start_date, :created_after, :created_before, project_ids: [])
+ end
+
def options(params)
- @options ||= { from: start_date(params), current_user: current_user }
+ @options ||= { from: start_date(params), current_user: current_user }.merge(date_range(params))
end
def start_date(params)
@@ -17,6 +29,17 @@ module CycleAnalyticsParams
90.days.ago
end
end
+
+ def date_range(params)
+ {}.tap do |date_range_params|
+ date_range_params[:from] = to_utc_time(params[:created_after]).beginning_of_day if params[:created_after]
+ date_range_params[:to] = to_utc_time(params[:created_before]).end_of_day if params[:created_before]
+ end.compact
+ end
+
+ def to_utc_time(field)
+ Date.parse(field).to_time.utc
+ end
end
CycleAnalyticsParams.prepend_if_ee('EE::CycleAnalyticsParams')
diff --git a/app/controllers/concerns/enforces_admin_authentication.rb b/app/controllers/concerns/enforces_admin_authentication.rb
index 3ef92730df6..e731211f423 100644
--- a/app/controllers/concerns/enforces_admin_authentication.rb
+++ b/app/controllers/concerns/enforces_admin_authentication.rb
@@ -14,6 +14,16 @@ module EnforcesAdminAuthentication
end
def authenticate_admin!
- render_404 unless current_user.admin?
+ return render_404 unless current_user.admin?
+ return unless Feature.enabled?(:user_mode_in_session)
+
+ unless current_user_mode.admin_mode?
+ store_location_for(:redirect, request.fullpath) if storable_location?
+ redirect_to(new_admin_session_path, notice: _('Re-authentication required'))
+ end
+ end
+
+ def storable_location?
+ request.path != new_admin_session_path
end
end
diff --git a/app/controllers/concerns/invisible_captcha.rb b/app/controllers/concerns/invisible_captcha.rb
index 45c0a5c58ef..d56f1d7fa5f 100644
--- a/app/controllers/concerns/invisible_captcha.rb
+++ b/app/controllers/concerns/invisible_captcha.rb
@@ -8,7 +8,7 @@ module InvisibleCaptcha
end
def on_honeypot_spam_callback
- return unless Feature.enabled?(:invisible_captcha)
+ return unless Feature.enabled?(:invisible_captcha) || experiment_enabled?(:signup_flow)
invisible_captcha_honeypot_counter.increment
log_request('Invisible_Captcha_Honeypot_Request')
@@ -17,7 +17,7 @@ module InvisibleCaptcha
end
def on_timestamp_spam_callback
- return unless Feature.enabled?(:invisible_captcha)
+ return unless Feature.enabled?(:invisible_captcha) || experiment_enabled?(:signup_flow)
invisible_captcha_timestamp_counter.increment
log_request('Invisible_Captcha_Timestamp_Request')
diff --git a/app/controllers/concerns/metrics_dashboard.rb b/app/controllers/concerns/metrics_dashboard.rb
new file mode 100644
index 00000000000..62efdacb710
--- /dev/null
+++ b/app/controllers/concerns/metrics_dashboard.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+# Provides an action which fetches a metrics dashboard according
+# to the parameters specified by the controller.
+module MetricsDashboard
+ extend ActiveSupport::Concern
+
+ def metrics_dashboard
+ result = dashboard_finder.find(
+ project_for_dashboard,
+ current_user,
+ metrics_dashboard_params
+ )
+
+ if include_all_dashboards?
+ result[:all_dashboards] = dashboard_finder.find_all_paths(project_for_dashboard)
+ end
+
+ respond_to do |format|
+ if result[:status] == :success
+ format.json { render dashboard_success_response(result) }
+ else
+ format.json { render dashboard_error_response(result) }
+ end
+ end
+ end
+
+ private
+
+ # Override in class to provide arguments to the finder.
+ def metrics_dashboard_params
+ {}
+ end
+
+ # Override in class if response requires complete list of
+ # dashboards in addition to requested dashboard body.
+ def include_all_dashboards?
+ false
+ end
+
+ def dashboard_finder
+ ::Gitlab::Metrics::Dashboard::Finder
+ end
+
+ # Project is not defined for group and admin level clusters.
+ def project_for_dashboard
+ defined?(project) ? project : nil
+ end
+
+ def dashboard_success_response(result)
+ {
+ status: :ok,
+ json: result.slice(:all_dashboards, :dashboard, :status)
+ }
+ end
+
+ def dashboard_error_response(result)
+ {
+ status: result[:http_status],
+ json: result.slice(:all_dashboards, :message, :status)
+ }
+ end
+end
diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb
index 1ead631663e..672d31ec779 100644
--- a/app/controllers/concerns/milestone_actions.rb
+++ b/app/controllers/concerns/milestone_actions.rb
@@ -35,7 +35,7 @@ module MilestoneActions
render json: tabs_json("shared/milestones/_labels_tab", {
labels: milestone_labels.map do |label|
- label.present(issuable_subject: @milestone.parent)
+ label.present(issuable_subject: @milestone.resource_parent)
end
})
end
diff --git a/app/controllers/concerns/render_service_results.rb b/app/controllers/concerns/render_service_results.rb
new file mode 100644
index 00000000000..0149a71d9f5
--- /dev/null
+++ b/app/controllers/concerns/render_service_results.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module RenderServiceResults
+ extend ActiveSupport::Concern
+
+ def success_response(result)
+ render({
+ status: result[:http_status],
+ json: result[:body]
+ })
+ end
+
+ def continue_polling_response
+ render({
+ status: :no_content,
+ json: {
+ status: _('processing'),
+ message: _('Not ready yet. Try again later.')
+ }
+ })
+ end
+
+ def error_response(result)
+ render({
+ status: result[:http_status] || :bad_request,
+ json: { status: result[:status], message: result[:message] }
+ })
+ end
+end
diff --git a/app/controllers/concerns/renders_assignees.rb b/app/controllers/concerns/renders_assignees.rb
new file mode 100644
index 00000000000..e9583a7a530
--- /dev/null
+++ b/app/controllers/concerns/renders_assignees.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module RendersAssignees
+ def preload_assignees_for_render(merge_request)
+ merge_request.project.team.max_member_access_for_user_ids(merge_request.assignees.map(&:id))
+ end
+end
diff --git a/app/controllers/concerns/sessionless_authentication.rb b/app/controllers/concerns/sessionless_authentication.rb
index ba06384a37a..f644923443b 100644
--- a/app/controllers/concerns/sessionless_authentication.rb
+++ b/app/controllers/concerns/sessionless_authentication.rb
@@ -5,6 +5,12 @@
# Controller concern to handle PAT, RSS, and static objects token authentication methods
#
module SessionlessAuthentication
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :enable_admin_mode!, if: :sessionless_user?
+ end
+
# This filter handles personal access tokens, atom requests with rss tokens, and static object tokens
def authenticate_sessionless_user!(request_format)
user = Gitlab::Auth::RequestAuthenticator.new(request).find_sessionless_user(request_format)
@@ -25,4 +31,8 @@ module SessionlessAuthentication
sign_in(user, store: false, message: :sessionless_sign_in)
end
end
+
+ def enable_admin_mode!
+ current_user_mode.enable_admin_mode!(skip_password_validation: true) if Feature.enabled?(:user_mode_in_session)
+ end
end
diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb
index 60a68cec3c3..6d9ee39f841 100644
--- a/app/controllers/concerns/uploads_actions.rb
+++ b/app/controllers/concerns/uploads_actions.rb
@@ -29,15 +29,17 @@ module UploadsActions
def show
return render_404 unless uploader&.exists?
- if cache_publicly?
- # We need to reset caching from the applications controller to get rid of the no-store value
- headers['Cache-Control'] = ''
- expires_in 5.minutes, public: true, must_revalidate: false
- else
- expires_in 0.seconds, must_revalidate: true, private: true
- end
+ # We need to reset caching from the applications controller to get rid of the no-store value
+ headers['Cache-Control'] = ''
+ headers['Pragma'] = ''
+
+ ttl, directives = *cache_settings
+ ttl ||= 6.months
+ directives ||= { private: true, must_revalidate: true }
+
+ expires_in ttl, directives
- disposition = uploader.image_or_video? ? 'inline' : 'attachment'
+ disposition = uploader.embeddable? ? 'inline' : 'attachment'
uploaders = [uploader, *uploader.versions.values]
uploader = uploaders.find { |version| version.filename == params[:filename] }
@@ -91,7 +93,7 @@ module UploadsActions
upload_paths = uploader.upload_paths(params[:filename])
upload = Upload.find_by(model: model, uploader: uploader_class.to_s, path: upload_paths)
- upload&.build_uploader
+ upload&.retrieve_uploader
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -112,16 +114,16 @@ module UploadsActions
uploader
end
- def image_or_video?
- uploader && uploader.exists? && uploader.image_or_video?
+ def embeddable?
+ uploader && uploader.exists? && uploader.embeddable?
end
def find_model
nil
end
- def cache_publicly?
- false
+ def cache_settings
+ []
end
def model
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index 7012bfcefe3..80c0a0d88a8 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -78,8 +78,8 @@ class Dashboard::TodosController < Dashboard::ApplicationController
def todos_counts
{
- count: number_with_delimiter(current_user.todos_pending_count),
- done_count: number_with_delimiter(current_user.todos_done_count)
+ count: current_user.todos_pending_count,
+ done_count: current_user.todos_done_count
}
end
diff --git a/app/controllers/explore/snippets_controller.rb b/app/controllers/explore/snippets_controller.rb
index d4c6aae2ca8..61068df77d1 100644
--- a/app/controllers/explore/snippets_controller.rb
+++ b/app/controllers/explore/snippets_controller.rb
@@ -5,7 +5,7 @@ class Explore::SnippetsController < Explore::ApplicationController
include Gitlab::NoteableMetadata
def index
- @snippets = SnippetsFinder.new(current_user)
+ @snippets = SnippetsFinder.new(current_user, explore: true)
.execute
.page(params[:page])
.inc_author
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index 40b8d5ed72c..3c86f3108ab 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -5,6 +5,9 @@ class Groups::BoardsController < Groups::ApplicationController
include RecordUserLastActivity
before_action :assign_endpoint_vars
+ before_action do
+ push_frontend_feature_flag(:multi_select_board)
+ end
private
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index 1eacae06457..1e9d51cf970 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -44,7 +44,7 @@ class Groups::MilestonesController < Groups::ApplicationController
# all projects milestones states at once.
milestones, update_params = get_milestones_for_update
milestones.each do |milestone|
- Milestones::UpdateService.new(milestone.parent, current_user, update_params).execute(milestone)
+ Milestones::UpdateService.new(milestone.resource_parent, current_user, update_params).execute(milestone)
end
redirect_to milestone_path
diff --git a/app/controllers/groups/registry/repositories_controller.rb b/app/controllers/groups/registry/repositories_controller.rb
new file mode 100644
index 00000000000..e09a9e6eb21
--- /dev/null
+++ b/app/controllers/groups/registry/repositories_controller.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+module Groups
+ module Registry
+ class RepositoriesController < Groups::ApplicationController
+ before_action :verify_container_registry_enabled!
+ before_action :authorize_read_container_image!
+ before_action :feature_flag_group_container_registry_browser!
+
+ def index
+ track_event(:list_repositories)
+
+ respond_to do |format|
+ format.html
+ format.json do
+ @images = group.container_repositories.with_api_entity_associations
+
+ render json: ContainerRepositoriesSerializer
+ .new(current_user: current_user)
+ .represent(@images)
+ end
+ end
+ end
+
+ private
+
+ def feature_flag_group_container_registry_browser!
+ render_404 unless Feature.enabled?(:group_container_registry_browser, group)
+ end
+
+ def verify_container_registry_enabled!
+ render_404 unless Gitlab.config.registry.enabled
+ end
+
+ def authorize_read_container_image!
+ return render_404 unless can?(current_user, :read_container_image, group)
+ end
+ end
+ end
+end
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index c465e622de0..0e83d057484 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -5,11 +5,22 @@ module Groups
class CiCdController < Groups::ApplicationController
skip_cross_project_access_check :show
before_action :authorize_admin_group!
+ before_action :authorize_update_max_artifacts_size!, only: [:update]
def show
define_ci_variables
end
+ def update
+ if update_group_service.execute
+ flash[:notice] = s_('GroupSettings|Pipeline settings was updated for the group')
+ else
+ flash[:alert] = s_("GroupSettings|There was a problem updating the pipeline settings: %{error_messages}." % { error_messages: group.errors.full_messages })
+ end
+
+ redirect_to group_settings_ci_cd_path
+ end
+
def reset_registration_token
@group.reset_runners_token!
@@ -40,6 +51,10 @@ module Groups
return render_404 unless can?(current_user, :admin_group, group)
end
+ def authorize_update_max_artifacts_size!
+ return render_404 unless can?(current_user, :update_max_artifacts_size, group)
+ end
+
def auto_devops_params
params.require(:group).permit(:auto_devops_enabled)
end
@@ -47,6 +62,14 @@ module Groups
def auto_devops_service
Groups::AutoDevopsService.new(group, current_user, auto_devops_params)
end
+
+ def update_group_service
+ Groups::UpdateService.new(group, current_user, update_group_params)
+ end
+
+ def update_group_params
+ params.require(:group).permit(:max_artifacts_size)
+ end
end
end
end
diff --git a/app/controllers/groups/uploads_controller.rb b/app/controllers/groups/uploads_controller.rb
index 7e5cdae0ce3..3ae7e36c740 100644
--- a/app/controllers/groups/uploads_controller.rb
+++ b/app/controllers/groups/uploads_controller.rb
@@ -4,7 +4,7 @@ class Groups::UploadsController < Groups::ApplicationController
include UploadsActions
include WorkhorseRequest
- skip_before_action :group, if: -> { action_name == 'show' && image_or_video? }
+ skip_before_action :group, if: -> { action_name == 'show' && embeddable? }
before_action :authorize_upload_file!, only: [:create, :authorize]
before_action :verify_workhorse_api!, only: [:authorize]
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 95a7876a055..35e364abba3 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -104,7 +104,6 @@ class GroupsController < Groups::ApplicationController
redirect_to edit_group_path(@group, anchor: params[:update_section]), notice: "Group '#{@group.name}' was successfully updated."
else
@group.path = @group.path_before_last_save || @group.path_was
-
render action: "edit"
end
end
@@ -124,7 +123,7 @@ class GroupsController < Groups::ApplicationController
flash[:notice] = "Group '#{@group.name}' was successfully transferred."
redirect_to group_path(@group)
else
- flash[:alert] = service.error
+ flash[:alert] = service.error.html_safe
redirect_to edit_group_path(@group)
end
end
@@ -198,15 +197,13 @@ class GroupsController < Groups::ApplicationController
def load_events
params[:sort] ||= 'latest_activity_desc'
- options = {}
- options[:include_subgroups] = true
-
- @projects = GroupProjectsFinder.new(params: params, group: group, options: options, current_user: current_user)
- .execute
- .includes(:namespace)
+ options = { include_subgroups: true }
+ projects = GroupProjectsFinder.new(params: params, group: group, options: options, current_user: current_user)
+ .execute
+ .includes(:namespace)
@events = EventCollection
- .new(@projects, offset: params[:offset].to_i, filter: event_filter)
+ .new(projects, offset: params[:offset].to_i, filter: event_filter, groups: groups)
.to_a
Events::RenderService
@@ -228,6 +225,14 @@ class GroupsController < Groups::ApplicationController
url_for(safe_params)
end
+
+ private
+
+ def groups
+ if @group.supports_events?
+ @group.self_and_descendants.public_or_visible_to_user(current_user)
+ end
+ end
end
GroupsController.prepend_if_ee('EE::GroupsController')
diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb
index dc9a52f8da5..efd5f0fc607 100644
--- a/app/controllers/health_controller.rb
+++ b/app/controllers/health_controller.rb
@@ -14,35 +14,25 @@ class HealthController < ActionController::Base
].freeze
def readiness
- results = CHECKS.map { |check| [check.name, check.readiness] }
-
- render_check_results(results)
+ # readiness check is a collection with all above application-level checks
+ render_checks(*CHECKS)
end
def liveness
- results = CHECKS.map { |check| [check.name, check.liveness] }
-
- render_check_results(results)
+ # liveness check is a collection without additional checks
+ render_checks
end
private
- def render_check_results(results)
- flattened = results.flat_map do |name, result|
- if result.is_a?(Gitlab::HealthChecks::Result)
- [[name, result]]
- else
- result.map { |r| [name, r] }
- end
- end
- success = flattened.all? { |name, r| r.success }
-
- response = flattened.map do |name, r|
- info = { status: r.success ? 'ok' : 'failed' }
- info['message'] = r.message if r.message
- info[:labels] = r.labels if r.labels
- [name, info]
- end
- render json: response.to_h, status: success ? :ok : :service_unavailable
+ def render_checks(*checks)
+ result = Gitlab::HealthChecks::Probes::Collection
+ .new(*checks)
+ .execute
+
+ # disable static error pages at the gitlab-workhorse level, we want to see this error response even in production
+ headers["X-GitLab-Custom-Error"] = 1 unless result.success?
+
+ render json: result.json, status: result.http_status
end
end
diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb
index 837c26c630a..a58235790ad 100644
--- a/app/controllers/help_controller.rb
+++ b/app/controllers/help_controller.rb
@@ -40,8 +40,8 @@ class HelpController < ApplicationController
end
end
- # Allow access to images in the doc folder
- format.any(:png, :gif, :jpeg, :mp4) do
+ # Allow access to specific media files in the doc folder
+ format.any(:png, :gif, :jpeg, :mp4, :mp3) do
# Note: We are purposefully NOT using `Rails.root.join`
path = File.join(Rails.root, 'doc', "#{@path}.#{params[:format]}")
diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb
index 293d76ea765..c37e799de62 100644
--- a/app/controllers/import/bitbucket_controller.rb
+++ b/app/controllers/import/bitbucket_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Import::BitbucketController < Import::BaseController
+ include ActionView::Helpers::SanitizeHelper
+
before_action :verify_bitbucket_import_enabled
before_action :bitbucket_auth, except: :callback
@@ -21,7 +23,7 @@ class Import::BitbucketController < Import::BaseController
# rubocop: disable CodeReuse/ActiveRecord
def status
bitbucket_client = Bitbucket::Client.new(credentials)
- repos = bitbucket_client.repos
+ repos = bitbucket_client.repos(filter: sanitized_filter_param)
@repos, @incompatible_repos = repos.partition { |repo| repo.valid? }
@@ -104,4 +106,8 @@ class Import::BitbucketController < Import::BaseController
refresh_token: session[:bitbucket_refresh_token]
}
end
+
+ def sanitized_filter_param
+ @filter ||= sanitize(params[:filter])
+ end
end
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index 72f830fc9a1..c418b11ab13 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -2,6 +2,7 @@
class Import::GithubController < Import::BaseController
include ImportHelper
+ include ActionView::Helpers::SanitizeHelper
before_action :verify_import_enabled
before_action :provider_auth, only: [:status, :realtime_changes, :create]
@@ -55,7 +56,7 @@ class Import::GithubController < Import::BaseController
def realtime_changes
Gitlab::PollingInterval.set_header(response, interval: 3_000)
- render json: find_jobs(provider)
+ render json: already_added_projects.to_json(only: [:id], methods: [:import_status])
end
private
@@ -82,7 +83,7 @@ class Import::GithubController < Import::BaseController
end
def already_added_projects
- @already_added_projects ||= find_already_added_projects(provider)
+ @already_added_projects ||= filtered(find_already_added_projects(provider))
end
def already_added_project_names
@@ -104,7 +105,7 @@ class Import::GithubController < Import::BaseController
end
def client_repos
- @client_repos ||= client.repos
+ @client_repos ||= filtered(client.repos)
end
def verify_import_enabled
@@ -185,6 +186,20 @@ class Import::GithubController < Import::BaseController
def extra_import_params
{}
end
+
+ def sanitized_filter_param
+ @filter ||= sanitize(params[:filter])
+ end
+
+ def filter_attribute
+ :name
+ end
+
+ def filtered(collection)
+ return collection unless sanitized_filter_param
+
+ collection.select { |item| item[filter_attribute].include?(sanitized_filter_param) }
+ end
end
Import::GithubController.prepend_if_ee('EE::Import::GithubController')
diff --git a/app/controllers/notification_settings_controller.rb b/app/controllers/notification_settings_controller.rb
index 43c4f4d220e..c97fec0a6ee 100644
--- a/app/controllers/notification_settings_controller.rb
+++ b/app/controllers/notification_settings_controller.rb
@@ -50,8 +50,6 @@ class NotificationSettingsController < ApplicationController
end
def notification_setting_params_for(source)
- allowed_fields = NotificationSetting.email_events(source).dup
- allowed_fields << :level
- params.require(:notification_setting).permit(allowed_fields)
+ params.require(:notification_setting).permit(NotificationSetting.allowed_fields(source))
end
end
diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb
index ab4ca56bb49..12dc2d1af1c 100644
--- a/app/controllers/oauth/applications_controller.rb
+++ b/app/controllers/oauth/applications_controller.rb
@@ -5,6 +5,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
include Gitlab::Allowable
include PageLayoutHelper
include OauthApplications
+ include Gitlab::Experimentation::ControllerConcern
before_action :verify_user_oauth_applications_enabled, except: :index
before_action :authenticate_user!
diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb
index 705389749d8..e65726dffbf 100644
--- a/app/controllers/oauth/authorizations_controller.rb
+++ b/app/controllers/oauth/authorizations_controller.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
+ include Gitlab::Experimentation::ControllerConcern
layout 'profile'
# Overridden from Doorkeeper::AuthorizationsController to
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index 755ce3463c4..b992972dfb8 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -148,6 +148,11 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
if user.two_factor_enabled? && !auth_user.bypass_two_factor?
prompt_for_two_factor(user)
else
+ if user.deactivated?
+ user.activate
+ flash[:notice] = _('Welcome back! Your account had been deactivated due to inactivity but is now reactivated.')
+ end
+
sign_in_and_redirect(user, event: :authentication)
end
else
diff --git a/app/controllers/profiles/groups_controller.rb b/app/controllers/profiles/groups_controller.rb
index c755bcb718a..04b5ee270dc 100644
--- a/app/controllers/profiles/groups_controller.rb
+++ b/app/controllers/profiles/groups_controller.rb
@@ -5,7 +5,7 @@ class Profiles::GroupsController < Profiles::ApplicationController
def update
group = find_routable!(Group, params[:id])
- notification_setting = current_user.notification_settings.find_by(source: group) # rubocop: disable CodeReuse/ActiveRecord
+ notification_setting = current_user.notification_settings_for(group)
if notification_setting.update(update_params)
flash[:notice] = "Notification settings for #{group.name} saved"
diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb
index 617e5bb7cb3..5f44e55f3ef 100644
--- a/app/controllers/profiles/notifications_controller.rb
+++ b/app/controllers/profiles/notifications_controller.rb
@@ -3,9 +3,14 @@
class Profiles::NotificationsController < Profiles::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def show
- @user = current_user
- @group_notifications = current_user.notification_settings.for_groups.order(:id)
- @project_notifications = current_user.notification_settings.for_projects.order(:id)
+ @user = current_user
+ @group_notifications = current_user.notification_settings.for_groups.order(:id)
+ @group_notifications += GroupsFinder.new(
+ current_user,
+ all_available: false,
+ exclude_group_ids: @group_notifications.select(:source_id)
+ ).execute.map { |group| current_user.notification_settings_for(group, inherit: true) }
+ @project_notifications = current_user.notification_settings.for_projects.order(:id)
@global_notification_setting = current_user.global_notification_setting
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index 958a24b6c0e..2b7571e42b7 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -100,6 +100,7 @@ class ProfilesController < Profiles::ApplicationController
:avatar,
:bio,
:email,
+ :role,
:hide_no_password,
:hide_no_ssh_key,
:hide_project_limit,
diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb
index da8a371acaa..50399a8cfbb 100644
--- a/app/controllers/projects/artifacts_controller.rb
+++ b/app/controllers/projects/artifacts_controller.rb
@@ -8,10 +8,37 @@ class Projects::ArtifactsController < Projects::ApplicationController
layout 'project'
before_action :authorize_read_build!
before_action :authorize_update_build!, only: [:keep]
+ before_action :authorize_destroy_artifacts!, only: [:destroy]
before_action :extract_ref_name_and_path
- before_action :validate_artifacts!, except: [:download]
+ before_action :validate_artifacts!, except: [:index, :download, :destroy]
before_action :entry, only: [:file]
+ MAX_PER_PAGE = 20
+
+ def index
+ # Loading artifacts is very expensive in projects with a lot of artifacts.
+ # This feature flag prevents a DOS attack vector.
+ # It should be removed only after resolving the underlying performance
+ # issues: https://gitlab.com/gitlab-org/gitlab/issues/32281
+ return head :no_content unless Feature.enabled?(:artifacts_management_page, @project)
+
+ finder = ArtifactsFinder.new(@project, artifacts_params)
+ all_artifacts = finder.execute
+
+ @artifacts = all_artifacts.page(params[:page]).per(MAX_PER_PAGE)
+ @total_size = all_artifacts.total_size
+ end
+
+ def destroy
+ notice = if artifact.destroy
+ _('Artifact was successfully deleted.')
+ else
+ _('Artifact could not be deleted.')
+ end
+
+ redirect_to project_artifacts_path(@project), status: :see_other, notice: notice
+ end
+
def download
return render_404 unless artifacts_file
@@ -74,6 +101,10 @@ class Projects::ArtifactsController < Projects::ApplicationController
@ref_name, @path = extract_ref(params[:ref_name_and_path])
end
+ def artifacts_params
+ params.permit(:sort)
+ end
+
def validate_artifacts!
render_404 unless build&.artifacts?
end
@@ -85,6 +116,11 @@ class Projects::ArtifactsController < Projects::ApplicationController
end
end
+ def artifact
+ @artifact ||=
+ project.job_artifacts.find(params[:id])
+ end
+
def build_from_id
project.builds.find_by_id(params[:job_id]) if params[:job_id]
end
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index 14b02993e6e..3b335fa4af4 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -7,6 +7,9 @@ class Projects::BoardsController < Projects::ApplicationController
before_action :check_issues_available!
before_action :authorize_read_board!, only: [:index, :show]
before_action :assign_endpoint_vars
+ before_action do
+ push_frontend_feature_flag(:multi_select_board)
+ end
private
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index c125ed3605a..578a3d451a7 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -11,6 +11,7 @@ class Projects::BranchesController < Projects::ApplicationController
# Support legacy URLs
before_action :redirect_for_legacy_index_sort_or_search, only: [:index]
+ before_action :limit_diverging_commit_counts!, only: [:diverging_commit_counts]
def index
respond_to do |format|
@@ -125,6 +126,24 @@ class Projects::BranchesController < Projects::ApplicationController
private
+ # It can be expensive to calculate the diverging counts for each
+ # branch. Normally the frontend should be specifying a set of branch
+ # names, but prior to
+ # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/32496, the
+ # frontend could omit this set. To prevent excessive I/O, we require
+ # that a list of names be specified.
+ def limit_diverging_commit_counts!
+ return unless Feature.enabled?(:limit_diverging_commit_counts, default_enabled: true)
+
+ limit = Kaminari.config.default_per_page
+
+ # If we don't have many branches in the repository, then go ahead.
+ return if project.repository.branch_count <= limit
+ return if params[:names].present? && Array(params[:names]).length <= limit
+
+ render json: { error: "Specify at least one and at most #{limit} branch names" }, status: :unprocessable_entity
+ end
+
def ref
if params[:ref]
ref_escaped = strip_tags(sanitize(params[:ref]))
diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb
index 76705b4410c..15bb35dd0be 100644
--- a/app/controllers/projects/commits_controller.rb
+++ b/app/controllers/projects/commits_controller.rb
@@ -72,7 +72,9 @@ class Projects::CommitsController < Projects::ApplicationController
@repository.commits(@ref, path: @path, limit: @limit, offset: @offset)
end
- @commits = @commits.with_pipeline_status
+ @commits.each(&:lazy_author) # preload authors
+
+ @commits = @commits.with_latest_pipeline(@ref)
@commits = set_commits_for_rendering(@commits)
end
diff --git a/app/controllers/projects/cycle_analytics/events_controller.rb b/app/controllers/projects/cycle_analytics/events_controller.rb
index 926592b9681..673f53c221b 100644
--- a/app/controllers/projects/cycle_analytics/events_controller.rb
+++ b/app/controllers/projects/cycle_analytics/events_controller.rb
@@ -23,7 +23,7 @@ module Projects
end
def test
- options(cycle_analytics_params)[:branch] = cycle_analytics_params[:branch_name]
+ options(cycle_analytics_project_params)[:branch] = cycle_analytics_project_params[:branch_name]
render_events(cycle_analytics[:test].events)
end
@@ -50,13 +50,7 @@ module Projects
end
def cycle_analytics
- @cycle_analytics ||= ::CycleAnalytics::ProjectLevel.new(project, options: options(cycle_analytics_params))
- end
-
- def cycle_analytics_params
- return {} unless params[:cycle_analytics].present?
-
- params[:cycle_analytics].permit(:start_date, :branch_name)
+ @cycle_analytics ||= ::CycleAnalytics::ProjectLevel.new(project, options: options(cycle_analytics_project_params))
end
end
end
diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb
index b9d7dbd37be..f13c75ac4cc 100644
--- a/app/controllers/projects/cycle_analytics_controller.rb
+++ b/app/controllers/projects/cycle_analytics_controller.rb
@@ -9,7 +9,7 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
before_action :authorize_read_cycle_analytics!
def show
- @cycle_analytics = ::CycleAnalytics::ProjectLevel.new(@project, options: options(cycle_analytics_params))
+ @cycle_analytics = ::CycleAnalytics::ProjectLevel.new(@project, options: options(cycle_analytics_project_params))
@cycle_analytics_no_data = @cycle_analytics.no_stats?
@@ -27,12 +27,6 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
private
- def cycle_analytics_params
- return {} unless params[:cycle_analytics].present?
-
- params[:cycle_analytics].permit(:start_date)
- end
-
def cycle_analytics_json
{
summary: @cycle_analytics.summary,
diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb
index 514b03e23b5..f13fb4d0b3d 100644
--- a/app/controllers/projects/deploy_keys_controller.rb
+++ b/app/controllers/projects/deploy_keys_controller.rb
@@ -73,6 +73,10 @@ class Projects::DeployKeysController < Projects::ApplicationController
@deploy_key ||= DeployKey.find(params[:id])
end
+ def deploy_keys_project
+ @deploy_keys_project ||= deploy_key.deploy_keys_project_for(@project)
+ end
+
def create_params
create_params = params.require(:deploy_key)
.permit(:key, :title, deploy_keys_projects_attributes: [:can_push])
@@ -81,10 +85,16 @@ class Projects::DeployKeysController < Projects::ApplicationController
end
def update_params
- params.require(:deploy_key).permit(:title, deploy_keys_projects_attributes: [:id, :can_push])
+ permitted_params = [deploy_keys_projects_attributes: [:id, :can_push]]
+ permitted_params << :title if can?(current_user, :update_deploy_key, deploy_key)
+
+ params.require(:deploy_key).permit(*permitted_params)
end
def authorize_update_deploy_key!
- access_denied! unless can?(current_user, :update_deploy_key, deploy_key)
+ if !can?(current_user, :update_deploy_key, deploy_key) &&
+ !can?(current_user, :update_deploy_keys_project, deploy_keys_project)
+ access_denied!
+ end
end
end
diff --git a/app/controllers/projects/deployments_controller.rb b/app/controllers/projects/deployments_controller.rb
index 32111b07a0b..766e2f86ea2 100644
--- a/app/controllers/projects/deployments_controller.rb
+++ b/app/controllers/projects/deployments_controller.rb
@@ -47,11 +47,9 @@ class Projects::DeploymentsController < Projects::ApplicationController
@deployment_metrics ||= DeploymentMetrics.new(deployment.project, deployment)
end
- # rubocop: disable CodeReuse/ActiveRecord
def deployment
- @deployment ||= environment.deployments.find_by(iid: params[:id])
+ @deployment ||= environment.deployments.find_successful_deployment!(params[:id])
end
- # rubocop: enable CodeReuse/ActiveRecord
def environment
@environment ||= project.environments.find(params[:environment_id])
diff --git a/app/controllers/projects/environments/prometheus_api_controller.rb b/app/controllers/projects/environments/prometheus_api_controller.rb
index 9c6c6513a78..e902d218c75 100644
--- a/app/controllers/projects/environments/prometheus_api_controller.rb
+++ b/app/controllers/projects/environments/prometheus_api_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Projects::Environments::PrometheusApiController < Projects::ApplicationController
+ include RenderServiceResults
+
before_action :authorize_read_prometheus!
before_action :environment
@@ -12,21 +14,10 @@ class Projects::Environments::PrometheusApiController < Projects::ApplicationCon
proxy_params
).execute
- if result.nil?
- return render status: :no_content, json: {
- status: _('processing'),
- message: _('Not ready yet. Try again later.')
- }
- end
-
- if result[:status] == :success
- render status: result[:http_status], json: result[:body]
- else
- render(
- status: result[:http_status] || :bad_request,
- json: { status: result[:status], message: result[:message] }
- )
- end
+ return continue_polling_response if result.nil?
+ return error_response(result) if result[:status] == :error
+
+ success_response(result)
end
private
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index 64de0e665d3..c053ca19a94 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Projects::EnvironmentsController < Projects::ApplicationController
+ include MetricsDashboard
+
layout 'project'
before_action :authorize_read_environment!
before_action :authorize_create_environment!, only: [:new, :create]
@@ -12,7 +14,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :expire_etag_cache, only: [:index]
before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do
push_frontend_feature_flag(:environment_metrics_use_prometheus_endpoint, default_enabled: true)
- push_frontend_feature_flag(:environment_metrics_show_multiple_dashboards)
push_frontend_feature_flag(:environment_metrics_additional_panel_types)
push_frontend_feature_flag(:prometheus_computed_alerts)
end
@@ -159,44 +160,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController
end
end
- def metrics_dashboard
- if params[:embedded]
- result = dashboard_finder.find(
- project,
- current_user,
- environment: environment,
- dashboard_path: params[:dashboard],
- **dashboard_params.to_h.symbolize_keys
- )
- elsif Feature.enabled?(:environment_metrics_show_multiple_dashboards, project)
- result = dashboard_finder.find(
- project,
- current_user,
- environment: environment,
- dashboard_path: params[:dashboard]
- )
-
- result[:all_dashboards] = dashboard_finder.find_all_paths(project)
- else
- result = dashboard_finder.find(project, current_user, environment: environment)
- end
-
- respond_to do |format|
- if result[:status] == :success
- format.json do
- render status: :ok, json: result.slice(:all_dashboards, :dashboard, :status)
- end
- else
- format.json do
- render(
- status: result[:http_status],
- json: result.slice(:all_dashboards, :message, :status)
- )
- end
- end
- end
- end
-
def search
respond_to do |format|
format.json do
@@ -234,12 +197,15 @@ class Projects::EnvironmentsController < Projects::ApplicationController
params.require([:start, :end])
end
- def dashboard_params
- params.permit(:embedded, :group, :title, :y_label)
+ def metrics_dashboard_params
+ params
+ .permit(:embedded, :group, :title, :y_label)
+ .to_h.symbolize_keys
+ .merge(dashboard_path: params[:dashboard], environment: environment)
end
- def dashboard_finder
- Gitlab::Metrics::Dashboard::Finder
+ def include_all_dashboards?
+ !params[:embedded]
end
def search_environment_names
diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb
index a597cc9af32..ccfc38d97b2 100644
--- a/app/controllers/projects/git_http_client_controller.rb
+++ b/app/controllers/projects/git_http_client_controller.rb
@@ -1,7 +1,5 @@
# frozen_string_literal: true
-# This file should be identical in GitLab Community Edition and Enterprise Edition
-
class Projects::GitHttpClientController < Projects::ApplicationController
include ActionController::HttpAuthentication::Basic
include KerberosSpnegoHelper
diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb
index 0c8c03cb16a..93f7ce73a51 100644
--- a/app/controllers/projects/git_http_controller.rb
+++ b/app/controllers/projects/git_http_controller.rb
@@ -6,10 +6,10 @@ class Projects::GitHttpController < Projects::GitHttpClientController
before_action :access_check
prepend_before_action :deny_head_requests, only: [:info_refs]
- rescue_from Gitlab::GitAccess::UnauthorizedError, with: :render_403
- rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404
- rescue_from Gitlab::GitAccess::ProjectCreationError, with: :render_422
- rescue_from Gitlab::GitAccess::TimeoutError, with: :render_503
+ rescue_from Gitlab::GitAccess::UnauthorizedError, with: :render_403_with_exception
+ rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404_with_exception
+ rescue_from Gitlab::GitAccess::ProjectCreationError, with: :render_422_with_exception
+ rescue_from Gitlab::GitAccess::TimeoutError, with: :render_503_with_exception
# GET /foo/bar.git/info/refs?service=git-upload-pack (git pull)
# GET /foo/bar.git/info/refs?service=git-receive-pack (git push)
@@ -58,19 +58,19 @@ class Projects::GitHttpController < Projects::GitHttpClientController
render json: Gitlab::Workhorse.git_http_ok(repository, repo_type, user, action_name)
end
- def render_403(exception)
+ def render_403_with_exception(exception)
render plain: exception.message, status: :forbidden
end
- def render_404(exception)
+ def render_404_with_exception(exception)
render plain: exception.message, status: :not_found
end
- def render_422(exception)
+ def render_422_with_exception(exception)
render plain: exception.message, status: :unprocessable_entity
end
- def render_503(exception)
+ def render_503_with_exception(exception)
render plain: exception.message, status: :service_unavailable
end
diff --git a/app/controllers/projects/grafana_api_controller.rb b/app/controllers/projects/grafana_api_controller.rb
new file mode 100644
index 00000000000..4bdf4c12cac
--- /dev/null
+++ b/app/controllers/projects/grafana_api_controller.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class Projects::GrafanaApiController < Projects::ApplicationController
+ include RenderServiceResults
+
+ def proxy
+ result = ::Grafana::ProxyService.new(
+ project,
+ params[:datasource_id],
+ params[:proxy_path],
+ query_params.to_h
+ ).execute
+
+ return continue_polling_response if result.nil?
+ return error_response(result) if result[:status] == :error
+
+ success_response(result)
+ end
+
+ private
+
+ def query_params
+ params.permit(:query, :start, :end, :step)
+ end
+end
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 7a192a9ec2d..96cb400950b 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -42,6 +42,10 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :authorize_import_issues!, only: [:import_csv]
before_action :authorize_download_code!, only: [:related_branches]
+ before_action do
+ push_frontend_feature_flag(:vue_issuable_sidebar, project.group)
+ end
+
respond_to :html
alias_method :designs, :show
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index 0fdd4d4f33d..1d914ab6011 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -11,8 +11,8 @@ class Projects::JobsController < Projects::ApplicationController
before_action :authorize_erase_build!, only: [:erase]
before_action :authorize_use_build_terminal!, only: [:terminal, :terminal_websocket_authorize]
before_action :verify_api_request!, only: :terminal_websocket_authorize
- before_action only: [:trace] do
- push_frontend_feature_flag(:job_log_json)
+ before_action only: [:show] do
+ push_frontend_feature_flag(:job_log_json, project)
end
layout 'project'
@@ -67,38 +67,27 @@ class Projects::JobsController < Projects::ApplicationController
# rubocop: enable CodeReuse/ActiveRecord
def trace
- if Feature.enabled?(:job_log_json, @project)
- json_trace
- else
- html_trace
- end
- end
-
- def html_trace
build.trace.read do |stream|
respond_to do |format|
format.json do
- result = {
- id: @build.id, status: @build.status, complete: @build.complete?
- }
-
- if stream.valid?
- stream.limit
- state = params[:state].presence
- trace = stream.html_with_state(state)
- result.merge!(trace.to_h)
- end
-
- render json: result
+ # TODO: when the feature flag is removed we should not pass
+ # content_format to serialize method.
+ content_format = Feature.enabled?(:job_log_json, @project) ? :json : :html
+
+ build_trace = Ci::BuildTrace.new(
+ build: @build,
+ stream: stream,
+ state: params[:state],
+ content_format: content_format)
+
+ render json: BuildTraceSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(build_trace)
end
end
end
end
- def json_trace
- # will be implemented with https://gitlab.com/gitlab-org/gitlab-foss/issues/66454
- end
-
def retry
return respond_422 unless @build.retryable?
diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb
index 739f7a2437e..a1983bc5462 100644
--- a/app/controllers/projects/lfs_api_controller.rb
+++ b/app/controllers/projects/lfs_api_controller.rb
@@ -2,6 +2,7 @@
class Projects::LfsApiController < Projects::GitHttpClientController
include LfsRequest
+ include Gitlab::Utils::StrongMemoize
LFS_TRANSFER_CONTENT_TYPE = 'application/octet-stream'
@@ -81,7 +82,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController
download: {
href: "#{project.http_url_to_repo}/gitlab-lfs/objects/#{object[:oid]}",
header: {
- Authorization: request.headers['Authorization']
+ Authorization: authorization_header
}.compact
}
}
@@ -92,7 +93,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController
upload: {
href: "#{project.http_url_to_repo}/gitlab-lfs/objects/#{object[:oid]}/#{object[:size]}",
header: {
- Authorization: request.headers['Authorization'],
+ Authorization: authorization_header,
# git-lfs v2.5.0 sets the Content-Type based on the uploaded file. This
# ensures that Workhorse can intercept the request.
'Content-Type': LFS_TRANSFER_CONTENT_TYPE
@@ -122,6 +123,18 @@ class Projects::LfsApiController < Projects::GitHttpClientController
def lfs_read_only_message
_('You cannot write to this read-only GitLab instance.')
end
+
+ def authorization_header
+ strong_memoize(:authorization_header) do
+ lfs_auth_header || request.headers['Authorization']
+ end
+ end
+
+ def lfs_auth_header
+ return unless user.is_a?(User)
+
+ Gitlab::LfsToken.new(user).basic_encoding
+ end
end
Projects::LfsApiController.prepend_if_ee('EE::Projects::LfsApiController')
diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb
index edffeb32203..b7e99cb7ed0 100644
--- a/app/controllers/projects/merge_requests/application_controller.rb
+++ b/app/controllers/projects/merge_requests/application_controller.rb
@@ -14,7 +14,11 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
end
def merge_request_includes(association)
- association.includes(:metrics, :assignees, author: :status) # rubocop:disable CodeReuse/ActiveRecord
+ association.includes(preloadable_mr_relations) # rubocop:disable CodeReuse/ActiveRecord
+ end
+
+ def preloadable_mr_relations
+ [:metrics, :assignees, { author: :status }]
end
def merge_request_params
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index 9c5caf7719e..4a37dfe5c19 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -5,9 +5,9 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
include RendersNotes
before_action :apply_diff_view_cookie!
- before_action :commit
- before_action :define_diff_vars
- before_action :define_diff_comment_vars
+ before_action :commit, except: :diffs_batch
+ before_action :define_diff_vars, except: :diffs_batch
+ before_action :define_diff_comment_vars, except: [:diffs_batch, :diffs_metadata]
def show
render_diffs
@@ -17,14 +17,41 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
render_diffs
end
+ def diffs_batch
+ return render_404 unless Feature.enabled?(:diffs_batch_load, @merge_request.project)
+
+ diffable = @merge_request.merge_request_diff
+
+ return render_404 unless diffable
+
+ diffs = diffable.diffs_in_batch(params[:page], params[:per_page], diff_options: diff_options)
+ positions = @merge_request.note_positions_for_paths(diffs.diff_file_paths, current_user)
+
+ diffs.unfold_diff_files(positions.unfoldable)
+
+ options = {
+ merge_request: @merge_request,
+ pagination_data: diffs.pagination_data
+ }
+
+ render json: PaginatedDiffSerializer.new(current_user: current_user).represent(diffs, options)
+ end
+
+ def diffs_metadata
+ render json: DiffsMetadataSerializer.new(project: @merge_request.project)
+ .represent(@diffs, additional_attributes)
+ end
+
private
+ def preloadable_mr_relations
+ [{ source_project: :namespace }, { target_project: :namespace }]
+ end
+
def render_diffs
@environment = @merge_request.environments_for(current_user).last
- note_positions = renderable_notes.map(&:position).compact
- @diffs.unfold_diff_files(note_positions)
-
+ @diffs.unfold_diff_files(note_positions.unfoldable)
@diffs.write_cache
request = {
@@ -111,6 +138,10 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
@notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes), @merge_request)
end
+ def note_positions
+ @note_positions ||= Gitlab::Diff::PositionCollection.new(renderable_notes.map(&:position))
+ end
+
def renderable_notes
define_diff_comment_vars unless @notes
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index e51ce752233..ff199e05e99 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -5,6 +5,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
include IssuableActions
include RendersNotes
include RendersCommits
+ include RendersAssignees
include ToggleAwardEmoji
include IssuableCollections
include RecordUserLastActivity
@@ -16,6 +17,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action :set_issuables_index, only: [:index]
before_action :authenticate_user!, only: [:assign_related_issues]
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
+ before_action only: [:show] do
+ push_frontend_feature_flag(:diffs_batch_load, @project)
+ end
+
+ before_action do
+ push_frontend_feature_flag(:vue_issuable_sidebar, @project.group)
+ end
around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions]
@@ -41,6 +49,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
# use next to appease Rubocop
next render('invalid') if target_branch_missing?
+ preload_assignees_for_render(@merge_request)
+
# Build a note object for comment form
@note = @project.notes.new(noteable: @merge_request)
@@ -79,7 +89,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
# Get commits from repository
# or from cache if already merged
@commits =
- set_commits_for_rendering(@merge_request.commits.with_pipeline_status)
+ set_commits_for_rendering(@merge_request.commits.with_latest_pipeline)
render json: { html: view_to_html_string('projects/merge_requests/_commits') }
end
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index cfa46705483..106ef1b72c1 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Projects::PipelinesController < Projects::ApplicationController
+ include ::Gitlab::Utils::StrongMemoize
+
before_action :whitelist_query_limiting, only: [:create, :retry]
before_action :pipeline, except: [:index, :new, :create, :charts]
before_action :set_pipeline_path, only: [:show]
@@ -151,6 +153,19 @@ class Projects::PipelinesController < Projects::ApplicationController
@counts[:failed] = @project.all_pipelines.failed.count(:all)
end
+ def test_report
+ return unless Feature.enabled?(:junit_pipeline_view, project)
+
+ if pipeline_test_report == :error
+ render json: { status: :error_parsing_report }
+ return
+ end
+
+ render json: TestReportSerializer
+ .new(current_user: @current_user)
+ .represent(pipeline_test_report)
+ end
+
private
def serialize_pipelines
@@ -169,7 +184,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def show_represent_params
- { grouped: true }
+ { grouped: true, expanded: params[:expanded].to_a.map(&:to_i) }
end
def create_params
@@ -217,6 +232,14 @@ class Projects::PipelinesController < Projects::ApplicationController
view_context.limited_counter_with_delimiter(finder.execute)
end
+
+ def pipeline_test_report
+ strong_memoize(:pipeline_test_report) do
+ @pipeline.test_reports
+ rescue Gitlab::Ci::Parsers::ParserError
+ :error
+ end
+ end
end
Projects::PipelinesController.prepend_if_ee('EE::Projects::PipelinesController')
diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb
index c5454883060..d4f7d0bc521 100644
--- a/app/controllers/projects/protected_branches_controller.rb
+++ b/app/controllers/projects/protected_branches_controller.rb
@@ -19,9 +19,13 @@ class Projects::ProtectedBranchesController < Projects::ProtectedRefsController
[:merge_access_levels, :push_access_levels]
end
- def protected_ref_params
- params.require(:protected_branch).permit(:name,
- merge_access_levels_attributes: access_level_attributes,
- push_access_levels_attributes: access_level_attributes)
+ def protected_ref_params(*attrs)
+ attrs = ([:name,
+ merge_access_levels_attributes: access_level_attributes,
+ push_access_levels_attributes: access_level_attributes] + attrs).uniq
+
+ params.require(:protected_branch).permit(attrs)
end
end
+
+Projects::ProtectedBranchesController.prepend_if_ee('EE::Projects::ProtectedBranchesController')
diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb
index e205e2fd4f8..9405fd526ae 100644
--- a/app/controllers/projects/registry/repositories_controller.rb
+++ b/app/controllers/projects/registry/repositories_controller.rb
@@ -8,6 +8,7 @@ module Projects
def index
@images = project.container_repositories
+ track_event(:list_repositories)
respond_to do |format|
format.html
@@ -21,6 +22,7 @@ module Projects
def destroy
DeleteContainerRepositoryWorker.perform_async(current_user.id, image.id)
+ track_event(:delete_repository)
respond_to do |format|
format.json { head :no_content }
diff --git a/app/controllers/projects/registry/tags_controller.rb b/app/controllers/projects/registry/tags_controller.rb
index 54e2faa2dd7..e572c56adf5 100644
--- a/app/controllers/projects/registry/tags_controller.rb
+++ b/app/controllers/projects/registry/tags_controller.rb
@@ -8,6 +8,7 @@ module Projects
LIMIT = 15
def index
+ track_event(:list_tags)
respond_to do |format|
format.json do
render json: ContainerTagsSerializer
@@ -19,14 +20,13 @@ module Projects
end
def destroy
- if tag.delete
- respond_to do |format|
- format.json { head :no_content }
- end
- else
- respond_to do |format|
- format.json { head :bad_request }
- end
+ result = Projects::ContainerRepository::DeleteTagsService
+ .new(image.project, current_user, tags: [params[:id]])
+ .execute(image)
+ track_event(:delete_tag)
+
+ respond_to do |format|
+ format.json { head(result[:status] == :success ? :ok : bad_request) }
end
end
@@ -42,21 +42,13 @@ module Projects
return
end
- @tags = tag_names.map { |tag_name| image.tag(tag_name) }
- unless @tags.all? { |tag| tag.valid_name? }
- head :bad_request
- return
- end
-
- success_count = 0
- @tags.each do |tag|
- if tag.delete
- success_count += 1
- end
- end
+ result = Projects::ContainerRepository::DeleteTagsService
+ .new(image.project, current_user, tags: tag_names)
+ .execute(image)
+ track_event(:delete_tag_bulk)
respond_to do |format|
- format.json { head(success_count == @tags.size ? :no_content : :bad_request) }
+ format.json { head(result[:status] == :success ? :no_content : :bad_request) }
end
end
@@ -70,10 +62,6 @@ module Projects
@image ||= project.container_repositories
.find(params[:repository_id])
end
-
- def tag
- @tag ||= image.tag(params[:id])
- end
end
end
end
diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb
index 4c39ee4045f..717df9f09e0 100644
--- a/app/controllers/projects/releases_controller.rb
+++ b/app/controllers/projects/releases_controller.rb
@@ -4,6 +4,9 @@ class Projects::ReleasesController < Projects::ApplicationController
# Authorize
before_action :require_non_empty_project
before_action :authorize_read_release!
+ before_action do
+ push_frontend_feature_flag(:release_edit_page, project)
+ end
def index
end
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index 0d61c3cc031..cfed8727450 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -46,13 +46,19 @@ module Projects
private
def update_params
- params.require(:project).permit(
+ params.require(:project).permit(*permitted_project_params)
+ end
+
+ def permitted_project_params
+ [
:runners_token, :builds_enabled, :build_allow_git_fetch,
:build_timeout_human_readable, :build_coverage_regex, :public_builds,
:auto_cancel_pending_pipelines, :ci_config_path,
auto_devops_attributes: [:id, :domain, :enabled, :deploy_strategy],
ci_cd_settings_attributes: [:default_git_depth]
- )
+ ].tap do |list|
+ list << :max_artifacts_size if can?(current_user, :update_max_artifacts_size, project)
+ end
end
def run_autodevops_pipeline(service)
diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb
index 7c71486a765..5bf3618b389 100644
--- a/app/controllers/projects/settings/operations_controller.rb
+++ b/app/controllers/projects/settings/operations_controller.rb
@@ -13,9 +13,14 @@ module Projects
def update
result = ::Projects::Operations::UpdateService.new(project, current_user, update_params).execute
+ track_events(result)
render_update_response(result)
end
+ # overridden in EE
+ def track_events(result)
+ end
+
private
# overridden in EE
@@ -63,7 +68,9 @@ module Projects
:api_host,
:token,
project: [:slug, :name, :organization_slug, :organization_name]
- ]
+ ],
+
+ grafana_integration_attributes: [:token, :grafana_url]
}
end
end
diff --git a/app/controllers/projects/templates_controller.rb b/app/controllers/projects/templates_controller.rb
index f987033a26c..95739f96d39 100644
--- a/app/controllers/projects/templates_controller.rb
+++ b/app/controllers/projects/templates_controller.rb
@@ -13,6 +13,14 @@ class Projects::TemplatesController < Projects::ApplicationController
end
end
+ def names
+ templates = @template_type.dropdown_names(project)
+
+ respond_to do |format|
+ format.json { render json: templates }
+ end
+ end
+
private
# User must have:
diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb
index 4ffcc2ac805..3e5a1cfc74d 100644
--- a/app/controllers/projects/uploads_controller.rb
+++ b/app/controllers/projects/uploads_controller.rb
@@ -6,7 +6,7 @@ class Projects::UploadsController < Projects::ApplicationController
# These will kick you out if you don't have access.
skip_before_action :project, :repository,
- if: -> { action_name == 'show' && image_or_video? }
+ if: -> { action_name == 'show' && embeddable? }
before_action :authorize_upload_file!, only: [:create, :authorize]
before_action :verify_workhorse_api!, only: [:authorize]
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index b8beecf823c..abd19df9a3d 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -376,6 +376,7 @@ class ProjectsController < Projects::ApplicationController
:tag_list,
:visibility_level,
:template_name,
+ :template_project_id,
:merge_method,
:initialize_with_readme,
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 2e4c6a801b0..4a746fc915d 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -6,13 +6,20 @@ class RegistrationsController < Devise::RegistrationsController
include RecaptchaExperimentHelper
include InvisibleCaptcha
+ layout :choose_layout
+
+ skip_before_action :require_role, only: [:welcome, :update_role]
prepend_before_action :check_captcha, only: :create
before_action :whitelist_query_limiting, only: [:destroy]
before_action :ensure_terms_accepted,
if: -> { action_name == 'create' && Gitlab::CurrentSettings.current_application_settings.enforce_terms? }
def new
- redirect_to(new_user_session_path)
+ if experiment_enabled?(:signup_flow)
+ @resource = build_resource
+ else
+ redirect_to new_user_session_path(anchor: 'register-pane')
+ end
end
def create
@@ -20,8 +27,13 @@ class RegistrationsController < Devise::RegistrationsController
super do |new_user|
persist_accepted_terms_if_required(new_user)
+ set_role_required(new_user)
yield new_user if block_given?
end
+
+ # Do not show the signed_up notice message when the signup_flow experiment is enabled.
+ # Instead, show it after succesfully updating the role.
+ flash[:notice] = nil if experiment_enabled?(:signup_flow)
rescue Gitlab::Access::AccessDeniedError
redirect_to(new_user_session_path)
end
@@ -36,6 +48,26 @@ class RegistrationsController < Devise::RegistrationsController
end
end
+ def welcome
+ return redirect_to new_user_registration_path unless current_user
+ return redirect_to stored_location_or_dashboard_or_almost_there_path(current_user) if current_user.role.present?
+
+ current_user.name = nil
+ render layout: 'devise_experimental_separate_sign_up_flow'
+ end
+
+ def update_role
+ user_params = params.require(:user).permit(:name, :role)
+ result = ::Users::UpdateService.new(current_user, user_params.merge(user: current_user)).execute
+
+ if result[:status] == :success
+ set_flash_message! :notice, :signed_up
+ redirect_to stored_location_or_dashboard_or_almost_there_path(current_user)
+ else
+ redirect_to users_sign_up_welcome_path, alert: result[:message]
+ end
+ end
+
protected
def persist_accepted_terms_if_required(new_user)
@@ -48,6 +80,10 @@ class RegistrationsController < Devise::RegistrationsController
end
end
+ def set_role_required(new_user)
+ new_user.set_role_required! if new_user.persisted? && experiment_enabled?(:signup_flow)
+ end
+
def destroy_confirmation_valid?
if current_user.confirm_deletion_with_password?
current_user.valid_password?(params[:password])
@@ -70,7 +106,10 @@ class RegistrationsController < Devise::RegistrationsController
def after_sign_up_path_for(user)
Gitlab::AppLogger.info(user_created_message(confirmed: user.confirmed?))
- confirmed_or_unconfirmed_access_allowed(user) ? stored_location_or_dashboard(user) : users_almost_there_path
+
+ return users_sign_up_welcome_path if experiment_enabled?(:signup_flow)
+
+ stored_location_or_dashboard_or_almost_there_path(user)
end
def after_inactive_sign_up_path_for(resource)
@@ -97,6 +136,7 @@ class RegistrationsController < Devise::RegistrationsController
ensure_correct_params!
return unless Feature.enabled?(:registrations_recaptcha, default_enabled: true) # reCAPTCHA on the UI will still display however
+ return if experiment_enabled?(:signup_flow) # when the experimental signup flow is enabled for the current user, disable the reCAPTCHA check
return unless show_recaptcha_sign_up?
return unless Gitlab::Recaptcha.load_configurations!
@@ -108,7 +148,13 @@ class RegistrationsController < Devise::RegistrationsController
end
def sign_up_params
- params.require(:user).permit(:username, :email, :email_confirmation, :name, :password)
+ clean_params = params.require(:user).permit(:username, :email, :email_confirmation, :name, :password)
+
+ if experiment_enabled?(:signup_flow)
+ clean_params[:name] = clean_params[:username]
+ end
+
+ clean_params
end
def resource_name
@@ -138,12 +184,26 @@ class RegistrationsController < Devise::RegistrationsController
end
def confirmed_or_unconfirmed_access_allowed(user)
- user.confirmed? || Feature.enabled?(:soft_email_confirmation)
+ user.confirmed? || Feature.enabled?(:soft_email_confirmation) || experiment_enabled?(:signup_flow)
end
def stored_location_or_dashboard(user)
stored_location_for(user) || dashboard_projects_path
end
+
+ def stored_location_or_dashboard_or_almost_there_path(user)
+ confirmed_or_unconfirmed_access_allowed(user) ? stored_location_or_dashboard(user) : users_almost_there_path
+ end
+
+ # Part of an experiment to build a new sign up flow. Will be resolved
+ # with https://gitlab.com/gitlab-org/growth/engineering/issues/64
+ def choose_layout
+ if experiment_enabled?(:signup_flow)
+ 'devise_experimental_separate_sign_up_flow'
+ else
+ 'devise'
+ end
+ end
end
RegistrationsController.prepend_if_ee('EE::RegistrationsController')
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index f8da152e3d2..1c506065b56 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -57,8 +57,14 @@ class SessionsController < Devise::SessionsController
reset_password_sent_at: nil)
end
- # hide the signed-in notification
- flash[:notice] = nil
+ if resource.deactivated?
+ resource.activate
+ flash[:notice] = _('Welcome back! Your account had been deactivated due to inactivity but is now reactivated.')
+ else
+ # hide the default signed-in notification
+ flash[:notice] = nil
+ end
+
log_audit_event(current_user, resource, with: authentication_method)
log_user_activity(current_user)
end
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index 2adfeab182e..635db386792 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -81,8 +81,13 @@ class UploadsController < ApplicationController
end
end
- def cache_publicly?
- User === model || Appearance === model
+ def cache_settings
+ case model
+ when User, Appearance
+ [5.minutes, { public: true, must_revalidate: false }]
+ when Project, Group
+ [5.minutes, { private: true, must_revalidate: true }]
+ end
end
def secret?
diff --git a/app/finders/artifacts_finder.rb b/app/finders/artifacts_finder.rb
new file mode 100644
index 00000000000..81c5168d782
--- /dev/null
+++ b/app/finders/artifacts_finder.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class ArtifactsFinder
+ def initialize(project, params = {})
+ @project = project
+ @params = params
+ end
+
+ def execute
+ artifacts = @project.job_artifacts
+
+ sort(artifacts)
+ end
+
+ private
+
+ def sort_key
+ @params[:sort] || 'created_desc'
+ end
+
+ def sort(artifacts)
+ artifacts.order_by(sort_key)
+ end
+end
diff --git a/app/finders/clusters/kubernetes_namespace_finder.rb b/app/finders/clusters/kubernetes_namespace_finder.rb
index e947796c1e7..82df96ed79e 100644
--- a/app/finders/clusters/kubernetes_namespace_finder.rb
+++ b/app/finders/clusters/kubernetes_namespace_finder.rb
@@ -2,12 +2,12 @@
module Clusters
class KubernetesNamespaceFinder
- attr_reader :cluster, :project, :environment_slug
+ attr_reader :cluster, :project, :environment_name
- def initialize(cluster, project:, environment_slug:, allow_blank_token: false)
+ def initialize(cluster, project:, environment_name:, allow_blank_token: false)
@cluster = cluster
@project = project
- @environment_slug = environment_slug
+ @environment_name = environment_name
@allow_blank_token = allow_blank_token
end
@@ -20,7 +20,11 @@ module Clusters
attr_reader :allow_blank_token
def find_namespace(with_environment:)
- relation = with_environment ? namespaces.with_environment_slug(environment_slug) : namespaces
+ relation = if with_environment
+ namespaces.with_environment_name(environment_name)
+ else
+ namespaces
+ end
relation.find_by_project_id(project.id)
end
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 2364777cdc5..477093ddadf 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -161,7 +161,7 @@ class IssuableFinder
labels_count = label_names.any? ? label_names.count : 1
labels_count = 1 if use_cte_for_search?
- finder.execute.reorder(nil).group(:state).count.each do |key, value|
+ finder.execute.reorder(nil).group(:state_id).count.each do |key, value|
counts[count_key(key)] += value / labels_count
end
@@ -385,7 +385,8 @@ class IssuableFinder
end
def count_key(value)
- Array(value).last.to_sym
+ value = Array(value).last
+ klass.available_states.key(value)
end
# Negates all params found in `negatable_params`
@@ -444,7 +445,6 @@ class IssuableFinder
items
end
- # rubocop: disable CodeReuse/ActiveRecord
def by_state(items)
case params[:state].to_s
when 'closed'
@@ -454,12 +454,11 @@ class IssuableFinder
when 'opened'
items.opened
when 'locked'
- items.where(state: 'locked')
+ items.with_state(:locked)
else
items
end
end
- # rubocop: enable CodeReuse/ActiveRecord
def by_group(items)
# Selection by group is already covered by `by_project` and `projects`
diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb
index bf29f15642d..bd6b6190fb5 100644
--- a/app/finders/snippets_finder.rb
+++ b/app/finders/snippets_finder.rb
@@ -41,13 +41,14 @@
class SnippetsFinder < UnionFinder
include FinderMethods
- attr_accessor :current_user, :project, :author, :scope
+ attr_accessor :current_user, :project, :author, :scope, :explore
def initialize(current_user = nil, params = {})
@current_user = current_user
@project = params[:project]
@author = params[:author]
@scope = params[:scope].to_s
+ @explore = params[:explore]
if project && author
raise(
@@ -59,18 +60,30 @@ class SnippetsFinder < UnionFinder
end
def execute
- base =
- if project
- snippets_for_a_single_project
- else
- snippets_for_multiple_projects
- end
-
+ base = init_collection
base.with_optional_visibility(visibility_from_scope).fresh
end
private
+ def init_collection
+ if explore
+ snippets_for_explore
+ elsif project
+ snippets_for_a_single_project
+ else
+ snippets_for_multiple_projects
+ end
+ end
+
+ # Produces a query that retrieves snippets for the Explore page
+ #
+ # We only show personal snippets here because this page is meant for
+ # discovery, and project snippets are of limited interest here.
+ def snippets_for_explore
+ Snippet.public_to_user(current_user).only_personal_snippets
+ end
+
# Produces a query that retrieves snippets from multiple projects.
#
# The resulting query will, depending on the user's permissions, include the
@@ -84,7 +97,7 @@ class SnippetsFinder < UnionFinder
# Each collection is constructed in isolation, allowing for greater control
# over the resulting SQL query.
def snippets_for_multiple_projects
- queries = [global_snippets]
+ queries = [personal_snippets]
if Ability.allowed?(current_user, :read_cross_project)
queries << snippets_of_visible_projects
@@ -98,8 +111,8 @@ class SnippetsFinder < UnionFinder
Snippet.for_project_with_user(project, current_user)
end
- def global_snippets
- snippets_for_author_or_visible_to_user.only_global_snippets
+ def personal_snippets
+ snippets_for_author_or_visible_to_user.only_personal_snippets
end
# Returns the snippets that the current user (logged in or not) can view.
@@ -115,7 +128,7 @@ class SnippetsFinder < UnionFinder
# This method requires that `current_user` returns a `User` instead of `nil`,
# and is optimised for this specific scenario.
def snippets_of_authorized_projects
- base = author ? snippets_for_author : Snippet.all
+ base = author ? author.snippets : Snippet.all
base
.only_include_projects_with_snippets_enabled(include_private: true)
@@ -157,3 +170,5 @@ class SnippetsFinder < UnionFinder
end
end
end
+
+SnippetsFinder.prepend_if_ee('EE::SnippetsFinder')
diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb
index 427fd3e7d85..2b46e51290f 100644
--- a/app/finders/todos_finder.rb
+++ b/app/finders/todos_finder.rb
@@ -33,6 +33,8 @@ class TodosFinder
end
def execute
+ return Todo.none if current_user.nil?
+
items = current_user.todos
items = by_action_id(items)
items = by_action(items)
@@ -65,8 +67,20 @@ class TodosFinder
params[:action_id]
end
+ def action_array_provided?
+ params[:action].is_a?(Array)
+ end
+
+ def map_actions_to_ids
+ params[:action].map { |item| Todo::ACTION_NAMES.key(item.to_sym) }
+ end
+
def to_action_id
- Todo::ACTION_NAMES.key(action.to_sym)
+ if action_array_provided?
+ map_actions_to_ids
+ else
+ Todo::ACTION_NAMES.key(action.to_sym)
+ end
end
def action?
@@ -133,9 +147,19 @@ class TodosFinder
end
end
+ def action_id_array_provided?
+ params[:action_id].is_a?(Array) && params[:action_id].any?
+ end
+
+ def by_action_ids(items)
+ items.for_action(action_id)
+ end
+
def by_action_id(items)
+ return by_action_ids(items) if action_id_array_provided?
+
if action_id?
- items.for_action(action_id)
+ by_action_ids(items)
else
items
end
@@ -158,11 +182,9 @@ class TodosFinder
end
def by_group(items)
- if group?
- items.for_group_and_descendants(group)
- else
- items
- end
+ return items unless group?
+
+ items.for_group_ids_and_descendants(params[:group_id])
end
def by_state(items)
diff --git a/app/finders/user_finder.rb b/app/finders/user_finder.rb
index 556be4c4338..1dd1a27437e 100644
--- a/app/finders/user_finder.rb
+++ b/app/finders/user_finder.rb
@@ -52,6 +52,12 @@ class UserFinder
end
end
+ def find_by_ssh_key_id
+ return unless input_is_id?
+
+ User.find_by_ssh_key_id(@username_or_id)
+ end
+
def input_is_id?
@username_or_id.is_a?(Numeric) || @username_or_id =~ /^\d+$/
end
diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb
index 7273a74cb86..623f7c27584 100644
--- a/app/graphql/mutations/base_mutation.rb
+++ b/app/graphql/mutations/base_mutation.rb
@@ -5,6 +5,8 @@ module Mutations
prepend Gitlab::Graphql::Authorize::AuthorizeResource
prepend Gitlab::Graphql::CopyFieldDescription
+ ERROR_MESSAGE = 'You cannot perform write operations on a read-only instance'
+
field :errors, [GraphQL::STRING_TYPE],
null: false,
description: "Reasons why the mutation failed."
@@ -17,5 +19,13 @@ module Mutations
def errors_on_object(record)
record.errors.full_messages
end
+
+ def ready?(**args)
+ if Gitlab::Database.read_only?
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, ERROR_MESSAGE
+ else
+ true
+ end
+ end
end
end
diff --git a/app/graphql/mutations/concerns/mutations/resolves_group.rb b/app/graphql/mutations/concerns/mutations/resolves_group.rb
new file mode 100644
index 00000000000..4306ce512f1
--- /dev/null
+++ b/app/graphql/mutations/concerns/mutations/resolves_group.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Mutations
+ module ResolvesGroup
+ extend ActiveSupport::Concern
+
+ def resolve_group(full_path:)
+ resolver.resolve(full_path: full_path)
+ end
+
+ def resolver
+ Resolvers::GroupResolver.new(object: nil, context: context)
+ end
+ end
+end
diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb
index 850df2885aa..1fbc61cd950 100644
--- a/app/graphql/resolvers/issues_resolver.rb
+++ b/app/graphql/resolvers/issues_resolver.rb
@@ -35,7 +35,7 @@ module Resolvers
description: 'Issues closed after this date'
argument :search, GraphQL::STRING_TYPE, # rubocop:disable Graphql/Descriptions
required: false
- argument :sort, Types::SortEnum,
+ argument :sort, Types::IssueSortEnum,
description: 'Sort issues by this criteria',
required: false,
default_value: 'created_desc'
diff --git a/app/graphql/resolvers/last_commit_resolver.rb b/app/graphql/resolvers/last_commit_resolver.rb
new file mode 100644
index 00000000000..7a433d6556f
--- /dev/null
+++ b/app/graphql/resolvers/last_commit_resolver.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class LastCommitResolver < BaseResolver
+ type Types::CommitType, null: true
+
+ alias_method :tree, :object
+
+ def resolve(**args)
+ # Ensure merge commits can be returned by sending nil to Gitaly instead of '/'
+ path = tree.path == '/' ? nil : tree.path
+ commit = Gitlab::Git::Commit.last_for_path(tree.repository, tree.sha, path)
+
+ ::Commit.new(commit, tree.repository.project) if commit
+ end
+ end
+end
diff --git a/app/graphql/resolvers/todo_resolver.rb b/app/graphql/resolvers/todo_resolver.rb
new file mode 100644
index 00000000000..38a4539f34a
--- /dev/null
+++ b/app/graphql/resolvers/todo_resolver.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class TodoResolver < BaseResolver
+ type Types::TodoType, null: true
+
+ alias_method :user, :object
+
+ argument :action, [Types::TodoActionEnum],
+ required: false,
+ description: 'The action to be filtered'
+
+ argument :author_id, [GraphQL::ID_TYPE],
+ required: false,
+ description: 'The ID of an author'
+
+ argument :project_id, [GraphQL::ID_TYPE],
+ required: false,
+ description: 'The ID of a project'
+
+ argument :group_id, [GraphQL::ID_TYPE],
+ required: false,
+ description: 'The ID of a group'
+
+ argument :state, [Types::TodoStateEnum],
+ required: false,
+ description: 'The state of the todo'
+
+ argument :type, [Types::TodoTargetEnum],
+ required: false,
+ description: 'The type of the todo'
+
+ def resolve(**args)
+ return Todo.none if user != context[:current_user]
+
+ TodosFinder.new(user, todo_finder_params(args)).execute
+ end
+
+ private
+
+ # TODO: Support multiple queries for e.g. state and type on TodosFinder:
+ #
+ # https://gitlab.com/gitlab-org/gitlab/merge_requests/18487
+ # https://gitlab.com/gitlab-org/gitlab/merge_requests/18518
+ #
+ # As soon as these MR's are merged, we can refactor this to query by
+ # multiple contents.
+ #
+ def todo_finder_params(args)
+ {
+ state: first_state(args),
+ type: first_type(args),
+ group_id: first_group_id(args),
+ author_id: first_author_id(args),
+ action_id: first_action(args),
+ project_id: first_project(args)
+ }
+ end
+
+ def first_project(args)
+ first_query_field(args, :project_id)
+ end
+
+ def first_action(args)
+ first_query_field(args, :action)
+ end
+
+ def first_author_id(args)
+ first_query_field(args, :author_id)
+ end
+
+ def first_group_id(args)
+ first_query_field(args, :group_id)
+ end
+
+ def first_state(args)
+ first_query_field(args, :state)
+ end
+
+ def first_type(args)
+ first_query_field(args, :type)
+ end
+
+ def first_query_field(query, field)
+ return unless query.key?(field)
+
+ query[field].first if query[field].respond_to?(:first)
+ end
+ end
+end
diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb
index dd2d81adb8b..fe71791f413 100644
--- a/app/graphql/types/commit_type.rb
+++ b/app/graphql/types/commit_type.rb
@@ -15,6 +15,8 @@ module Types
field :message, type: GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions
field :authored_date, type: Types::TimeType, null: true # rubocop:disable Graphql/Descriptions
field :web_url, type: GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions
+ field :signature_html, type: GraphQL::STRING_TYPE,
+ null: true, calls_gitaly: true, description: 'Rendered html for the commit signature'
# models/commit lazy loads the author by email
field :author, type: Types::UserType, null: true # rubocop:disable Graphql/Descriptions
diff --git a/app/graphql/types/extended_issue_type.rb b/app/graphql/types/extended_issue_type.rb
new file mode 100644
index 00000000000..e007c1109a3
--- /dev/null
+++ b/app/graphql/types/extended_issue_type.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ class ExtendedIssueType < IssueType
+ graphql_name 'ExtendedIssue'
+
+ authorize :read_issue
+ expose_permissions Types::PermissionTypes::Issue
+ present_using IssuePresenter
+
+ field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false, complexity: 5,
+ description: 'Boolean flag for whether the currently logged in user is subscribed to this issue'
+ end
+end
diff --git a/app/graphql/types/issuable_sort_enum.rb b/app/graphql/types/issuable_sort_enum.rb
new file mode 100644
index 00000000000..932e90c2d22
--- /dev/null
+++ b/app/graphql/types/issuable_sort_enum.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ class IssuableSortEnum < SortEnum
+ graphql_name 'IssuableSort'
+ description 'Values for sorting issuables'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+end
diff --git a/app/graphql/types/issue_sort_enum.rb b/app/graphql/types/issue_sort_enum.rb
new file mode 100644
index 00000000000..ad919b55481
--- /dev/null
+++ b/app/graphql/types/issue_sort_enum.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ class IssueSortEnum < IssuableSortEnum
+ graphql_name 'IssueSort'
+ description 'Values for sorting issues'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+end
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index 09e51ae4bc0..4965601fe65 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -49,6 +49,10 @@ module Types
field :web_url, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions
field :relative_position, GraphQL::INT_TYPE, null: true # rubocop:disable Graphql/Descriptions
+ field :participants, Types::UserType.connection_type, null: true, complexity: 5, description: 'List of participants for the issue'
+ field :time_estimate, GraphQL::INT_TYPE, null: false, description: 'The time estimate on the issue'
+ field :total_time_spent, GraphQL::INT_TYPE, null: false, description: 'Total time reported as spent on the issue'
+
field :closed_at, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions
field :created_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index 1baaa33c819..71a65dc6713 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -55,12 +55,27 @@ module Types
field :web_url, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions
field :upvotes, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions
field :downvotes, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions
- field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false # rubocop:disable Graphql/Descriptions
field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline # rubocop:disable Graphql/Descriptions
field :pipelines, Types::Ci::PipelineType.connection_type, # rubocop:disable Graphql/Descriptions
resolver: Resolvers::MergeRequestPipelinesResolver
+ field :milestone, Types::MilestoneType, description: 'The milestone this merge request is linked to',
+ null: true,
+ resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find }
+ field :assignees, Types::UserType.connection_type, null: true, complexity: 5, description: 'The list of assignees for the merge request'
+ field :participants, Types::UserType.connection_type, null: true, complexity: 5, description: 'The list of participants on the merge request'
+ field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false, complexity: 5,
+ description: 'Boolean flag for whether the currently logged in user is subscribed to this MR'
+ field :labels, Types::LabelType.connection_type, null: true, complexity: 5, description: 'The list of labels on the merge request'
+ field :discussion_locked, GraphQL::BOOLEAN_TYPE, description: 'Boolean flag determining if comments on the merge request are locked to members only',
+ null: false,
+ resolve: -> (obj, _args, _ctx) { !!obj.discussion_locked }
+ field :time_estimate, GraphQL::INT_TYPE, null: false, description: 'The time estimate for the merge request'
+ field :total_time_spent, GraphQL::INT_TYPE, null: false, description: 'Total time reported as spent on the merge request'
+ field :reference, GraphQL::STRING_TYPE, null: false, method: :to_reference, description: 'Internal merge request reference. Returned in shortened format by default' do
+ argument :full, GraphQL::BOOLEAN_TYPE, required: false, default_value: false, description: 'Boolean option specifying whether the reference should be returned in full'
+ end
field :task_completion_status, Types::TaskCompletionStatus, null: false # rubocop:disable Graphql/Descriptions
end
end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 7184cf42284..5663f833b7a 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -92,7 +92,7 @@ module Types
resolver: Resolvers::IssuesResolver
field :issue, # rubocop:disable Graphql/Descriptions
- Types::IssueType,
+ Types::ExtendedIssueType,
null: true,
resolver: Resolvers::IssuesResolver.single
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index bbf94fb92df..996bf225976 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -14,6 +14,11 @@ module Types
resolver: Resolvers::GroupResolver,
description: "Find a group"
+ field :current_user, Types::UserType,
+ null: true,
+ resolve: -> (_obj, _args, context) { context[:current_user] },
+ description: "Get information about current user"
+
field :namespace, Types::NamespaceType,
null: true,
resolver: Resolvers::NamespaceResolver,
diff --git a/app/graphql/types/todo_action_enum.rb b/app/graphql/types/todo_action_enum.rb
new file mode 100644
index 00000000000..0e538838474
--- /dev/null
+++ b/app/graphql/types/todo_action_enum.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Types
+ class TodoActionEnum < BaseEnum
+ value 'assigned', value: 1
+ value 'mentioned', value: 2
+ value 'build_failed', value: 3
+ value 'marked', value: 4
+ value 'approval_required', value: 5
+ value 'unmergeable', value: 6
+ value 'directly_addressed', value: 7
+ end
+end
diff --git a/app/graphql/types/todo_state_enum.rb b/app/graphql/types/todo_state_enum.rb
new file mode 100644
index 00000000000..29a28b5208d
--- /dev/null
+++ b/app/graphql/types/todo_state_enum.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module Types
+ class TodoStateEnum < BaseEnum
+ value 'pending'
+ value 'done'
+ end
+end
diff --git a/app/graphql/types/todo_target_enum.rb b/app/graphql/types/todo_target_enum.rb
new file mode 100644
index 00000000000..9a7391dcd99
--- /dev/null
+++ b/app/graphql/types/todo_target_enum.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Types
+ class TodoTargetEnum < BaseEnum
+ value 'Issue'
+ value 'MergeRequest'
+ value 'Epic'
+ end
+end
diff --git a/app/graphql/types/todo_type.rb b/app/graphql/types/todo_type.rb
new file mode 100644
index 00000000000..d36daaf7dec
--- /dev/null
+++ b/app/graphql/types/todo_type.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module Types
+ class TodoType < BaseObject
+ graphql_name 'Todo'
+ description 'Representing a todo entry'
+
+ present_using TodoPresenter
+
+ authorize :read_todo
+
+ field :id, GraphQL::ID_TYPE,
+ description: 'Id of the todo',
+ null: false
+
+ field :project, Types::ProjectType,
+ description: 'The project this todo is associated with',
+ null: true,
+ authorize: :read_project,
+ resolve: -> (todo, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, todo.project_id).find }
+
+ field :group, Types::GroupType,
+ description: 'Group this todo is associated with',
+ null: true,
+ authorize: :read_group,
+ resolve: -> (todo, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, todo.group_id).find }
+
+ field :author, Types::UserType,
+ description: 'The owner of this todo',
+ null: false,
+ resolve: -> (todo, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, todo.author_id).find }
+
+ field :action, Types::TodoActionEnum,
+ description: 'Action of the todo',
+ null: false
+
+ field :target_type, Types::TodoTargetEnum,
+ description: 'Target type of the todo',
+ null: false
+
+ field :body, GraphQL::STRING_TYPE,
+ description: 'Body of the todo',
+ null: false
+
+ field :state, Types::TodoStateEnum,
+ description: 'State of the todo',
+ null: false
+
+ field :created_at, Types::TimeType,
+ description: 'Timestamp this todo was created',
+ null: false
+ end
+end
diff --git a/app/graphql/types/tree/tree_type.rb b/app/graphql/types/tree/tree_type.rb
index b967cf3a247..56d544b5fd1 100644
--- a/app/graphql/types/tree/tree_type.rb
+++ b/app/graphql/types/tree/tree_type.rb
@@ -7,9 +7,9 @@ module Types
graphql_name 'Tree'
# Complexity 10 as it triggers a Gitaly call on each render
- field :last_commit, Types::CommitType, null: true, complexity: 10, calls_gitaly: true, resolve: -> (tree, args, ctx) do # rubocop:disable Graphql/Descriptions
- tree.repository.last_commit_for_path(tree.sha, tree.path)
- end
+ field :last_commit, Types::CommitType,
+ null: true, complexity: 10, calls_gitaly: true, resolver: Resolvers::LastCommitResolver,
+ description: 'Last commit for the tree'
field :trees, Types::Tree::TreeEntryType.connection_type, null: false, resolve: -> (obj, args, ctx) do # rubocop:disable Graphql/Descriptions
Gitlab::Graphql::Representation::TreeEntry.decorate(obj.trees, obj.repository)
diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb
index 9f7d2a171d6..1ba37927b40 100644
--- a/app/graphql/types/user_type.rb
+++ b/app/graphql/types/user_type.rb
@@ -12,5 +12,8 @@ module Types
field :username, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions
field :avatar_url, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions
field :web_url, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions
+ field :todos, Types::TodoType.connection_type, null: false,
+ resolver: Resolvers::TodoResolver,
+ description: 'Todos of this user'
end
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 5c2420e80f2..ecaeb7060c8 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -108,6 +108,11 @@ module ApplicationHelper
Gitlab.config.extra
end
+ # shortcut for gitlab registry config
+ def registry_config
+ Gitlab.config.registry
+ end
+
# Render a `time` element with Javascript-based relative date and tooltip
#
# time - Time object
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 8c5be1c315d..df17b82412f 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -265,6 +265,10 @@ module ApplicationSettingsHelper
:throttle_unauthenticated_enabled,
:throttle_unauthenticated_period_in_seconds,
:throttle_unauthenticated_requests_per_period,
+ :throttle_protected_paths_enabled,
+ :throttle_protected_paths_period_in_seconds,
+ :throttle_protected_paths_requests_per_period,
+ :protected_paths_raw,
:time_tracking_limit_to_hours,
:two_factor_grace_period,
:unique_ips_limit_enabled,
@@ -285,7 +289,10 @@ module ApplicationSettingsHelper
:snowplow_collector_hostname,
:snowplow_cookie_domain,
:snowplow_enabled,
- :snowplow_site_id
+ :snowplow_site_id,
+ :push_event_hooks_limit,
+ :push_event_activities_limit,
+ :custom_http_clone_url_root
]
end
@@ -308,6 +315,10 @@ module ApplicationSettingsHelper
def instance_clusters_enabled?
can?(current_user, :read_cluster, Clusters::Instance.new)
end
+
+ def omnibus_protected_paths_throttle?
+ Rack::Attack.throttles.key?('protected paths')
+ end
end
ApplicationSettingsHelper.prepend_if_ee('EE::ApplicationSettingsHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index b7f7e617825..733d21daec1 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -56,16 +56,6 @@ module AvatarsHelper
}))
end
- def user_avatar_url_for(only_path: true, **options)
- if options[:url]
- options[:url]
- elsif options[:user]
- avatar_icon_for_user(options[:user], options[:size], only_path: only_path)
- else
- avatar_icon_for_email(options[:user_email], options[:size], only_path: only_path)
- end
- end
-
def user_avatar_without_link(options = {})
avatar_size = options[:size] || 16
user_name = options[:user].try(:name) || options[:user_name]
@@ -111,6 +101,19 @@ module AvatarsHelper
private
+ def user_avatar_url_for(only_path: true, **options)
+ return options[:url] if options[:url]
+
+ email = options[:user_email]
+ user = options.key?(:user) ? options[:user] : User.find_by_any_email(email)
+
+ if user
+ avatar_icon_for_user(user, options[:size], only_path: only_path)
+ else
+ gravatar_icon(email, options[:size])
+ end
+ end
+
def source_icon(source, options = {})
avatar_url = source.try(:avatar_url)
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 4b0713001a1..5c24b0e1704 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -32,6 +32,14 @@ module BlobHelper
File.join(segments)
end
+ def ide_fork_and_edit_path(project = @project, ref = @ref, path = @path, options = {})
+ if current_user
+ project_forks_path(project,
+ namespace_key: current_user&.namespace&.id,
+ continue: edit_blob_fork_params(ide_edit_path(project, ref, path)))
+ end
+ end
+
def encode_ide_path(path)
url_encode(path).gsub('%2F', '/')
end
@@ -197,13 +205,13 @@ module BlobHelper
end
def copy_file_path_button(file_path)
- clipboard_button(text: file_path, gfm: "`#{file_path}`", class: 'btn-clipboard btn-transparent', title: 'Copy file path to clipboard')
+ clipboard_button(text: file_path, gfm: "`#{file_path}`", class: 'btn-clipboard btn-transparent', title: _('Copy file path'))
end
def copy_blob_source_button(blob)
return unless blob.rendered_as_text?(ignore_errors: false)
- clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm js-copy-blob-source-btn", title: "Copy source to clipboard")
+ clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm js-copy-blob-source-btn", title: _("Copy file contents"))
end
def open_raw_blob_button(blob)
diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb
index 12cd5403f71..610d823dd3c 100644
--- a/app/helpers/button_helper.rb
+++ b/app/helpers/button_helper.rb
@@ -21,7 +21,7 @@ module ButtonHelper
# See http://clipboardjs.com/#usage
def clipboard_button(data = {})
css_class = data[:class] || 'btn-clipboard btn-transparent'
- title = data[:title] || _('Copy to clipboard')
+ title = data[:title] || _('Copy')
button_text = data[:button_text] || ''
hide_tooltip = data[:hide_tooltip] || false
hide_button_icon = data[:hide_button_icon] || false
diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb
index 144df676304..4471d5b64b2 100644
--- a/app/helpers/ci_status_helper.rb
+++ b/app/helpers/ci_status_helper.rb
@@ -64,7 +64,7 @@ module CiStatusHelper
def ci_icon_for_status(status, size: 16)
if detailed_status?(status)
- return sprite_icon(status.icon)
+ return sprite_icon(status.icon, size: size)
end
icon_name =
@@ -77,6 +77,8 @@ module CiStatusHelper
'status_failed'
when 'pending'
'status_pending'
+ when 'preparing'
+ 'status_preparing'
when 'running'
'status_running'
when 'play'
@@ -96,23 +98,29 @@ module CiStatusHelper
sprite_icon(icon_name, size: size)
end
+ def ci_icon_class_for_status(status)
+ group = detailed_status?(status) ? status.group : status.dasherize
+
+ "ci-status-icon-#{group}"
+ end
+
def pipeline_status_cache_key(pipeline_status)
"pipeline-status/#{pipeline_status.sha}-#{pipeline_status.status}"
end
- def render_commit_status(commit, ref: nil, tooltip_placement: 'left')
+ def render_commit_status(commit, status, ref: nil, tooltip_placement: 'left')
project = commit.project
path = pipelines_project_commit_path(project, commit, ref: ref)
render_status_with_link(
- commit.status(ref),
+ status,
path,
tooltip_placement: tooltip_placement,
icon_size: 24)
end
def render_status_with_link(status, path = nil, type: _('pipeline'), tooltip_placement: 'left', cssclass: '', container: 'body', icon_size: 16)
- klass = "ci-status-link ci-status-icon-#{status.dasherize} d-inline-flex #{cssclass}"
+ klass = "ci-status-link #{ci_icon_class_for_status(status)} d-inline-flex #{cssclass}"
title = "#{type.titleize}: #{ci_label_for_status(status)}"
data = { toggle: 'tooltip', placement: tooltip_placement, container: container }
@@ -127,6 +135,7 @@ module CiStatusHelper
def detailed_status?(status)
status.respond_to?(:text) &&
+ status.respond_to?(:group) &&
status.respond_to?(:label) &&
status.respond_to?(:icon)
end
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index 7f3e78f3a81..52ec2eadf5e 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -60,9 +60,14 @@ module DiffHelper
if line.blank?
"&nbsp;".html_safe
else
- # We can't use `sub` because the HTML-safeness of `line` will not survive.
- line[0] = '' if line.start_with?('+', '-', ' ')
- line
+ # `sub` and substring-ing would destroy HTML-safeness of `line`
+ if line.start_with?('+', '-', ' ')
+ line.dup.tap do |line|
+ line[0] = ''
+ end
+ else
+ line
+ end
end
end
@@ -198,8 +203,8 @@ module DiffHelper
link_to "#{hide_whitespace? ? 'Show' : 'Hide'} whitespace changes", url, class: options[:class]
end
- def render_overflow_warning?(diff_files)
- diffs = @merge_request_diff.presence || diff_files
+ def render_overflow_warning?(diffs_collection)
+ diffs = @merge_request_diff.presence || diffs_collection.diff_files
diffs.overflow?
end
diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb
index 2b7320817ed..52f189b122f 100644
--- a/app/helpers/environment_helper.rb
+++ b/app/helpers/environment_helper.rb
@@ -18,12 +18,16 @@ module EnvironmentHelper
end
end
+ def deployment_path(deployment)
+ [deployment.project.namespace.becomes(Namespace), deployment.project, deployment.deployable]
+ end
+
def deployment_link(deployment, text: nil)
return unless deployment
link_label = text ? text : "##{deployment.iid}"
- link_to link_label, [deployment.project.namespace.becomes(Namespace), deployment.project, deployment.deployable]
+ link_to link_label, deployment_path(deployment)
end
def last_deployment_link_for_environment_build(project, build)
@@ -32,4 +36,31 @@ module EnvironmentHelper
deployment_link(environment.last_deployment)
end
+
+ def render_deployment_status(deployment)
+ status = deployment.status
+
+ status_text =
+ case status
+ when 'created'
+ s_('Deployment|created')
+ when 'running'
+ s_('Deployment|running')
+ when 'success'
+ s_('Deployment|success')
+ when 'failed'
+ s_('Deployment|failed')
+ when 'canceled'
+ s_('Deployment|canceled')
+ end
+
+ klass = "ci-status ci-#{status.dasherize}"
+ text = "#{ci_icon_for_status(status)} #{status_text}".html_safe
+
+ if deployment.deployable
+ link_to(text, deployment_path(deployment), class: klass)
+ else
+ content_tag(:span, text, class: klass)
+ end
+ end
end
diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb
new file mode 100644
index 00000000000..d03fa6eadb2
--- /dev/null
+++ b/app/helpers/export_helper.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module ExportHelper
+ # An EE-overwriteable list of descriptions
+ def project_export_descriptions
+ [
+ _('Project and wiki repositories'),
+ _('Project uploads'),
+ _('Project configuration, including services'),
+ _('Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities'),
+ _('LFS objects'),
+ _('Issue Boards')
+ ]
+ end
+end
+
+ExportHelper.prepend_if_ee('EE::ExportHelper')
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index f524696cc2f..4f31cc67ccc 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -58,7 +58,7 @@ module GitlabRoutingHelper
end
def commits_url(entity, *args)
- project_commits_url(entity.project, entity.ref, *args)
+ project_commits_url(entity.project, entity.source_ref, *args)
end
def commit_url(entity, *args)
@@ -76,10 +76,10 @@ module GitlabRoutingHelper
end
def edit_milestone_path(entity, *args)
- if entity.parent.is_a?(Group)
- edit_group_milestone_path(entity.parent, entity, *args)
+ if entity.resource_parent.is_a?(Group)
+ edit_group_milestone_path(entity.resource_parent, entity, *args)
else
- edit_project_milestone_path(entity.parent, entity, *args)
+ edit_project_milestone_path(entity.resource_parent, entity, *args)
end
end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 601560cca92..6ddcbf61090 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -15,6 +15,18 @@ module GroupsHelper
%w[groups#projects groups#edit badges#index ci_cd#show ldap_group_links#index hooks#index audit_events#index pipeline_quota#index]
end
+ def group_packages_nav_link_paths
+ %w[
+ groups/container_registries#index
+ ]
+ end
+
+ def group_container_registry_nav?
+ Gitlab.config.registry.enabled &&
+ can?(current_user, :read_container_image, @group) &&
+ Feature.enabled?(:group_container_registry_browser, @group)
+ end
+
def group_sidebar_links
@group_sidebar_links ||= get_group_sidebar_links
end
@@ -32,8 +44,7 @@ module GroupsHelper
end
def can_disable_group_emails?(group)
- Feature.enabled?(:emails_disabled, group, default_enabled: true) &&
- can?(current_user, :set_emails_disabled, group) && !group.parent&.emails_disabled?
+ can?(current_user, :set_emails_disabled, group) && !group.parent&.emails_disabled?
end
def group_issues_count(state:)
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 014523b54cb..df9d1933271 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -272,7 +272,7 @@ module IssuablesHelper
markdownPreviewPath: preview_markdown_path(parent),
markdownDocsPath: help_page_path('user/markdown'),
lockVersion: issuable.lock_version,
- issuableTemplates: issuable_templates(issuable),
+ issuableTemplateNamesPath: template_names_path(parent, issuable),
initialTitleHtml: markdown_field(issuable, :title),
initialTitleText: issuable.title,
initialDescriptionHtml: markdown_field(issuable, :description),
@@ -372,6 +372,12 @@ module IssuablesHelper
finder.class.scalar_params.any? { |p| params[p].present? }
end
+ def assignee_sidebar_data(assignee, merge_request: nil)
+ { avatar_url: assignee.avatar_url, name: assignee.name, username: assignee.username }.tap do |data|
+ data[:can_merge] = merge_request.can_be_merged_by?(assignee) if merge_request
+ end
+ end
+
private
def sidebar_gutter_collapsed?
@@ -429,6 +435,12 @@ module IssuablesHelper
end
end
+ def template_names_path(parent, issuable)
+ return '' unless parent.is_a?(Project)
+
+ project_template_names_path(parent, template_type: issuable.class.name.underscore)
+ end
+
def issuable_sidebar_options(issuable)
{
endpoint: "#{issuable[:issuable_json_path]}?serializer=sidebar_extras",
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index 6aa910e6c3f..2ce45cec878 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -20,7 +20,6 @@ module NavHelper
def page_gutter_class
if page_has_markdown?
-
if cookies[:collapsed_gutter] == 'true'
%w[page-gutter right-sidebar-collapsed]
else
@@ -87,6 +86,12 @@ module NavHelper
links << :admin_impersonation
end
+ if Feature.enabled?(:user_mode_in_session)
+ if current_user&.admin? && current_user_mode&.admin_mode?
+ links << :admin_mode
+ end
+ end
+
links
end
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index bf6abdb8c4b..16360c7139a 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -76,7 +76,7 @@ module ProjectsHelper
link_to(author_html, user_path(author), class: "author-link js-user-link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}", data: data_attrs).html_safe
else
title = opts[:title].sub(":name", sanitize(author.name))
- link_to(author_html, user_path(author), class: "author-link has-tooltip", title: title, data: { container: 'body' }).html_safe
+ link_to(author_html, user_path(author), class: "author-link has-tooltip", title: title, data: { container: 'body', qa_selector: 'assignee_link' }).html_safe
end
end
@@ -160,7 +160,7 @@ module ProjectsHelper
def can_disable_emails?(project, current_user)
return false if project.group&.emails_disabled?
- can?(current_user, :set_emails_disabled, project) && Feature.enabled?(:emails_disabled, project, default_enabled: true)
+ can?(current_user, :set_emails_disabled, project)
end
def last_push_event
@@ -168,7 +168,7 @@ module ProjectsHelper
end
def link_to_autodeploy_doc
- link_to _('About auto deploy'), help_page_path('ci/autodeploy/index'), target: '_blank'
+ link_to _('About auto deploy'), help_page_path('autodevops/index.md#auto-deploy'), target: '_blank'
end
def autodeploy_flash_notice(branch_name)
@@ -354,6 +354,14 @@ module ProjectsHelper
@project.metrics_setting_external_dashboard_url
end
+ def grafana_integration_url
+ @project.grafana_integration&.grafana_url
+ end
+
+ def grafana_integration_token
+ @project.grafana_integration&.token
+ end
+
private
def get_project_nav_tabs(project, current_user)
@@ -565,7 +573,7 @@ module ProjectsHelper
lfsHelpPath: help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs'),
pagesAvailable: Gitlab.config.pages.enabled,
pagesAccessControlEnabled: Gitlab.config.pages.access_control,
- pagesHelpPath: help_page_path('user/project/pages/introduction', anchor: 'gitlab-pages-access-control-core-only')
+ pagesHelpPath: help_page_path('user/project/pages/introduction', anchor: 'gitlab-pages-access-control-core')
}
end
diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb
index 4d9fe345edf..68a19152d8f 100644
--- a/app/helpers/releases_helper.rb
+++ b/app/helpers/releases_helper.rb
@@ -12,27 +12,21 @@ module ReleasesHelper
help_page_path(DOCUMENTATION_PATH)
end
- def url_for_merge_requests
- project_merge_requests_url(@project, params_for_issue_and_mr_paths)
- end
-
- def url_for_issues
- project_issues_url(@project, params_for_issue_and_mr_paths)
- end
-
def data_for_releases_page
{
project_id: @project.id,
illustration_path: illustration,
- documentation_path: help_page,
- merge_requests_url: url_for_merge_requests,
- issues_url: url_for_issues
+ documentation_path: help_page
}
end
- private
-
- def params_for_issue_and_mr_paths
- { scope: 'all', state: 'opened' }
+ def data_for_edit_release_page
+ {
+ project_id: @project.id,
+ tag_name: @release.tag,
+ markdown_preview_path: preview_markdown_path(@project),
+ markdown_docs_path: help_page_path('user/markdown'),
+ releases_page_path: project_releases_path(@project, anchor: @release.tag)
+ }
end
end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 0f4e5adca6c..9a19758b4e8 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -34,15 +34,15 @@ module SearchHelper
from: from,
to: to,
count: count,
- scope: search_entries_info_label(scope, count),
+ scope: search_entries_scope_label(scope, count),
term: term
}
end
- def search_entries_info_label(scope, count)
+ def search_entries_scope_label(scope, count)
case scope
- when 'blobs', 'snippet_blobs', 'wiki_blobs'
- ns_('SearchResults|result', 'SearchResults|results', count)
+ when 'blobs'
+ ns_('SearchResults|code result', 'SearchResults|code results', count)
when 'commits'
ns_('SearchResults|commit', 'SearchResults|commits', count)
when 'issues'
@@ -55,10 +55,14 @@ module SearchHelper
ns_('SearchResults|comment', 'SearchResults|comments', count)
when 'projects'
ns_('SearchResults|project', 'SearchResults|projects', count)
+ when 'snippet_blobs'
+ ns_('SearchResults|snippet result', 'SearchResults|snippet results', count)
when 'snippet_titles'
ns_('SearchResults|snippet', 'SearchResults|snippets', count)
when 'users'
ns_('SearchResults|user', 'SearchResults|users', count)
+ when 'wiki_blobs'
+ ns_('SearchResults|wiki result', 'SearchResults|wiki results', count)
else
raise "Unrecognized search scope '#{scope}'"
end
@@ -72,6 +76,13 @@ module SearchHelper
end
end
+ def search_entries_empty_message(scope, term)
+ (s_("SearchResults|We couldn't find any %{scope} matching %{term}") % {
+ scope: search_entries_scope_label(scope, 0),
+ term: "<code>#{h(term)}</code>"
+ }).html_safe
+ end
+
def find_project_for_result_blob(projects, result)
@project
end
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index d680e10525d..33f3bb0b749 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -28,7 +28,9 @@ module SortingHelper
sort_value_priority => sort_title_priority,
sort_value_upvotes => sort_title_upvotes,
sort_value_contacted_date => sort_title_contacted_date,
- sort_value_relative_position => sort_title_relative_position
+ sort_value_relative_position => sort_title_relative_position,
+ sort_value_size => sort_title_size,
+ sort_value_expire_date => sort_title_expire_date
}
end
@@ -406,6 +408,14 @@ module SortingHelper
s_('SortOptions|Manual')
end
+ def sort_title_size
+ s_('SortOptions|Size')
+ end
+
+ def sort_title_expire_date
+ s_('SortOptions|Expired date')
+ end
+
# Values.
def sort_value_access_level_asc
'access_level_asc'
@@ -558,4 +568,12 @@ module SortingHelper
def sort_value_relative_position
'relative_position'
end
+
+ def sort_value_size
+ 'size_desc'
+ end
+
+ def sort_value_expire_date
+ 'expired_asc'
+ end
end
diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb
index e683e2959d1..4b83988e8bb 100644
--- a/app/helpers/submodule_helper.rb
+++ b/app/helpers/submodule_helper.rb
@@ -81,7 +81,7 @@ module SubmoduleHelper
end
def relative_self_links(relative_path, commit, project)
- relative_path.rstrip!
+ relative_path = relative_path.rstrip
absolute_project_path = "/" + project.full_path
# Resolve `relative_path` to target path
diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb
index de0b92b6fd7..4984b51555d 100644
--- a/app/helpers/tags_helper.rb
+++ b/app/helpers/tags_helper.rb
@@ -28,4 +28,14 @@ module TagsHelper
def protected_tag?(project, tag)
ProtectedTag.protected?(project, tag.name)
end
+
+ def tag_description_help_text
+ text = s_('TagsPage|Optionally, add a message to the tag. Leaving this blank creates '\
+ 'a %{link_start}lightweight tag.%{link_end}') % {
+ link_start: '<a href="https://git-scm.com/book/en/v2/Git-Basics-Tagging\" target="_blank" rel="noopener noreferrer">',
+ link_end: '</a>'
+ }
+
+ text.html_safe
+ end
end
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index a919c068c42..dce0842060d 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -45,8 +45,8 @@ module TodosHelper
end
def todo_parent_path(todo)
- if todo.parent.is_a?(Group)
- link_to todo.parent.name, group_path(todo.parent)
+ if todo.resource_parent.is_a?(Group)
+ link_to todo.resource_parent.name, group_path(todo.resource_parent)
else
link_to_project(todo.project)
end
@@ -64,7 +64,7 @@ module TodosHelper
if todo.for_commit?
project_commit_path(todo.project, todo.target, path_options)
else
- path = [todo.parent, todo.target]
+ path = [todo.resource_parent, todo.target]
path.unshift(:pipelines) if todo.build_failed?
diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb
index 47d15836da0..3fd865003c1 100644
--- a/app/mailers/emails/issues.rb
+++ b/app/mailers/emails/issues.rb
@@ -85,7 +85,7 @@ module Emails
@project = Project.find(project_id)
@results = results
- mail(to: recipient(@user.id, @project.group), subject: subject('Imported issues')) do |format|
+ mail(to: @user.notification_email_for(@project.group), subject: subject('Imported issues')) do |format|
format.html { render layout: 'mailer' }
format.text { render layout: 'mailer' }
end
@@ -105,7 +105,7 @@ module Emails
def issue_thread_options(sender_id, recipient_id, reason)
{
from: sender(sender_id),
- to: recipient(recipient_id, @project.group),
+ to: User.find(recipient_id).notification_email_for(@project.group),
subject: subject("#{@issue.title} (##{@issue.iid})"),
'X-GitLab-NotificationReason' => reason
}
diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb
index 76fa7236ab1..ea8032324aa 100644
--- a/app/mailers/emails/members.rb
+++ b/app/mailers/emails/members.rb
@@ -13,7 +13,9 @@ module Emails
@member_source_type = member_source_type
@member_id = member_id
- mail(to: recipient(recipient_id, notification_group),
+ user = User.find(recipient_id)
+
+ mail(to: user.notification_email_for(notification_group),
subject: subject("Request to join the #{member_source.human_name} #{member_source.model_name.singular}"))
end
@@ -21,7 +23,7 @@ module Emails
@member_source_type = member_source_type
@member_id = member_id
- mail(to: recipient(member.user, notification_group),
+ mail(to: member.user.notification_email_for(notification_group),
subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was granted"))
end
@@ -29,7 +31,9 @@ module Emails
@member_source_type = member_source_type
@member_source = member_source_class.find(source_id)
- mail(to: recipient(user_id, notification_group),
+ user = User.find(user_id)
+
+ mail(to: user.notification_email_for(notification_group),
subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was denied"))
end
@@ -47,7 +51,7 @@ module Emails
@member_id = member_id
return unless member.created_by
- mail(to: recipient(member.created_by, notification_group),
+ mail(to: member.created_by.notification_email_for(notification_group),
subject: subject('Invitation accepted'))
end
@@ -58,7 +62,9 @@ module Emails
@member_source = member_source_class.find(source_id)
@invite_email = invite_email
- mail(to: recipient(created_by_id, notification_group),
+ user = User.find(created_by_id)
+
+ mail(to: user.notification_email_for(notification_group),
subject: subject('Invitation declined'))
end
diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb
index d972d0dea28..76b1c2d234c 100644
--- a/app/mailers/emails/merge_requests.rb
+++ b/app/mailers/emails/merge_requests.rb
@@ -110,7 +110,7 @@ module Emails
def merge_request_thread_options(sender_id, recipient_id, reason = nil)
{
from: sender(sender_id),
- to: recipient(recipient_id, @project.group),
+ to: User.find(recipient_id).notification_email_for(@project.group),
subject: subject("#{@merge_request.title} (#{@merge_request.to_reference})"),
'X-GitLab-NotificationReason' => reason
}
diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb
index 51b6368a307..a1c8c3455b5 100644
--- a/app/mailers/emails/notes.rb
+++ b/app/mailers/emails/notes.rb
@@ -55,7 +55,7 @@ module Emails
def note_thread_options(recipient_id, reason)
{
from: sender(@note.author_id),
- to: recipient(recipient_id, @project&.group || @group),
+ to: User.find(recipient_id).notification_email_for(@project&.group || @group),
subject: subject("#{@note.noteable.title} (#{@note.noteable.reference_link_text})"),
'X-GitLab-NotificationReason' => reason
}
diff --git a/app/mailers/emails/pages_domains.rb b/app/mailers/emails/pages_domains.rb
index 2d390666f65..1caca6b3e44 100644
--- a/app/mailers/emails/pages_domains.rb
+++ b/app/mailers/emails/pages_domains.rb
@@ -7,7 +7,7 @@ module Emails
@project = domain.project
mail(
- to: recipient(recipient.id, @project.group),
+ to: recipient.notification_email_for(@project.group),
subject: subject("GitLab Pages domain '#{domain.domain}' has been enabled")
)
end
@@ -17,7 +17,7 @@ module Emails
@project = domain.project
mail(
- to: recipient(recipient.id, @project.group),
+ to: recipient.notification_email_for(@project.group),
subject: subject("GitLab Pages domain '#{domain.domain}' has been disabled")
)
end
@@ -27,7 +27,7 @@ module Emails
@project = domain.project
mail(
- to: recipient(recipient.id, @project.group),
+ to: recipient.notification_email_for(@project.group),
subject: subject("Verification succeeded for GitLab Pages domain '#{domain.domain}'")
)
end
@@ -37,7 +37,7 @@ module Emails
@project = domain.project
mail(
- to: recipient(recipient.id, @project.group),
+ to: recipient.notification_email_for(@project.group),
subject: subject("ACTION REQUIRED: Verification failed for GitLab Pages domain '#{domain.domain}'")
)
end
diff --git a/app/mailers/emails/pipelines.rb b/app/mailers/emails/pipelines.rb
index fb57c0da34d..34e12a5fa6d 100644
--- a/app/mailers/emails/pipelines.rb
+++ b/app/mailers/emails/pipelines.rb
@@ -15,7 +15,7 @@ module Emails
def pipeline_mail(pipeline, recipients, status)
@project = pipeline.project
@pipeline = pipeline
- @merge_request = pipeline.merge_requests_as_head_pipeline.first
+ @merge_request = pipeline.all_merge_requests.first
add_headers
# We use bcc here because we don't want to generate this emails for a
@@ -44,7 +44,7 @@ module Emails
commit = [@pipeline.short_sha]
commit << "in #{@merge_request.to_reference}" if @merge_request
- subject("Pipeline ##{@pipeline.id} has #{status} for #{@pipeline.ref}", commit.join(' '))
+ subject("Pipeline ##{@pipeline.id} has #{status} for #{@pipeline.source_ref}", commit.join(' '))
end
end
end
diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb
index 4acf4a1dc4f..6274879ee99 100644
--- a/app/mailers/emails/projects.rb
+++ b/app/mailers/emails/projects.rb
@@ -7,20 +7,20 @@ module Emails
@project = Project.find project_id
@target_url = project_url(@project)
@old_path_with_namespace = old_path_with_namespace
- mail(to: recipient(user_id, @project.group),
+ mail(to: @user.notification_email_for(@project.group),
subject: subject("Project was moved"))
end
def project_was_exported_email(current_user, project)
@project = project
- mail(to: recipient(current_user.id, project.group),
+ mail(to: current_user.notification_email_for(project.group),
subject: subject("Project was exported"))
end
def project_was_not_exported_email(current_user, project, errors)
@project = project
@errors = errors
- mail(to: recipient(current_user.id, @project.group),
+ mail(to: current_user.notification_email_for(@project.group),
subject: subject("Project export error"))
end
@@ -28,7 +28,7 @@ module Emails
@project = project
@user = user
- mail(to: recipient(user.id, project.group), subject: subject("Project cleanup has completed"))
+ mail(to: user.notification_email_for(project.group), subject: subject("Project cleanup has completed"))
end
def repository_cleanup_failure_email(project, user, error)
@@ -36,7 +36,7 @@ module Emails
@user = user
@error = error
- mail(to: recipient(user.id, project.group), subject: subject("Project cleanup failure"))
+ mail(to: user.notification_email_for(project.group), subject: subject("Project cleanup failure"))
end
def repository_push_email(project_id, opts = {})
diff --git a/app/mailers/emails/releases.rb b/app/mailers/emails/releases.rb
new file mode 100644
index 00000000000..137858d31e8
--- /dev/null
+++ b/app/mailers/emails/releases.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Emails
+ module Releases
+ def new_release_email(user_id, release, reason = nil)
+ @release = release
+ @project = @release.project
+ @target_url = namespace_project_releases_url(
+ namespace_id: @project.namespace,
+ project_id: @project
+ )
+
+ user = User.find(user_id)
+
+ mail(
+ to: user.notification_email_for(@project.group),
+ subject: subject(release_email_subject)
+ )
+ end
+
+ private
+
+ def release_email_subject
+ release_info = [@release.name, @release.tag].select(&:presence).join(' - ')
+ "New release: #{release_info}"
+ end
+ end
+end
diff --git a/app/mailers/emails/remote_mirrors.rb b/app/mailers/emails/remote_mirrors.rb
index f3938a052b0..9cde53918b9 100644
--- a/app/mailers/emails/remote_mirrors.rb
+++ b/app/mailers/emails/remote_mirrors.rb
@@ -5,8 +5,9 @@ module Emails
def remote_mirror_update_failed_email(remote_mirror_id, recipient_id)
@remote_mirror = RemoteMirror.find_by_id(remote_mirror_id)
@project = @remote_mirror.project
+ user = User.find(recipient_id)
- mail(to: recipient(recipient_id, @project.group), subject: subject('Remote mirror update failed'))
+ mail(to: user.notification_email_for(@project.group), subject: subject('Remote mirror update failed'))
end
end
end
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index 6fa1c701cd8..c7cfefeec9b 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -16,6 +16,7 @@ class Notify < BaseMailer
include Emails::Members
include Emails::AutoDevops
include Emails::RemoteMirrors
+ include Emails::Releases
helper MilestonesHelper
helper MergeRequestsHelper
@@ -71,20 +72,6 @@ class Notify < BaseMailer
address.format
end
- # Look up a User's notification email for a particular context.
- # Can look up by their ID or can accept a User object.
- #
- # recipient - User object OR a User ID
- # notification_group - The parent group of the notification
- #
- # Returns a String containing the User's email address.
- def recipient(recipient, notification_group = nil)
- user = recipient if recipient.is_a?(User)
- user ||= User.find(recipient)
-
- user.notification_email_for(notification_group)
- end
-
# Formats arguments into a String suitable for use as an email subject
#
# extra - Extra Strings to be inserted into the subject
diff --git a/app/models/analytics/cycle_analytics/project_stage.rb b/app/models/analytics/cycle_analytics/project_stage.rb
index a312bd24e78..23f0db0829b 100644
--- a/app/models/analytics/cycle_analytics/project_stage.rb
+++ b/app/models/analytics/cycle_analytics/project_stage.rb
@@ -9,6 +9,7 @@ module Analytics
belongs_to :project
alias_attribute :parent, :project
+ alias_attribute :parent_id, :project_id
end
end
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 92526def144..a07933d4975 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -210,6 +210,16 @@ class ApplicationSetting < ApplicationRecord
presence: true,
if: :static_objects_external_storage_url?
+ validates :protected_paths,
+ length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') },
+ allow_nil: false
+
+ validates :push_event_hooks_limit,
+ numericality: { greater_than_or_equal_to: 0 }
+
+ validates :push_event_activities_limit,
+ numericality: { greater_than_or_equal_to: 0 }
+
SUPPORTED_KEY_TYPES.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index 8d9597aa5a4..0c0ffb67c9a 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -4,7 +4,7 @@ module ApplicationSettingImplementation
extend ActiveSupport::Concern
include Gitlab::Utils::StrongMemoize
- DOMAIN_LIST_SEPARATOR = %r{\s*[,;]\s* # comma or semicolon, optionally surrounded by whitespace
+ STRING_LIST_SEPARATOR = %r{\s*[,;]\s* # comma or semicolon, optionally surrounded by whitespace
| # or
\s # any whitespace character
| # or
@@ -16,6 +16,19 @@ module ApplicationSettingImplementation
FORBIDDEN_KEY_VALUE = KeyRestrictionValidator::FORBIDDEN
SUPPORTED_KEY_TYPES = %i[rsa dsa ecdsa ed25519].freeze
+ DEFAULT_PROTECTED_PATHS = [
+ '/users/password',
+ '/users/sign_in',
+ '/api/v3/session.json',
+ '/api/v3/session',
+ '/api/v4/session.json',
+ '/api/v4/session',
+ '/users',
+ '/users/confirmation',
+ '/unsubscribes/',
+ '/import/github/personal_access_token'
+ ].freeze
+
class_methods do
def defaults
{
@@ -69,6 +82,8 @@ module ApplicationSettingImplementation
polling_interval_multiplier: 1,
project_export_enabled: true,
protected_ci_variables: false,
+ push_event_hooks_limit: 3,
+ push_event_activities_limit: 3,
raw_blob_request_limit: 300,
recaptcha_enabled: false,
login_recaptcha_protection_enabled: false,
@@ -92,6 +107,13 @@ module ApplicationSettingImplementation
throttle_unauthenticated_enabled: false,
throttle_unauthenticated_period_in_seconds: 3600,
throttle_unauthenticated_requests_per_period: 3600,
+ throttle_protected_paths_enabled: false,
+ throttle_protected_paths_in_seconds: 10,
+ throttle_protected_paths_per_period: 60,
+ protected_paths: DEFAULT_PROTECTED_PATHS,
+ throttle_incident_management_notification_enabled: false,
+ throttle_incident_management_notification_period_in_seconds: 3600,
+ throttle_incident_management_notification_per_period: 3600,
time_tracking_limit_to_hours: false,
two_factor_grace_period: 48,
unique_ips_limit_enabled: false,
@@ -106,7 +128,8 @@ module ApplicationSettingImplementation
snowplow_collector_hostname: nil,
snowplow_cookie_domain: nil,
snowplow_enabled: false,
- snowplow_site_id: nil
+ snowplow_site_id: nil,
+ custom_http_clone_url_root: nil
}
end
@@ -149,11 +172,11 @@ module ApplicationSettingImplementation
end
def domain_whitelist_raw=(values)
- self.domain_whitelist = domain_strings_to_array(values)
+ self.domain_whitelist = strings_to_array(values)
end
def domain_blacklist_raw=(values)
- self.domain_blacklist = domain_strings_to_array(values)
+ self.domain_blacklist = strings_to_array(values)
end
def domain_blacklist_file=(file)
@@ -167,7 +190,7 @@ module ApplicationSettingImplementation
def outbound_local_requests_whitelist_raw=(values)
clear_memoization(:outbound_local_requests_whitelist_arrays)
- self.outbound_local_requests_whitelist = domain_strings_to_array(values)
+ self.outbound_local_requests_whitelist = strings_to_array(values)
end
def add_to_outbound_local_requests_whitelist(values_array)
@@ -200,8 +223,16 @@ module ApplicationSettingImplementation
end
end
+ def protected_paths_raw
+ array_to_string(self.protected_paths)
+ end
+
+ def protected_paths_raw=(values)
+ self.protected_paths = strings_to_array(values)
+ end
+
def asset_proxy_whitelist=(values)
- values = domain_strings_to_array(values) if values.is_a?(String)
+ values = strings_to_array(values) if values.is_a?(String)
# make sure we always whitelist the running host
values << Gitlab.config.gitlab.host unless values.include?(Gitlab.config.gitlab.host)
@@ -316,11 +347,11 @@ module ApplicationSettingImplementation
arr&.join("\n")
end
- def domain_strings_to_array(values)
+ def strings_to_array(values)
return [] unless values
values
- .split(DOMAIN_LIST_SEPARATOR)
+ .split(STRING_LIST_SEPARATOR)
.map(&:strip)
.reject(&:empty?)
.uniq
diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb
index c2eef500fb0..06a607b75a4 100644
--- a/app/models/audit_event.rb
+++ b/app/models/audit_event.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class AuditEvent < ApplicationRecord
+ include CreatedAtFilterable
+
serialize :details, Hash # rubocop:disable Cop/ActiveRecordSerialize
belongs_to :user, foreign_key: :author_id
@@ -9,6 +11,9 @@ class AuditEvent < ApplicationRecord
validates :entity_id, presence: true
validates :entity_type, presence: true
+ scope :by_entity_type, -> (entity_type) { where(entity_type: entity_type) }
+ scope :by_entity_id, -> (entity_id) { where(entity_id: entity_id) }
+
after_initialize :initialize_details
def initialize_details
@@ -18,6 +23,10 @@ class AuditEvent < ApplicationRecord
def author_name
self.user.name
end
+
+ def formatted_details
+ details.merge(details.slice(:from, :to).transform_values(&:to_s))
+ end
end
AuditEvent.prepend_if_ee('EE::AuditEvent')
diff --git a/app/models/aws/role.rb b/app/models/aws/role.rb
new file mode 100644
index 00000000000..836107435ad
--- /dev/null
+++ b/app/models/aws/role.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Aws
+ class Role < ApplicationRecord
+ self.table_name = 'aws_roles'
+
+ belongs_to :user, inverse_of: :aws_role
+
+ validates :role_external_id, uniqueness: true, length: { in: 1..64 }
+ validates :role_arn,
+ length: 1..2048,
+ format: {
+ with: Gitlab::Regex.aws_arn_regex,
+ message: Gitlab::Regex.aws_arn_regex_message
+ }
+ end
+end
diff --git a/app/models/blob.rb b/app/models/blob.rb
index a590536d5fe..cc089715b06 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -32,6 +32,7 @@ class Blob < SimpleDelegator
BlobViewer::Balsamiq,
BlobViewer::Video,
+ BlobViewer::Audio,
BlobViewer::PDF,
@@ -176,7 +177,11 @@ class Blob < SimpleDelegator
end
def video?
- UploaderHelper::VIDEO_EXT.include?(extension)
+ UploaderHelper::SAFE_VIDEO_EXT.include?(extension)
+ end
+
+ def audio?
+ UploaderHelper::SAFE_AUDIO_EXT.include?(extension)
end
def readable_text?
diff --git a/app/models/blob_viewer/audio.rb b/app/models/blob_viewer/audio.rb
new file mode 100644
index 00000000000..cc7fe3b0d90
--- /dev/null
+++ b/app/models/blob_viewer/audio.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module BlobViewer
+ class Audio < Base
+ include Rich
+ include ClientSide
+
+ self.partial_name = 'audio'
+ self.extensions = UploaderHelper::SAFE_AUDIO_EXT
+ self.binary = true
+ end
+end
diff --git a/app/models/blob_viewer/image.rb b/app/models/blob_viewer/image.rb
index 56e27839fca..cbebef46c60 100644
--- a/app/models/blob_viewer/image.rb
+++ b/app/models/blob_viewer/image.rb
@@ -6,7 +6,7 @@ module BlobViewer
include ClientSide
self.partial_name = 'image'
- self.extensions = UploaderHelper::IMAGE_EXT
+ self.extensions = UploaderHelper::SAFE_IMAGE_EXT
self.binary = true
self.switcher_icon = 'picture-o'
self.switcher_title = 'image'
diff --git a/app/models/blob_viewer/video.rb b/app/models/blob_viewer/video.rb
index 48bb2a13518..3ec4e90b24e 100644
--- a/app/models/blob_viewer/video.rb
+++ b/app/models/blob_viewer/video.rb
@@ -6,9 +6,7 @@ module BlobViewer
include ClientSide
self.partial_name = 'video'
- self.extensions = UploaderHelper::VIDEO_EXT
+ self.extensions = UploaderHelper::SAFE_VIDEO_EXT
self.binary = true
- self.switcher_icon = 'film'
- self.switcher_title = 'video'
end
end
diff --git a/app/models/board.rb b/app/models/board.rb
index 31011dc4742..f3f938224a4 100644
--- a/app/models/board.rb
+++ b/app/models/board.rb
@@ -16,10 +16,9 @@ class Board < ApplicationRecord
!group
end
- def parent
- @parent ||= group || project
+ def resource_parent
+ @resource_parent ||= group || project
end
- alias_method :resource_parent, :parent
def group_board?
group_id.present?
diff --git a/app/models/ci/artifact_blob.rb b/app/models/ci/artifact_blob.rb
index ef00ad75683..76d4b9d6206 100644
--- a/app/models/ci/artifact_blob.rb
+++ b/app/models/ci/artifact_blob.rb
@@ -53,7 +53,7 @@ module Ci
pages_config.enabled &&
pages_config.artifacts_server &&
EXTENSIONS_SERVED_BY_PAGES.include?(File.extname(name)) &&
- job.project.public?
+ (pages_config.access_control || job.project.public?)
end
private
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 1f8a0373450..c48ab28ce73 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -12,7 +12,6 @@ module Ci
include Presentable
include Importable
include Gitlab::Utils::StrongMemoize
- include Deployable
include HasRef
BuildArchivedError = Class.new(StandardError)
@@ -43,6 +42,7 @@ module Ci
has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy, inverse_of: :job # rubocop:disable Cop/ActiveRecordDependent
has_many :job_variables, class_name: 'Ci::JobVariable', foreign_key: :job_id
+ has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_job_id
Ci::JobArtifact.file_types.each do |key, value|
has_one :"job_artifacts_#{key}", -> { where(file_type: value) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id
@@ -118,8 +118,6 @@ module Ci
scope :eager_load_job_artifacts, -> { includes(:job_artifacts) }
- scope :with_artifacts_stored_locally, -> { with_existing_job_artifacts(Ci::JobArtifact.archive.with_files_stored_locally) }
- scope :with_archived_trace_stored_locally, -> { with_existing_job_artifacts(Ci::JobArtifact.trace.with_files_stored_locally) }
scope :with_artifacts_not_expired, ->() { with_artifacts_archive.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) }
scope :with_expired_artifacts, ->() { with_artifacts_archive.where('artifacts_expire_at < ?', Time.now) }
scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) }
@@ -130,6 +128,12 @@ module Ci
scope :with_stale_live_trace, -> { with_live_trace.finished_before(12.hours.ago) }
scope :finished_before, -> (date) { finished.where('finished_at < ?', date) }
+ scope :with_secure_reports_from_options, -> (job_type) { where('options like :job_type', job_type: "%:artifacts:%:reports:%:#{job_type}:%") }
+
+ scope :with_secure_reports_from_config_options, -> (job_types) do
+ joins(:metadata).where("ci_builds_metadata.config_options -> 'artifacts' -> 'reports' ?| array[:job_types]", job_types: job_types)
+ end
+
scope :matches_tag_ids, -> (tag_ids) do
matcher = ::ActsAsTaggableOn::Tagging
.where(taggable_type: CommitStatus.name)
@@ -236,6 +240,7 @@ module Ci
end
after_transition pending: :running do |build|
+ build.pipeline.persistent_ref.create
build.deployment&.run
build.run_after_commit do
@@ -412,10 +417,6 @@ module Ci
self.options.fetch(:environment, {}).fetch(:action, 'start') if self.options
end
- def has_deployment?
- !!self.deployment
- end
-
def outdated_deployment?
success? && !deployment.try(:last?)
end
@@ -753,6 +754,10 @@ module Ci
true
end
+ def invalid_dependencies
+ dependencies.reject(&:valid_dependency?)
+ end
+
def runner_required_feature_names
strong_memoize(:runner_required_feature_names) do
RUNNER_FEATURES.select do |feature, method|
diff --git a/app/models/ci/build_trace.rb b/app/models/ci/build_trace.rb
new file mode 100644
index 00000000000..b9db1559836
--- /dev/null
+++ b/app/models/ci/build_trace.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Ci
+ class BuildTrace
+ CONVERTERS = {
+ html: Gitlab::Ci::Ansi2html,
+ json: Gitlab::Ci::Ansi2json
+ }.freeze
+
+ attr_reader :trace, :build
+
+ delegate :state, :append, :truncated, :offset, :size, :total, to: :trace, allow_nil: true
+ delegate :id, :status, :complete?, to: :build, prefix: true
+
+ def initialize(build:, stream:, state:, content_format:)
+ @build = build
+ @content_format = content_format
+
+ if stream.valid?
+ stream.limit
+ @trace = CONVERTERS.fetch(content_format).convert(stream.stream, state)
+ end
+ end
+
+ def json?
+ @content_format == :json
+ end
+
+ def html?
+ @content_format == :html
+ end
+
+ def json_lines
+ @trace&.lines if json?
+ end
+
+ def html_lines
+ @trace&.html if html?
+ end
+ end
+end
diff --git a/app/models/ci/build_trace_section.rb b/app/models/ci/build_trace_section.rb
index 8be42eb48d6..7fe6b753da1 100644
--- a/app/models/ci/build_trace_section.rb
+++ b/app/models/ci/build_trace_section.rb
@@ -4,6 +4,9 @@ module Ci
class BuildTraceSection < ApplicationRecord
extend Gitlab::Ci::Model
+ # Only remove > 2019-11-22 and > 12.5
+ self.ignored_columns += %i[id]
+
belongs_to :build, class_name: 'Ci::Build'
belongs_to :project
belongs_to :section_name, class_name: 'Ci::BuildTraceSectionName'
diff --git a/app/models/ci/group.rb b/app/models/ci/group.rb
index 9b2c3c807ac..0e05318b253 100644
--- a/app/models/ci/group.rb
+++ b/app/models/ci/group.rb
@@ -9,6 +9,7 @@ module Ci
#
class Group
include StaticModel
+ include Gitlab::Utils::StrongMemoize
attr_reader :stage, :name, :jobs
@@ -21,7 +22,17 @@ module Ci
end
def status
- @status ||= commit_statuses.status
+ strong_memoize(:status) do
+ if Feature.enabled?(:ci_composite_status, default_enabled: false)
+ Gitlab::Ci::Status::Composite
+ .new(@jobs)
+ .status
+ else
+ CommitStatus
+ .where(id: @jobs)
+ .legacy_status
+ end
+ end
end
def detailed_status(current_user)
@@ -40,11 +51,5 @@ module Ci
self.new(stage, name: group_name, jobs: grouped_statuses)
end
end
-
- private
-
- def commit_statuses
- @commit_statuses ||= CommitStatus.where(id: jobs.map(&:id))
- end
end
end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index da2758507ce..62bf2c3ac9c 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -5,6 +5,7 @@ module Ci
include AfterCommitQueue
include ObjectStorage::BackgroundMove
include UpdateProjectStatistics
+ include Sortable
extend Gitlab::Ci::Model
NotSupportedAdapterError = Class.new(StandardError)
@@ -64,6 +65,7 @@ module Ci
after_save :update_file_store, if: :saved_change_to_file?
scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) }
+ scope :with_files_stored_remotely, -> { where(file_store: ::JobArtifactUploader::Store::REMOTE) }
scope :with_file_types, -> (file_types) do
types = self.file_types.select { |file_type| file_types.include?(file_type) }.values
@@ -143,6 +145,10 @@ module Ci
self.update_column(:file_store, file.object_store)
end
+ def self.total_size
+ self.sum(:size)
+ end
+
def self.artifacts_size_for(project)
self.where(project: project).sum(:size)
end
diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb
index 930c8a71453..2fd369c9aff 100644
--- a/app/models/ci/legacy_stage.rb
+++ b/app/models/ci/legacy_stage.rb
@@ -14,7 +14,8 @@ module Ci
@pipeline = pipeline
@name = name
@status = status
- @warnings = warnings
+ # support ints and booleans
+ @has_warnings = ActiveRecord::Type::Boolean.new.cast(warnings)
end
def groups
@@ -30,7 +31,7 @@ module Ci
end
def status
- @status ||= statuses.latest.status
+ @status ||= statuses.latest.slow_composite_status
end
def detailed_status(current_user)
@@ -52,11 +53,12 @@ module Ci
end
def has_warnings?
- if @warnings.is_a?(Integer)
- @warnings > 0
- else
- statuses.latest.failed_but_allowed.any?
+ # lazilly calculate the warnings
+ if @has_warnings.nil?
+ @has_warnings = statuses.latest.failed_but_allowed.any?
end
+
+ @has_warnings
end
def manual_playable?
diff --git a/app/models/ci/persistent_ref.rb b/app/models/ci/persistent_ref.rb
new file mode 100644
index 00000000000..be3d4aa3203
--- /dev/null
+++ b/app/models/ci/persistent_ref.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Ci
+ ##
+ # The persistent pipeline ref to ensure runners can safely fetch source code
+ # even if force-push/source-branch-deletion happens.
+ class PersistentRef
+ include ActiveModel::Model
+
+ attr_accessor :pipeline
+
+ delegate :project, :sha, to: :pipeline
+ delegate :repository, to: :project
+ delegate :ref_exists?, :create_ref, :delete_refs, to: :repository
+
+ def exist?
+ ref_exists?(path)
+ rescue
+ false
+ end
+
+ def create
+ return if exist?
+
+ create_ref(sha, path)
+ rescue => e
+ Gitlab::Sentry
+ .track_acceptable_exception(e, extra: { pipeline_id: pipeline.id })
+ end
+
+ def delete
+ delete_refs(path)
+ rescue Gitlab::Git::Repository::NoRepository
+ # no-op
+ rescue => e
+ Gitlab::Sentry
+ .track_acceptable_exception(e, extra: { pipeline_id: pipeline.id })
+ end
+
+ def path
+ "refs/#{Repository::REF_PIPELINES}/#{pipeline.id}"
+ end
+ end
+end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 20b8be4017e..3bf19399cec 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -25,7 +25,7 @@ module Ci
belongs_to :merge_request, class_name: 'MergeRequest'
belongs_to :external_pull_request
- has_internal_id :iid, scope: :project, presence: false, init: ->(s) do
+ has_internal_id :iid, scope: :project, presence: false, ensure_if: -> { !importing? }, init: ->(s) do
s&.project&.all_pipelines&.maximum(:iid) || s&.project&.all_pipelines&.count
end
@@ -52,9 +52,15 @@ module Ci
has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id'
has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id'
+ has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_pipeline_id
+ has_one :source_pipeline, class_name: 'Ci::Sources::Pipeline', inverse_of: :pipeline
has_one :chat_data, class_name: 'Ci::PipelineChatData'
+ has_many :triggered_pipelines, through: :sourced_pipelines, source: :pipeline
+ has_one :triggered_by_pipeline, through: :source_pipeline, source: :source_pipeline
+ has_one :source_job, through: :source_pipeline, source: :source_job
+
accepts_nested_attributes_for :variables, reject_if: :persisted?
delegate :id, to: :project, prefix: true
@@ -174,6 +180,8 @@ module Ci
after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline|
pipeline.run_after_commit do
+ pipeline.persistent_ref.delete
+
pipeline.all_merge_requests.each do |merge_request|
next unless merge_request.auto_merge_enabled?
@@ -209,6 +217,8 @@ module Ci
scope :for_sha, -> (sha) { where(sha: sha) }
scope :for_source_sha, -> (source_sha) { where(source_sha: source_sha) }
scope :for_sha_or_source_sha, -> (sha) { for_sha(sha).or(for_source_sha(sha)) }
+ scope :for_ref, -> (ref) { where(ref: ref) }
+ scope :for_id, -> (id) { where(id: id) }
scope :created_after, -> (time) { where('ci_pipelines.created_at > ?', time) }
scope :triggered_by_merge_request, -> (merge_request) do
@@ -279,16 +289,16 @@ module Ci
end
end
- # Returns a Hash containing the latest pipeline status for every given
+ # Returns a Hash containing the latest pipeline for every given
# commit.
#
- # The keys of this Hash are the commit SHAs, the values the statuses.
+ # The keys of this Hash are the commit SHAs, the values the pipelines.
#
- # commits - The list of commit SHAs to get the status for.
+ # commits - The list of commit SHAs to get the pipelines for.
# ref - The ref to scope the data to (e.g. "master"). If the ref is not
- # given we simply get the latest status for the commits, regardless
- # of what refs their pipelines belong to.
- def self.latest_status_per_commit(commits, ref = nil)
+ # given we simply get the latest pipelines for the commits, regardless
+ # of what refs the pipelines belong to.
+ def self.latest_pipeline_per_commit(commits, ref = nil)
p1 = arel_table
p2 = arel_table.alias
@@ -302,15 +312,14 @@ module Ci
cond = cond.and(p1[:ref].eq(p2[:ref])) if ref
join = p1.join(p2, Arel::Nodes::OuterJoin).on(cond)
- relation = select(:sha, :status)
- .where(sha: commits)
+ relation = where(sha: commits)
.where(p2[:id].eq(nil))
.joins(join.join_sources)
relation = relation.where(ref: ref) if ref
- relation.each_with_object({}) do |row, hash|
- hash[row[:sha]] = row[:status]
+ relation.each_with_object({}) do |pipeline, hash|
+ hash[pipeline.sha] = pipeline
end
end
@@ -385,13 +394,12 @@ module Ci
end
end
- def legacy_stages
+ def legacy_stages_using_sql
# TODO, this needs refactoring, see gitlab-foss#26481.
-
stages_query = statuses
.group('stage').select(:stage).order('max(stage_idx)')
- status_sql = statuses.latest.where('stage=sg.stage').status_sql
+ status_sql = statuses.latest.where('stage=sg.stage').legacy_status_sql
warnings_sql = statuses.latest.select('COUNT(*)')
.where('stage=sg.stage').failed_but_allowed.to_sql
@@ -404,6 +412,30 @@ module Ci
end
end
+ def legacy_stages_using_composite_status
+ stages = statuses.latest
+ .order(:stage_idx, :stage)
+ .group_by(&:stage)
+
+ stages.map do |stage_name, jobs|
+ composite_status = Gitlab::Ci::Status::Composite
+ .new(jobs)
+
+ Ci::LegacyStage.new(self,
+ name: stage_name,
+ status: composite_status.status,
+ warnings: composite_status.warnings?)
+ end
+ end
+
+ def legacy_stages
+ if Feature.enabled?(:ci_composite_status, default_enabled: false)
+ legacy_stages_using_composite_status
+ else
+ legacy_stages_using_sql
+ end
+ end
+
def valid_commit_sha
if self.sha == Gitlab::Git::BLANK_SHA
self.errors.add(:sha, " cant be 00000000 (branch removal)")
@@ -584,11 +616,7 @@ module Ci
def ci_yaml_file_path
return unless repository_source? || unknown_source?
- if project.ci_config_path.blank?
- '.gitlab-ci.yml'
- else
- project.ci_config_path
- end
+ project.ci_config_path.presence || '.gitlab-ci.yml'
end
def ci_yaml_file
@@ -638,7 +666,8 @@ module Ci
def update_status
retry_optimistic_lock(self) do
- case latest_builds_status.to_s
+ new_status = latest_builds_status.to_s
+ case new_status
when 'created' then nil
when 'preparing' then prepare
when 'pending' then enqueue
@@ -651,7 +680,7 @@ module Ci
when 'scheduled' then delay
else
raise HasStatus::UnknownStatusError,
- "Unknown status `#{latest_builds_status}`"
+ "Unknown status `#{new_status}`"
end
end
end
@@ -725,6 +754,10 @@ module Ci
end
end
+ def all_merge_requests_by_recency
+ all_merge_requests.order(id: :desc)
+ end
+
def detailed_status(current_user)
Gitlab::Ci::Status::Pipeline::Factory
.new(self, current_user)
@@ -771,6 +804,18 @@ module Ci
end
end
+ def all_worktree_paths
+ strong_memoize(:all_worktree_paths) do
+ project.repository.ls_files(sha)
+ end
+ end
+
+ def top_level_worktree_paths
+ strong_memoize(:top_level_worktree_paths) do
+ project.repository.tree(sha).blobs.map(&:path)
+ end
+ end
+
def default_branch?
ref == project.default_branch
end
@@ -845,6 +890,10 @@ module Ci
end
end
+ def persistent_ref
+ @persistent_ref ||= PersistentRef.new(pipeline: self)
+ end
+
private
def ci_yaml_from_repo
@@ -894,7 +943,7 @@ module Ci
def latest_builds_status
return 'failed' unless yaml_errors.blank?
- statuses.latest.status || 'skipped'
+ statuses.latest.slow_composite_status || 'skipped'
end
def keep_around_commits
diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb
index cb92aef4bda..859abc4a0d5 100644
--- a/app/models/ci/pipeline_enums.rb
+++ b/app/models/ci/pipeline_enums.rb
@@ -22,6 +22,7 @@ module Ci
schedule: 4,
api: 5,
external: 6,
+ pipeline: 7,
chat: 8,
merge_request_event: 10,
external_pull_request_event: 11
diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb
index 42d4e86fe8d..946241b7d4c 100644
--- a/app/models/ci/pipeline_schedule.rb
+++ b/app/models/ci/pipeline_schedule.rb
@@ -86,3 +86,5 @@ module Ci
end
end
end
+
+Ci::PipelineSchedule.prepend_if_ee('EE::Ci::PipelineSchedule')
diff --git a/app/models/ci/sources/pipeline.rb b/app/models/ci/sources/pipeline.rb
new file mode 100644
index 00000000000..feaec27281c
--- /dev/null
+++ b/app/models/ci/sources/pipeline.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Ci
+ module Sources
+ class Pipeline < ApplicationRecord
+ self.table_name = "ci_sources_pipelines"
+
+ belongs_to :project, class_name: "Project"
+ belongs_to :pipeline, class_name: "Ci::Pipeline", inverse_of: :source_pipeline
+
+ belongs_to :source_project, class_name: "Project", foreign_key: :source_project_id
+ belongs_to :source_job, class_name: "CommitStatus", foreign_key: :source_job_id
+ belongs_to :source_pipeline, class_name: "Ci::Pipeline", foreign_key: :source_pipeline_id
+
+ validates :project, presence: true
+ validates :pipeline, presence: true
+
+ validates :source_project, presence: true
+ validates :source_job, presence: true
+ validates :source_pipeline, presence: true
+ end
+ end
+end
+
+::Ci::Sources::Pipeline.prepend_if_ee('::EE::Ci::Sources::Pipeline')
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index d90339d90dc..77ac8bfe875 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -78,7 +78,8 @@ module Ci
def update_status
retry_optimistic_lock(self) do
- case statuses.latest.status
+ new_status = latest_stage_status.to_s
+ case new_status
when 'created' then nil
when 'preparing' then prepare
when 'pending' then enqueue
@@ -91,7 +92,7 @@ module Ci
when 'skipped', nil then skip
else
raise HasStatus::UnknownStatusError,
- "Unknown status `#{statuses.latest.status}`"
+ "Unknown status `#{new_status}`"
end
end
end
@@ -124,5 +125,9 @@ module Ci
def manual_playable?
blocked? || skipped?
end
+
+ def latest_stage_status
+ statuses.latest.slow_composite_status || 'skipped'
+ end
end
end
diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb
index 8792c5cf98b..68548bd2fdc 100644
--- a/app/models/ci/trigger.rb
+++ b/app/models/ci/trigger.rb
@@ -45,3 +45,5 @@ module Ci
end
end
end
+
+Ci::Trigger.prepend_if_ee('EE::Ci::Trigger')
diff --git a/app/models/clusters/applications/cert_manager.rb b/app/models/clusters/applications/cert_manager.rb
index 27d4180e5b9..18cbf827a67 100644
--- a/app/models/clusters/applications/cert_manager.rb
+++ b/app/models/clusters/applications/cert_manager.rb
@@ -65,7 +65,7 @@ module Clusters
end
def retry_command(command)
- "for i in $(seq 1 30); do #{command} && break; sleep 1s; echo \"Retrying ($i)...\"; done"
+ "for i in $(seq 1 30); do #{command} && s=0 && break || s=$?; sleep 1s; echo \"Retrying ($i)...\"; done; (exit $s)"
end
def post_delete_script
diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb
index 455cf200fbc..4a1bcac4bb7 100644
--- a/app/models/clusters/applications/helm.rb
+++ b/app/models/clusters/applications/helm.rb
@@ -27,7 +27,7 @@ module Clusters
def set_initial_status
return unless not_installable?
- self.status = 'installable' if cluster&.platform_kubernetes_active?
+ self.status = status_states[:installable] if cluster&.platform_kubernetes_active?
end
# It can only be uninstalled if there are no other applications installed
@@ -68,6 +68,13 @@ module Clusters
ca_key.present? && ca_cert.present?
end
+ def post_uninstall
+ cluster.kubeclient.delete_namespace(Gitlab::Kubernetes::Helm::NAMESPACE)
+ rescue Kubeclient::ResourceNotFoundError
+ # we actually don't care if the namespace is not present
+ # since we want to delete it anyway.
+ end
+
private
def files
diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
index 44c66f06059..885e4ff7197 100644
--- a/app/models/clusters/applications/ingress.rb
+++ b/app/models/clusters/applications/ingress.rb
@@ -3,7 +3,7 @@
module Clusters
module Applications
class Ingress < ApplicationRecord
- VERSION = '1.1.2'
+ VERSION = '1.22.1'
self.table_name = 'clusters_applications_ingress'
diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb
index ec65482a846..ca93bc15be0 100644
--- a/app/models/clusters/applications/jupyter.rb
+++ b/app/models/clusters/applications/jupyter.rb
@@ -23,7 +23,7 @@ module Clusters
return unless cluster&.application_ingress_available?
ingress = cluster.application_ingress
- self.status = 'installable' if ingress.external_ip_or_hostname?
+ self.status = status_states[:installable] if ingress.external_ip_or_hostname?
end
def chart
diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb
index a9b9374622d..1093efee85a 100644
--- a/app/models/clusters/applications/knative.rb
+++ b/app/models/clusters/applications/knative.rb
@@ -3,7 +3,7 @@
module Clusters
module Applications
class Knative < ApplicationRecord
- VERSION = '0.6.0'
+ VERSION = '0.7.0'
REPOSITORY = 'https://storage.googleapis.com/triggermesh-charts'
METRICS_CONFIG = 'https://storage.googleapis.com/triggermesh-charts/istio-metrics.yaml'
FETCH_IP_ADDRESS_DELAY = 30.seconds
@@ -21,7 +21,7 @@ module Clusters
return unless not_installable?
return unless verify_cluster?
- self.status = 'installable'
+ self.status = status_states[:installable]
end
state_machine :status do
@@ -47,6 +47,10 @@ module Clusters
{ "domain" => hostname }.to_yaml
end
+ def allowed_to_uninstall?
+ !pre_installed?
+ end
+
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
name: name,
diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb
index 7a414d1a5bb..5e7fdd55cb6 100644
--- a/app/models/clusters/applications/prometheus.rb
+++ b/app/models/clusters/applications/prometheus.rb
@@ -112,7 +112,12 @@ module Clusters
def delete_knative_istio_metrics
return [] unless cluster.application_knative_available?
- [Gitlab::Kubernetes::KubectlCmd.delete("-f", Clusters::Applications::Knative::METRICS_CONFIG)]
+ [
+ Gitlab::Kubernetes::KubectlCmd.delete(
+ "-f", Clusters::Applications::Knative::METRICS_CONFIG,
+ "--ignore-not-found"
+ )
+ ]
end
end
end
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index 2d6af8f4f0b..954046c143b 100644
--- a/app/models/clusters/applications/runner.rb
+++ b/app/models/clusters/applications/runner.rb
@@ -3,7 +3,7 @@
module Clusters
module Applications
class Runner < ApplicationRecord
- VERSION = '0.8.0'
+ VERSION = '0.9.0'
self.table_name = 'clusters_applications_runners'
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index 6a5b98a4676..d6f5d7c3f93 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -24,6 +24,7 @@ module Clusters
KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN'
belongs_to :user
+ belongs_to :management_project, class_name: '::Project', optional: true
has_many :cluster_projects, class_name: 'Clusters::Project'
has_many :projects, through: :cluster_projects, class_name: '::Project'
@@ -34,12 +35,13 @@ module Clusters
# we force autosave to happen when we save `Cluster` model
has_one :provider_gcp, class_name: 'Clusters::Providers::Gcp', autosave: true
+ has_one :provider_aws, class_name: 'Clusters::Providers::Aws', autosave: true
has_one :platform_kubernetes, class_name: 'Clusters::Platforms::Kubernetes', inverse_of: :cluster, autosave: true
def self.has_one_cluster_application(name) # rubocop:disable Naming/PredicateName
application = APPLICATIONS[name.to_s]
- has_one application.association_name, class_name: application.to_s # rubocop:disable Rails/ReflectionClassName
+ has_one application.association_name, class_name: application.to_s, inverse_of: :cluster # rubocop:disable Rails/ReflectionClassName
end
has_one_cluster_application :helm
@@ -63,6 +65,7 @@ module Clusters
validate :restrict_modification, on: :update
validate :no_groups, unless: :group_type?
validate :no_projects, unless: :project_type?
+ validate :unique_management_project_environment_scope
after_save :clear_reactive_cache!
@@ -94,14 +97,20 @@ module Clusters
enum provider_type: {
user: 0,
- gcp: 1
+ gcp: 1,
+ aws: 2
}
scope :enabled, -> { where(enabled: true) }
scope :disabled, -> { where(enabled: false) }
- scope :user_provided, -> { where(provider_type: ::Clusters::Cluster.provider_types[:user]) }
- scope :gcp_provided, -> { where(provider_type: ::Clusters::Cluster.provider_types[:gcp]) }
- scope :gcp_installed, -> { gcp_provided.includes(:provider_gcp).where(cluster_providers_gcp: { status: ::Clusters::Providers::Gcp.state_machines[:status].states[:created].value }) }
+
+ scope :user_provided, -> { where(provider_type: :user) }
+ scope :gcp_provided, -> { where(provider_type: :gcp) }
+ scope :aws_provided, -> { where(provider_type: :aws) }
+
+ scope :gcp_installed, -> { gcp_provided.joins(:provider_gcp).merge(Clusters::Providers::Gcp.with_status(:created)) }
+ scope :aws_installed, -> { aws_provided.joins(:provider_aws).merge(Clusters::Providers::Aws.with_status(:created)) }
+
scope :managed, -> { where(managed: true) }
scope :default_environment, -> { where(environment_scope: DEFAULT_ENVIRONMENT) }
@@ -138,7 +147,11 @@ module Clusters
end
def provider
- return provider_gcp if gcp?
+ if gcp?
+ provider_gcp
+ elsif aws?
+ provider_aws
+ end
end
def platform
@@ -172,7 +185,7 @@ module Clusters
persisted_namespace = Clusters::KubernetesNamespaceFinder.new(
self,
project: project,
- environment_slug: environment.slug
+ environment_name: environment.name
).execute
persisted_namespace&.namespace || Gitlab::Kubernetes::DefaultNamespace.new(self, project: project).from_environment_slug(environment.slug)
@@ -194,8 +207,24 @@ module Clusters
end
end
+ def knative_pre_installed?
+ provider&.knative_pre_installed?
+ end
+
private
+ def unique_management_project_environment_scope
+ return unless management_project
+
+ duplicate_management_clusters = management_project.management_clusters
+ .where(environment_scope: environment_scope)
+ .where.not(id: id)
+
+ if duplicate_management_clusters.any?
+ errors.add(:environment_scope, "cannot add duplicated environment scope")
+ end
+ end
+
def instance_domain
@instance_domain ||= Gitlab::CurrentSettings.auto_devops_domain
end
diff --git a/app/models/clusters/clusters_hierarchy.rb b/app/models/clusters/clusters_hierarchy.rb
index 5556fc8d3f0..a906eb2888b 100644
--- a/app/models/clusters/clusters_hierarchy.rb
+++ b/app/models/clusters/clusters_hierarchy.rb
@@ -4,8 +4,9 @@ module Clusters
class ClustersHierarchy
DEPTH_COLUMN = :depth
- def initialize(clusterable)
+ def initialize(clusterable, include_management_project: true)
@clusterable = clusterable
+ @include_management_project = include_management_project
end
# Returns clusters in order from deepest to highest group
@@ -24,7 +25,7 @@ module Clusters
private
- attr_reader :clusterable
+ attr_reader :clusterable, :include_management_project
def recursive_cte
cte = Gitlab::SQL::RecursiveCTE.new(:clusters_cte)
@@ -38,12 +39,25 @@ module Clusters
raise ArgumentError, "unknown type for #{clusterable}"
end
+ if clusterable.is_a?(::Project) && include_management_project
+ cte << management_clusters_query
+ end
+
cte << base_query
cte << parent_query(cte)
cte
end
+ # Management clusters should be first in the hierarchy so we use 0 for the
+ # depth column.
+ #
+ # group_parent_id is un-used but we still need to match the same number of
+ # columns as other queries in the CTE.
+ def management_clusters_query
+ clusterable.management_clusters.select([clusters_star, 'NULL AS group_parent_id', "0 AS #{DEPTH_COLUMN}"])
+ end
+
def group_clusters_base_query
group_parent_id_alias = alias_as_column(groups[:parent_id], 'group_parent_id')
join_sources = ::Group.left_joins(:clusters).arel.join_sources
diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb
index d1b57a21a7d..979cf0645f5 100644
--- a/app/models/clusters/concerns/application_core.rb
+++ b/app/models/clusters/concerns/application_core.rb
@@ -15,7 +15,7 @@ module Clusters
def set_initial_status
return unless not_installable?
- self.status = 'installable' if cluster&.application_helm_available?
+ self.status = status_states[:installable] if cluster&.application_helm_available?
end
def can_uninstall?
@@ -64,3 +64,5 @@ module Clusters
end
end
end
+
+Clusters::Concerns::ApplicationCore.prepend_if_ee('EE::Clusters::Concerns::ApplicationCore')
diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb
index 342d766f723..b63a596dfee 100644
--- a/app/models/clusters/concerns/application_status.rb
+++ b/app/models/clusters/concerns/application_status.rb
@@ -28,6 +28,13 @@ module Clusters
state :uninstalling, value: 7
state :uninstall_errored, value: 8
+ # Used for applications that are pre-installed by the cluster,
+ # e.g. Knative in GCP Cloud Run enabled clusters
+ # Because we cannot upgrade or uninstall Knative in these clusters,
+ # we define only one simple state transition to enter the `pre_installed` state,
+ # and no exit transitions.
+ state :pre_installed, value: 9
+
event :make_scheduled do
transition [:installable, :errored, :installed, :updated, :update_errored, :uninstall_errored] => :scheduled
end
@@ -41,6 +48,10 @@ module Clusters
transition [:updating] => :updated
end
+ event :make_pre_installed do
+ transition any => :pre_installed
+ end
+
event :make_errored do
transition any - [:updating, :uninstalling] => :errored
transition [:updating] => :update_errored
@@ -90,12 +101,18 @@ module Clusters
end
end
+ def status_states
+ self.class.state_machines[:status].states.each_with_object({}) do |state, states|
+ states[state.name] = state.value
+ end
+ end
+
def updateable?
installed? || updated? || update_errored?
end
def available?
- installed? || updated?
+ pre_installed? || installed? || updated?
end
def update_in_progress?
diff --git a/app/models/clusters/concerns/application_version.rb b/app/models/clusters/concerns/application_version.rb
index db94e8e08c9..6c0b014662c 100644
--- a/app/models/clusters/concerns/application_version.rb
+++ b/app/models/clusters/concerns/application_version.rb
@@ -8,13 +8,13 @@ module Clusters
included do
state_machine :status do
before_transition any => [:installed, :updated] do |application|
- application.version = application.class.const_get(:VERSION)
+ application.version = application.class.const_get(:VERSION, false)
end
end
end
def update_available?
- version != self.class.const_get(:VERSION)
+ version != self.class.const_get(:VERSION, false)
end
end
end
diff --git a/app/models/clusters/concerns/provider_status.rb b/app/models/clusters/concerns/provider_status.rb
new file mode 100644
index 00000000000..2da1ee7aabb
--- /dev/null
+++ b/app/models/clusters/concerns/provider_status.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Concerns
+ module ProviderStatus
+ extend ActiveSupport::Concern
+
+ included do
+ state_machine :status, initial: :scheduled do
+ state :scheduled, value: 1
+ state :creating, value: 2
+ state :created, value: 3
+ state :errored, value: 4
+
+ event :make_creating do
+ transition any - [:creating] => :creating
+ end
+
+ event :make_created do
+ transition any - [:created] => :created
+ end
+
+ event :make_errored do
+ transition any - [:errored] => :errored
+ end
+
+ before_transition any => [:errored, :created] do |provider|
+ provider.nullify_credentials
+ end
+
+ before_transition any => [:creating] do |provider, transition|
+ operation_id = transition.args.first
+ provider.assign_operation_id(operation_id) if operation_id
+ end
+
+ before_transition any => [:errored] do |provider, transition|
+ status_reason = transition.args.first
+ provider.status_reason = status_reason if status_reason
+ end
+ end
+
+ def on_creation?
+ scheduled? || creating?
+ end
+
+ def assign_operation_id(_)
+ # Implemented by individual providers if operation ID is supported.
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/kubernetes_namespace.rb b/app/models/clusters/kubernetes_namespace.rb
index 69a2b99fcb6..42332bdc193 100644
--- a/app/models/clusters/kubernetes_namespace.rb
+++ b/app/models/clusters/kubernetes_namespace.rb
@@ -27,7 +27,7 @@ module Clusters
algorithm: 'aes-256-cbc'
scope :has_service_account_token, -> { where.not(encrypted_service_account_token: nil) }
- scope :with_environment_slug, -> (slug) { joins(:environment).where(environments: { slug: slug }) }
+ scope :with_environment_name, -> (name) { joins(:environment).where(environments: { name: name }) }
def token_name
"#{namespace}-token"
diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb
index 89b50d8e8ff..314ef78757d 100644
--- a/app/models/clusters/platforms/kubernetes.rb
+++ b/app/models/clusters/platforms/kubernetes.rb
@@ -6,6 +6,7 @@ module Clusters
include Gitlab::Kubernetes
include EnumWithNil
include AfterCommitQueue
+ include ReactiveCaching
RESERVED_NAMESPACES = %w(gitlab-managed-apps).freeze
@@ -23,11 +24,12 @@ module Clusters
key: Settings.attr_encrypted_db_key_base_truncated,
algorithm: 'aes-256-cbc'
+ before_validation :nullify_blank_namespace
before_validation :enforce_namespace_to_lower_case
before_validation :enforce_ca_whitespace_trimming
validates :namespace,
- allow_blank: true,
+ allow_nil: true,
length: 1..63,
format: {
with: Gitlab::Regex.kubernetes_namespace_regex,
@@ -71,7 +73,7 @@ module Clusters
.append(key: 'KUBE_CA_PEM_FILE', value: ca_pem, file: true)
end
- if !cluster.managed?
+ if !cluster.managed? || cluster.management_project == project
namespace = Gitlab::Kubernetes::DefaultNamespace.new(cluster, project: project).from_environment_name(environment_name)
variables
@@ -105,19 +107,11 @@ module Clusters
private
- ##
- # Environment slug can be predicted given an environment
- # name, so even if the environment isn't persisted yet we
- # still know what to look for.
- def environment_slug(name)
- Gitlab::Slug::Environment.new(name).generate
- end
-
def find_persisted_namespace(project, environment_name:)
Clusters::KubernetesNamespaceFinder.new(
cluster,
project: project,
- environment_slug: environment_slug(environment_name)
+ environment_name: environment_name
).execute
end
@@ -198,6 +192,10 @@ module Clusters
true
end
+
+ def nullify_blank_namespace
+ self.namespace = nil if namespace.blank?
+ end
end
end
end
diff --git a/app/models/clusters/providers/aws.rb b/app/models/clusters/providers/aws.rb
new file mode 100644
index 00000000000..ae4156896bc
--- /dev/null
+++ b/app/models/clusters/providers/aws.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Providers
+ class Aws < ApplicationRecord
+ include Clusters::Concerns::ProviderStatus
+
+ self.table_name = 'cluster_providers_aws'
+
+ belongs_to :cluster, inverse_of: :provider_aws, class_name: 'Clusters::Cluster'
+ belongs_to :created_by_user, class_name: 'User'
+
+ default_value_for :region, 'us-east-1'
+ default_value_for :num_nodes, 3
+ default_value_for :instance_type, 'm5.large'
+
+ attr_encrypted :secret_access_key,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ algorithm: 'aes-256-gcm'
+
+ validates :role_arn,
+ length: 1..2048,
+ format: {
+ with: Gitlab::Regex.aws_arn_regex,
+ message: Gitlab::Regex.aws_arn_regex_message
+ }
+
+ validates :num_nodes,
+ numericality: {
+ only_integer: true,
+ greater_than: 0
+ }
+
+ validates :key_name, :region, :instance_type, :security_group_id, length: { in: 1..255 }
+ validates :subnet_ids, presence: true
+
+ def nullify_credentials
+ assign_attributes(
+ access_key_id: nil,
+ secret_access_key: nil,
+ session_token: nil
+ )
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/providers/gcp.rb b/app/models/clusters/providers/gcp.rb
index 390748bf252..f871674676f 100644
--- a/app/models/clusters/providers/gcp.rb
+++ b/app/models/clusters/providers/gcp.rb
@@ -3,6 +3,8 @@
module Clusters
module Providers
class Gcp < ApplicationRecord
+ include Clusters::Concerns::ProviderStatus
+
self.table_name = 'cluster_providers_gcp'
belongs_to :cluster, inverse_of: :provider_gcp, class_name: 'Clusters::Cluster'
@@ -10,6 +12,9 @@ module Clusters
default_value_for :zone, 'us-central1-a'
default_value_for :num_nodes, 3
default_value_for :machine_type, 'n1-standard-2'
+ default_value_for :cloud_run, false
+
+ scope :cloud_run, -> { where(cloud_run: true) }
attr_encrypted :access_token,
mode: :per_attribute_iv,
@@ -32,50 +37,25 @@ module Clusters
greater_than: 0
}
- state_machine :status, initial: :scheduled do
- state :scheduled, value: 1
- state :creating, value: 2
- state :created, value: 3
- state :errored, value: 4
-
- event :make_creating do
- transition any - [:creating] => :creating
- end
-
- event :make_created do
- transition any - [:created] => :created
- end
-
- event :make_errored do
- transition any - [:errored] => :errored
- end
-
- before_transition any => [:errored, :created] do |provider|
- provider.access_token = nil
- provider.operation_id = nil
- end
-
- before_transition any => [:creating] do |provider, transition|
- operation_id = transition.args.first
- raise ArgumentError.new('operation_id is required') unless operation_id.present?
-
- provider.operation_id = operation_id
- end
+ def api_client
+ return unless access_token
- before_transition any => [:errored] do |provider, transition|
- status_reason = transition.args.first
- provider.status_reason = status_reason if status_reason
- end
+ @api_client ||= GoogleApi::CloudPlatform::Client.new(access_token, nil)
end
- def on_creation?
- scheduled? || creating?
+ def nullify_credentials
+ assign_attributes(
+ access_token: nil,
+ operation_id: nil
+ )
end
- def api_client
- return unless access_token
+ def assign_operation_id(operation_id)
+ assign_attributes(operation_id: operation_id)
+ end
- @api_client ||= GoogleApi::CloudPlatform::Client.new(access_token, nil)
+ def knative_pre_installed?
+ cloud_run?
end
end
end
diff --git a/app/models/commit.rb b/app/models/commit.rb
index a442f607fbf..aae49c36899 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -119,10 +119,22 @@ class Commit
@raw = raw_commit
@project = project
- @statuses = {}
@gpg_commit = Gitlab::Gpg::Commit.new(self) if project
end
+ delegate \
+ :pipelines,
+ :last_pipeline,
+ :latest_pipeline,
+ :latest_pipeline_for_project,
+ :set_latest_pipeline_for_ref,
+ :status,
+ to: :with_pipeline
+
+ def with_pipeline
+ @with_pipeline ||= CommitWithPipeline.new(self)
+ end
+
def id
raw.id
end
@@ -245,10 +257,9 @@ class Commit
end
def author
- # We use __sync so that we get the actual objects back (including an actual
- # nil), instead of a wrapper, as returning a wrapped nil breaks a lot of
- # code.
- lazy_author.__sync
+ strong_memoize(:author) do
+ lazy_author&.itself
+ end
end
request_cache(:author) { author_email.downcase }
@@ -301,30 +312,6 @@ class Commit
)
end
- def pipelines
- project.ci_pipelines.where(sha: sha)
- end
-
- def last_pipeline
- strong_memoize(:last_pipeline) do
- pipelines.last
- end
- end
-
- def status(ref = nil)
- return @statuses[ref] if @statuses.key?(ref)
-
- @statuses[ref] = status_for_project(ref, project)
- end
-
- def status_for_project(ref, pipeline_project)
- pipeline_project.ci_pipelines.latest_status_per_commit(id, ref)[id]
- end
-
- def set_status_for_ref(ref, status)
- @statuses[ref] = status
- end
-
def signature
return @signature if defined?(@signature)
@@ -427,7 +414,7 @@ class Commit
if entry[:type] == :blob
blob = ::Blob.decorate(Gitlab::Git::Blob.new(name: entry[:name]), @project)
- blob.image? || blob.video? ? :raw : :blob
+ blob.image? || blob.video? || blob.audio? ? :raw : :blob
else
entry[:type]
end
diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb
index e8df46e1cc3..d4c29aa295b 100644
--- a/app/models/commit_collection.rb
+++ b/app/models/commit_collection.rb
@@ -34,6 +34,20 @@ class CommitCollection
end
end
+ # Returns the collection with the latest pipeline for every commit pre-set.
+ #
+ # Setting the pipeline for each commit ahead of time removes the need for running
+ # a query for every commit we're displaying.
+ def with_latest_pipeline(ref = nil)
+ pipelines = project.ci_pipelines.latest_pipeline_per_commit(map(&:id), ref)
+
+ each do |commit|
+ commit.set_latest_pipeline_for_ref(ref, pipelines[commit.id])
+ end
+
+ self
+ end
+
def unenriched
commits.reject(&:gitaly_commit?)
end
@@ -58,22 +72,15 @@ class CommitCollection
end.compact]
# Replace the commits, keeping the same order
- @commits = @commits.map do |c|
- replacements.fetch(c.id, c)
- end
-
- self
- end
-
- # Sets the pipeline status for every commit.
- #
- # Setting this status ahead of time removes the need for running a query for
- # every commit we're displaying.
- def with_pipeline_status
- statuses = project.ci_pipelines.latest_status_per_commit(map(&:id), ref)
-
- each do |commit|
- commit.set_status_for_ref(ref, statuses[commit.id])
+ @commits = @commits.map do |original_commit|
+ # Return the original instance: if it didn't need to be batchloaded, it was
+ # already enriched.
+ batch_loaded_commit = replacements.fetch(original_commit.id, original_commit)
+
+ # If batch loading the commit failed, fall back to the original commit.
+ # We need to explicitly check `.nil?` since otherwise a `BatchLoader` instance
+ # that looks like `nil` is returned.
+ batch_loaded_commit.nil? ? original_commit : batch_loaded_commit
end
self
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 5d9d3179f9d..39a6247b3b2 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -48,6 +48,10 @@ class CommitStatus < ApplicationRecord
scope :processables, -> { where(type: %w[Ci::Build Ci::Bridge]) }
scope :for_ids, -> (ids) { where(id: ids) }
+ scope :with_preloads, -> do
+ preload(:project, :user)
+ end
+
scope :with_needs, -> (names = nil) do
needs = Ci::BuildNeed.scoped_build.select(1)
needs = needs.where(name: names) if names
@@ -161,11 +165,11 @@ class CommitStatus < ApplicationRecord
end
def self.status_for_prior_stages(index)
- before_stage(index).latest.status || 'success'
+ before_stage(index).latest.slow_composite_status || 'success'
end
def self.status_for_names(names)
- where(name: names).latest.status || 'success'
+ where(name: names).latest.slow_composite_status || 'success'
end
def locking_enabled?
diff --git a/app/models/commit_with_pipeline.rb b/app/models/commit_with_pipeline.rb
new file mode 100644
index 00000000000..f382ae8f55a
--- /dev/null
+++ b/app/models/commit_with_pipeline.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+class CommitWithPipeline < SimpleDelegator
+ include Presentable
+
+ def initialize(commit)
+ @latest_pipelines = {}
+ super(commit)
+ end
+
+ def pipelines
+ project.ci_pipelines.where(sha: sha)
+ end
+
+ def last_pipeline
+ strong_memoize(:last_pipeline) do
+ pipelines.last
+ end
+ end
+
+ def latest_pipeline(ref = nil)
+ @latest_pipelines.fetch(ref) do |ref|
+ @latest_pipelines[ref] = latest_pipeline_for_project(ref, project)
+ end
+ end
+
+ def latest_pipeline_for_project(ref, pipeline_project)
+ pipeline_project.ci_pipelines.latest_pipeline_per_commit(id, ref)[id]
+ end
+
+ def set_latest_pipeline_for_ref(ref, pipeline)
+ @latest_pipelines[ref] = pipeline
+ end
+
+ def status(ref = nil)
+ latest_pipeline(ref)&.status
+ end
+end
diff --git a/app/models/concerns/analytics/cycle_analytics/stage.rb b/app/models/concerns/analytics/cycle_analytics/stage.rb
index 0c603c2d5e6..54e9a13d1ea 100644
--- a/app/models/concerns/analytics/cycle_analytics/stage.rb
+++ b/app/models/concerns/analytics/cycle_analytics/stage.rb
@@ -7,6 +7,7 @@ module Analytics
included do
validates :name, presence: true
+ validates :name, exclusion: { in: Gitlab::Analytics::CycleAnalytics::DefaultStages.names }, if: :custom?
validates :start_event_identifier, presence: true
validates :end_event_identifier, presence: true
validate :validate_stage_event_pairs
@@ -15,6 +16,7 @@ module Analytics
enum end_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents.to_enum, _prefix: :end_event_identifier
alias_attribute :custom_stage?, :custom
+ scope :default_stages, -> { where(custom: false) }
end
def parent=(_)
@@ -45,11 +47,17 @@ module Analytics
!custom
end
- # The model that is going to be queried, Issue or MergeRequest
- def subject_model
+ # The model class that is going to be queried, Issue or MergeRequest
+ def subject_class
start_event.object_type
end
+ def matches_with_stage_params?(stage_params)
+ default_stage? &&
+ start_event_identifier.to_s.eql?(stage_params[:start_event_identifier].to_s) &&
+ end_event_identifier.to_s.eql?(stage_params[:end_event_identifier].to_s)
+ end
+
private
def validate_stage_event_pairs
diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb
index dc1735a7e48..64df265dc25 100644
--- a/app/models/concerns/atomic_internal_id.rb
+++ b/app/models/concerns/atomic_internal_id.rb
@@ -27,40 +27,73 @@ module AtomicInternalId
extend ActiveSupport::Concern
class_methods do
- def has_internal_id(column, scope:, init:, presence: true) # rubocop:disable Naming/PredicateName
+ def has_internal_id(column, scope:, init:, ensure_if: nil, presence: true) # rubocop:disable Naming/PredicateName
# We require init here to retain the ability to recalculate in the absence of a
# InternaLId record (we may delete records in `internal_ids` for example).
raise "has_internal_id requires a init block, none given." unless init
+ raise "has_internal_id needs to be defined on association." unless self.reflect_on_association(scope)
- before_validation :"ensure_#{scope}_#{column}!", on: :create
+ before_validation :"track_#{scope}_#{column}!", on: :create
+ before_validation :"ensure_#{scope}_#{column}!", on: :create, if: ensure_if
validates column, presence: presence
define_method("ensure_#{scope}_#{column}!") do
- scope_value = association(scope).reader
+ scope_value = internal_id_read_scope(scope)
value = read_attribute(column)
-
return value unless scope_value
- scope_attrs = { scope_value.class.table_name.singularize.to_sym => scope_value }
- usage = self.class.table_name.to_sym
-
- if value.present?
- InternalId.track_greatest(self, scope_attrs, usage, value, init)
- else
- value = InternalId.generate_next(self, scope_attrs, usage, init)
+ if value.nil?
+ # We don't have a value yet and use a InternalId record to generate
+ # the next value.
+ value = InternalId.generate_next(
+ self,
+ internal_id_scope_attrs(scope),
+ internal_id_scope_usage,
+ init)
write_attribute(column, value)
end
value
end
+ define_method("track_#{scope}_#{column}!") do
+ return unless @internal_id_needs_tracking
+
+ scope_value = internal_id_read_scope(scope)
+ return unless scope_value
+
+ value = read_attribute(column)
+
+ if value.present?
+ # The value was set externally, e.g. by the user
+ # We update the InternalId record to keep track of the greatest value.
+ InternalId.track_greatest(
+ self,
+ internal_id_scope_attrs(scope),
+ internal_id_scope_usage,
+ value,
+ init)
+
+ @internal_id_needs_tracking = false
+ end
+ end
+
+ define_method("#{column}=") do |value|
+ super(value).tap do |v|
+ # Indicate the iid was set from externally
+ @internal_id_needs_tracking = true
+ end
+ end
+
define_method("reset_#{scope}_#{column}") do
if value = read_attribute(column)
- scope_value = association(scope).reader
- scope_attrs = { scope_value.class.table_name.singularize.to_sym => scope_value }
- usage = self.class.table_name.to_sym
+ did_reset = InternalId.reset(
+ self,
+ internal_id_scope_attrs(scope),
+ internal_id_scope_usage,
+ value)
- if InternalId.reset(self, scope_attrs, usage, value)
+ if did_reset
write_attribute(column, nil)
end
end
@@ -69,4 +102,18 @@ module AtomicInternalId
end
end
end
+
+ def internal_id_scope_attrs(scope)
+ scope_value = internal_id_read_scope(scope)
+
+ { scope_value.class.table_name.singularize.to_sym => scope_value } if scope_value
+ end
+
+ def internal_id_scope_usage
+ self.class.table_name.to_sym
+ end
+
+ def internal_id_read_scope(scope)
+ association(scope).reader
+ end
end
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb
index 269145309fc..a98baeb0e3d 100644
--- a/app/models/concerns/avatarable.rb
+++ b/app/models/concerns/avatarable.rb
@@ -38,7 +38,7 @@ module Avatarable
def avatar_type
unless self.avatar.image?
- errors.add :avatar, "file format is not supported. Please try one of the following supported formats: #{AvatarUploader::IMAGE_EXT.join(', ')}"
+ errors.add :avatar, "file format is not supported. Please try one of the following supported formats: #{AvatarUploader::SAFE_IMAGE_EXT.join(', ')}"
end
end
diff --git a/app/models/concerns/checksummable.rb b/app/models/concerns/checksummable.rb
new file mode 100644
index 00000000000..1f76eb87aa5
--- /dev/null
+++ b/app/models/concerns/checksummable.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Checksummable
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def hexdigest(path)
+ Digest::SHA256.file(path).hexdigest
+ end
+ end
+end
diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb
index 91dda803031..49d6f3d399c 100644
--- a/app/models/concerns/ci/contextable.rb
+++ b/app/models/concerns/ci/contextable.rb
@@ -78,6 +78,7 @@ module Ci
variables.append(key: "CI_JOB_MANUAL", value: 'true') if action?
variables.append(key: "CI_NODE_INDEX", value: self.options[:instance].to_s) if self.options&.include?(:instance)
variables.append(key: "CI_NODE_TOTAL", value: (self.options&.dig(:parallel) || 1).to_s)
+ variables.append(key: "CI_DEFAULT_BRANCH", value: project.default_branch)
variables.concat(legacy_variables)
end
end
diff --git a/app/models/concerns/ci/pipeline_delegator.rb b/app/models/concerns/ci/pipeline_delegator.rb
index dbc5ed1bc9a..76e0cbc7dff 100644
--- a/app/models/concerns/ci/pipeline_delegator.rb
+++ b/app/models/concerns/ci/pipeline_delegator.rb
@@ -15,7 +15,8 @@ module Ci
:merge_request_ref?,
:source_ref,
:source_ref_slug,
- :legacy_detached_merge_request_pipeline?, to: :pipeline
+ :legacy_detached_merge_request_pipeline?,
+ :merge_train_pipeline?, to: :pipeline
end
end
end
diff --git a/app/models/concerns/deployable.rb b/app/models/concerns/deployable.rb
deleted file mode 100644
index 957b72f3721..00000000000
--- a/app/models/concerns/deployable.rb
+++ /dev/null
@@ -1,31 +0,0 @@
-# frozen_string_literal: true
-
-module Deployable
- extend ActiveSupport::Concern
-
- included do
- after_create :create_deployment
-
- def create_deployment
- return unless starts_environment? && !has_deployment?
-
- environment = project.environments.find_or_create_by(
- name: expanded_environment_name
- )
-
- # If we failed to persist envirionment record by validation error, such as name with invalid character,
- # the job will fall back to a non-environment job.
- return unless environment.persisted?
-
- create_deployment!(
- cluster_id: environment.deployment_platform&.cluster_id,
- project_id: environment.project_id,
- environment: environment,
- ref: ref,
- tag: tag,
- sha: sha,
- user: user,
- on_stop: on_stop)
- end
- end
-end
diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb
index e1a8725e728..fe8e9609820 100644
--- a/app/models/concerns/deployment_platform.rb
+++ b/app/models/concerns/deployment_platform.rb
@@ -11,6 +11,10 @@ module DeploymentPlatform
private
+ def cluster_management_project_enabled?
+ Feature.enabled?(:cluster_management_project, default_enabled: true)
+ end
+
def find_deployment_platform(environment)
find_platform_kubernetes_with_cte(environment) ||
find_instance_cluster_platform_kubernetes(environment: environment)
@@ -18,7 +22,7 @@ module DeploymentPlatform
# EE would override this and utilize environment argument
def find_platform_kubernetes_with_cte(_environment)
- Clusters::ClustersHierarchy.new(self).base_and_ancestors
+ Clusters::ClustersHierarchy.new(self, include_management_project: cluster_management_project_enabled?).base_and_ancestors
.enabled.default_environment
.first&.platform_kubernetes
end
diff --git a/app/models/concerns/group_api_compatibility.rb b/app/models/concerns/group_api_compatibility.rb
new file mode 100644
index 00000000000..f02aa2035e5
--- /dev/null
+++ b/app/models/concerns/group_api_compatibility.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+# Add methods used by the groups API
+module GroupAPICompatibility
+ extend ActiveSupport::Concern
+
+ def project_creation_level_str
+ ::Gitlab::Access.project_creation_string_options.key(project_creation_level)
+ end
+
+ def project_creation_level_str=(value)
+ write_attribute(:project_creation_level, ::Gitlab::Access.project_creation_string_options.fetch(value))
+ end
+
+ def subgroup_creation_level_str
+ ::Gitlab::Access.subgroup_creation_string_options.key(subgroup_creation_level)
+ end
+
+ def subgroup_creation_level_str=(value)
+ write_attribute(:subgroup_creation_level, ::Gitlab::Access.subgroup_creation_string_options.fetch(value))
+ end
+end
diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb
index bcbbb27a9a8..c01fb4740e5 100644
--- a/app/models/concerns/has_status.rb
+++ b/app/models/concerns/has_status.rb
@@ -10,6 +10,8 @@ module HasStatus
ACTIVE_STATUSES = %w[preparing pending running].freeze
COMPLETED_STATUSES = %w[success failed canceled skipped].freeze
ORDERED_STATUSES = %w[failed preparing pending running manual scheduled canceled success skipped created].freeze
+ PASSED_WITH_WARNINGS_STATUSES = %w[failed canceled].to_set.freeze
+ EXCLUDE_IGNORED_STATUSES = %w[manual failed canceled].to_set.freeze
STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3,
failed: 4, canceled: 5, skipped: 6, manual: 7,
scheduled: 8, preparing: 9 }.freeze
@@ -17,7 +19,7 @@ module HasStatus
UnknownStatusError = Class.new(StandardError)
class_methods do
- def status_sql
+ def legacy_status_sql
scope_relevant = respond_to?(:exclude_ignored) ? exclude_ignored : all
scope_warnings = respond_to?(:failed_but_allowed) ? failed_but_allowed : none
@@ -53,8 +55,22 @@ module HasStatus
)
end
- def status
- all.pluck(status_sql).first
+ def legacy_status
+ all.pluck(legacy_status_sql).first
+ end
+
+ # This method should not be used.
+ # This method performs expensive calculation of status:
+ # 1. By plucking all related objects,
+ # 2. Or executes expensive SQL query
+ def slow_composite_status
+ if Feature.enabled?(:ci_composite_status, default_enabled: false)
+ Gitlab::Ci::Status::Composite
+ .new(all, with_allow_failure: columns_hash.key?('allow_failure'))
+ .status
+ else
+ legacy_status
+ end
end
def started_at
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index d02f3731cc2..852576dbbc2 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -4,7 +4,7 @@
#
# Contains common functionality shared between Issues and MergeRequests
#
-# Used by Issue, MergeRequest
+# Used by Issue, MergeRequest, Epic
#
module Issuable
extend ActiveSupport::Concern
@@ -25,6 +25,19 @@ module Issuable
include UpdatedAtFilterable
include IssuableStates
include ClosedAtFilterable
+ include VersionedDescription
+
+ TITLE_LENGTH_MAX = 255
+ TITLE_HTML_LENGTH_MAX = 800
+ DESCRIPTION_LENGTH_MAX = 1.megabyte
+ DESCRIPTION_HTML_LENGTH_MAX = 5.megabytes
+
+ STATE_ID_MAP = {
+ opened: 1,
+ closed: 2,
+ merged: 3,
+ locked: 4
+ }.with_indifferent_access.freeze
# This object is used to gather issuable meta data for displaying
# upvotes, downvotes, notes and closing merge requests count for issues and merge requests
@@ -72,10 +85,15 @@ module Issuable
prefix: true
validates :author, presence: true
- validates :title, presence: true, length: { maximum: 255 }
- validates :description, length: { maximum: Gitlab::Database::MAX_TEXT_SIZE_LIMIT }, allow_blank: true
+ validates :title, presence: true, length: { maximum: TITLE_LENGTH_MAX }
+ # we validate the description against DESCRIPTION_LENGTH_MAX only for Issuables being created
+ # to avoid breaking the existing Issuables which may have their descriptions longer
+ validates :description, length: { maximum: DESCRIPTION_LENGTH_MAX }, allow_blank: true, on: :create
+ validate :description_max_length_for_new_records_is_valid, on: :update
validate :milestone_is_valid
+ before_validation :truncate_description_on_import!
+
scope :authored, ->(user) { where(author_id: user) }
scope :recent, -> { reorder(id: :desc) }
scope :of_projects, ->(ids) { where(project_id: ids) }
@@ -138,6 +156,16 @@ module Issuable
def milestone_is_valid
errors.add(:milestone_id, message: "is invalid") if milestone_id.present? && !milestone_available?
end
+
+ def description_max_length_for_new_records_is_valid
+ if new_record? && description.length > Issuable::DESCRIPTION_LENGTH_MAX
+ errors.add(:description, :too_long, count: Issuable::DESCRIPTION_LENGTH_MAX)
+ end
+ end
+
+ def truncate_description_on_import!
+ self.description = description&.slice(0, Issuable::DESCRIPTION_LENGTH_MAX) if importing?
+ end
end
class_methods do
@@ -152,13 +180,17 @@ module Issuable
fuzzy_search(query, [:title])
end
- # Available state values persisted in state_id column using state machine
+ def available_states
+ @available_states ||= STATE_ID_MAP.slice(*available_state_names)
+ end
+
+ # Available state names used to persist state_id column using state machine
#
# Override this on subclasses if different states are needed
#
- # Check MergeRequest.available_states for example
- def available_states
- @available_states ||= { opened: 1, closed: 2 }.with_indifferent_access
+ # Check MergeRequest.available_states_names for example
+ def available_state_names
+ [:opened, :closed]
end
# Searches for records with a matching title or description.
@@ -277,6 +309,14 @@ module Issuable
end
end
+ def state
+ self.class.available_states.key(state_id)
+ end
+
+ def state=(value)
+ self.state_id = self.class.available_states[value]
+ end
+
def resource_parent
project
end
diff --git a/app/models/concerns/issuable_states.rb b/app/models/concerns/issuable_states.rb
index 33bc41d7f44..f0b9f0d1f3a 100644
--- a/app/models/concerns/issuable_states.rb
+++ b/app/models/concerns/issuable_states.rb
@@ -4,22 +4,20 @@ module IssuableStates
extend ActiveSupport::Concern
# The state:string column is being migrated to state_id:integer column
- # This is a temporary hook to populate state_id column with new values
- # and should be removed after the state column is removed.
- # Check https://gitlab.com/gitlab-org/gitlab-foss/issues/51789 for more information
+ # This is a temporary hook to keep state column in sync until it is removed.
+ # Check https: https://gitlab.com/gitlab-org/gitlab/issues/33814 for more information
+ # The state column can be safely removed after 2019-10-27
included do
- before_save :set_state_id
+ before_save :sync_issuable_deprecated_state
end
- def set_state_id
- return if state.nil? || state.empty?
+ def sync_issuable_deprecated_state
+ return if self.is_a?(Epic)
+ return unless respond_to?(:state)
+ return if state_id.nil?
- # Needed to prevent breaking some migration specs that
- # rollback database to a point where state_id does not exist.
- # We can use this guard clause for now since this file will
- # be removed in the next release.
- return unless self.has_attribute?(:state_id)
+ deprecated_state = self.class.available_states.key(state_id)
- self.state_id = self.class.available_states[state]
+ self.write_attribute(:state, deprecated_state)
end
end
diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb
index 377600ef6e5..9b6c57261d8 100644
--- a/app/models/concerns/mentionable.rb
+++ b/app/models/concerns/mentionable.rb
@@ -150,7 +150,7 @@ module Mentionable
#
# Returns a Hash.
def detect_mentionable_changes
- source = (changes.present? ? changes : previous_changes).dup
+ source = (changes.presence || previous_changes).dup
mentionable = self.class.mentionable_attrs.map { |attr, options| attr }
diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb
index 3deb86da6cf..42b370990ac 100644
--- a/app/models/concerns/milestoneish.rb
+++ b/app/models/concerns/milestoneish.rb
@@ -6,7 +6,9 @@ module Milestoneish
end
def closed_issues_count(user)
- count_issues_by_state(user)['closed'].to_i
+ closed_state_id = Issue.available_states[:closed]
+
+ count_issues_by_state(user)[closed_state_id].to_i
end
def complete?(user)
@@ -117,7 +119,7 @@ module Milestoneish
def count_issues_by_state(user)
memoize_per_user(user, :count_issues_by_state) do
- issues_visible_to_user(user).reorder(nil).group(:state).count
+ issues_visible_to_user(user).reorder(nil).group(:state_id).count
end
end
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index 6caa23ef9b7..3065e0ba6c5 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -7,6 +7,8 @@ module Noteable
# avoiding n+1 queries and improving performance.
NoteableMeta = Struct.new(:user_notes_count)
+ MAX_NOTES_LIMIT = 5_000
+
class_methods do
# `Noteable` class names that support replying to individual notes.
def replyable_types
diff --git a/app/models/concerns/notification_branch_selection.rb b/app/models/concerns/notification_branch_selection.rb
index d8e18de7551..7f00b652530 100644
--- a/app/models/concerns/notification_branch_selection.rb
+++ b/app/models/concerns/notification_branch_selection.rb
@@ -21,7 +21,7 @@ module NotificationBranchSelection
end
is_default_branch = ref == project.default_branch
- is_protected_branch = project.protected_branches.exists?(name: ref)
+ is_protected_branch = ProtectedBranch.protected?(project, ref)
case branches_to_be_notified
when "all"
diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb
index aab0589f7ca..9df77b565da 100644
--- a/app/models/concerns/prometheus_adapter.rb
+++ b/app/models/concerns/prometheus_adapter.rb
@@ -44,7 +44,7 @@ module PrometheusAdapter
end
def query_klass_for(query_name)
- Gitlab::Prometheus::Queries.const_get("#{query_name.to_s.classify}Query")
+ Gitlab::Prometheus::Queries.const_get("#{query_name.to_s.classify}Query", false)
end
def build_query_args(*args)
diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb
index dfe3c391880..b645cf71443 100644
--- a/app/models/concerns/relative_positioning.rb
+++ b/app/models/concerns/relative_positioning.rb
@@ -127,6 +127,7 @@ module RelativePositioning
if pos_after && (pos_after - pos_before) < 2
before.move_sequence_after
+ pos_after = before.next_relative_position
end
self.relative_position = self.class.position_between(pos_before, pos_after)
@@ -138,6 +139,7 @@ module RelativePositioning
if pos_before && (pos_after - pos_before) < 2
after.move_sequence_before
+ pos_before = after.prev_relative_position
end
self.relative_position = self.class.position_between(pos_before, pos_after)
diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb
index bdd87437e2a..129d0fbb2c0 100644
--- a/app/models/concerns/routable.rb
+++ b/app/models/concerns/routable.rb
@@ -51,14 +51,21 @@ module Routable
# Klass.where_full_path_in(%w{gitlab-org/gitlab-foss gitlab-org/gitlab})
#
# Returns an ActiveRecord::Relation.
- def where_full_path_in(paths)
+ def where_full_path_in(paths, use_includes: true)
return none if paths.empty?
wheres = paths.map do |path|
"(LOWER(routes.path) = LOWER(#{connection.quote(path)}))"
end
- includes(:route).where(wheres.join(' OR ')).references(:routes)
+ route =
+ if use_includes
+ includes(:route).references(:routes)
+ else
+ joins(:route)
+ end
+
+ route.where(wheres.join(' OR '))
end
end
diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb
index 3ff4b4046d3..10bbeecc2f7 100644
--- a/app/models/concerns/spammable.rb
+++ b/app/models/concerns/spammable.rb
@@ -80,4 +80,9 @@ module Spammable
def check_for_spam?
true
end
+
+ # Override in Spammable if differs
+ def allow_possible_spam?
+ Feature.enabled?(:allow_possible_spam, project)
+ end
end
diff --git a/app/models/concerns/stepable.rb b/app/models/concerns/stepable.rb
index d00a049a004..dea241c5dbe 100644
--- a/app/models/concerns/stepable.rb
+++ b/app/models/concerns/stepable.rb
@@ -11,15 +11,15 @@ module Stepable
initial_result = {}
steps.inject(initial_result) do |previous_result, callback|
- result = method(callback).call
+ result = method(callback).call(previous_result)
- if result[:status] == :error
- result[:failed_step] = callback
+ if result[:status] != :success
+ result[:last_step] = callback
break result
end
- previous_result.merge(result)
+ result
end
end
diff --git a/app/models/concerns/versioned_description.rb b/app/models/concerns/versioned_description.rb
new file mode 100644
index 00000000000..63a24aadc8a
--- /dev/null
+++ b/app/models/concerns/versioned_description.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module VersionedDescription
+ extend ActiveSupport::Concern
+
+ included do
+ attr_accessor :saved_description_version
+
+ has_many :description_versions
+
+ after_update :save_description_version
+ end
+
+ private
+
+ def save_description_version
+ self.saved_description_version = nil
+
+ return unless Feature.enabled?(:save_description_versions, issuing_parent)
+ return unless saved_change_to_description?
+
+ unless description_versions.exists?
+ description_versions.create!(
+ description: description_before_last_save,
+ created_at: created_at
+ )
+ end
+
+ self.saved_description_version = description_versions.create!(description: description)
+ end
+end
diff --git a/app/models/concerns/worker_attributes.rb b/app/models/concerns/worker_attributes.rb
new file mode 100644
index 00000000000..af40e9e3b19
--- /dev/null
+++ b/app/models/concerns/worker_attributes.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module WorkerAttributes
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def feature_category(value)
+ raise "Invalid category. Use `feature_category_not_owned!` to mark a worker as not owned" if value == :not_owned
+
+ worker_attributes[:feature_category] = value
+ end
+
+ # Special case: mark this work as not associated with a feature category
+ # this should be used for cross-cutting concerns, such as mailer workers.
+ def feature_category_not_owned!
+ worker_attributes[:feature_category] = :not_owned
+ end
+
+ def get_feature_category
+ get_worker_attribute(:feature_category)
+ end
+
+ def feature_category_not_owned?
+ get_worker_attribute(:feature_category) == :not_owned
+ end
+
+ protected
+
+ # Returns a worker attribute declared on this class or its parent class.
+ # This approach allows declared attributes to be inherited by
+ # child classes.
+ def get_worker_attribute(name)
+ worker_attributes[name] || superclass_worker_attributes(name)
+ end
+
+ private
+
+ def worker_attributes
+ @attributes ||= {}
+ end
+
+ def superclass_worker_attributes(name)
+ return unless superclass.include? WorkerAttributes
+
+ superclass.get_worker_attribute(name)
+ end
+ end
+end
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index 583e23d1274..27bb76835c7 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -11,6 +11,7 @@ class ContainerRepository < ApplicationRecord
delegate :client, to: :registry
scope :ordered, -> { order(:name) }
+ scope :with_api_entity_associations, -> { preload(:project) }
# rubocop: disable CodeReuse/ServiceClass
def registry
@@ -67,11 +68,9 @@ class ContainerRepository < ApplicationRecord
def delete_tags!
return unless has_tags?
- digests = tags.map { |tag| tag.digest }.to_set
+ digests = tags.map { |tag| tag.digest }.compact.to_set
- digests.all? do |digest|
- delete_tag_by_digest(digest)
- end
+ digests.map(&method(:delete_tag_by_digest)).all?
end
def delete_tag_by_digest(digest)
diff --git a/app/models/cycle_analytics/project_level.rb b/app/models/cycle_analytics/project_level.rb
index 4aa426c58a1..591435baf34 100644
--- a/app/models/cycle_analytics/project_level.rb
+++ b/app/models/cycle_analytics/project_level.rb
@@ -13,6 +13,7 @@ module CycleAnalytics
def summary
@summary ||= ::Gitlab::CycleAnalytics::StageSummary.new(project,
from: options[:from],
+ to: options[:to],
current_user: options[:current_user]).data
end
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index db7f9e06362..7ccd5e98360 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -9,7 +9,7 @@ class Deployment < ApplicationRecord
belongs_to :environment, required: true
belongs_to :cluster, class_name: 'Clusters::Cluster', optional: true
belongs_to :user
- belongs_to :deployable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
+ belongs_to :deployable, polymorphic: true, optional: true # rubocop:disable Cop/PolymorphicAssociations
has_internal_id :iid, scope: :project, init: ->(s) do
Deployment.where(project: s.project).maximum(:iid) if s&.project
@@ -22,6 +22,8 @@ class Deployment < ApplicationRecord
scope :for_environment, -> (environment) { where(environment_id: environment) }
+ scope :visible, -> { where(status: %i[running success failed canceled]) }
+
state_machine :status, initial: :created do
event :run do
transition created: :running
@@ -73,6 +75,10 @@ class Deployment < ApplicationRecord
find(ids)
end
+ def self.find_successful_deployment!(iid)
+ success.find_by!(iid: iid)
+ end
+
def commit
project.commit(sha)
end
@@ -180,3 +186,5 @@ class Deployment < ApplicationRecord
self.created_at if success? && !read_attribute(:finished_at)
end
end
+
+Deployment.prepend_if_ee('EE::Deployment')
diff --git a/app/models/description_version.rb b/app/models/description_version.rb
new file mode 100644
index 00000000000..abab7f94212
--- /dev/null
+++ b/app/models/description_version.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class DescriptionVersion < ApplicationRecord
+ belongs_to :issue
+ belongs_to :merge_request
+
+ validate :exactly_one_issuable
+
+ def self.issuable_attrs
+ %i(issue merge_request).freeze
+ end
+
+ private
+
+ def exactly_one_issuable
+ issuable_count = self.class.issuable_attrs.count { |attr| self["#{attr}_id"] }
+
+ errors.add(:base, "Exactly one of #{self.class.issuable_attrs.join(', ')} is required") if issuable_count != 1
+ end
+end
+
+DescriptionVersion.prepend_if_ee('EE::DescriptionVersion')
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index aa7286a9971..65e87bb08a7 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -75,6 +75,10 @@ class DiffNote < Note
self.original_position.diff_refs == diff_refs
end
+ # Checks if the current `position` line in the diff
+ # exists and is suggestible (not a deletion).
+ #
+ # Avoid using in iterations as it requests Gitaly.
def supports_suggestion?
return false unless noteable&.supports_suggestion? && on_text?
# We don't want to trigger side-effects of `diff_file` call.
diff --git a/app/models/diff_viewer/image.rb b/app/models/diff_viewer/image.rb
index 350bef1d42a..cfda0058d81 100644
--- a/app/models/diff_viewer/image.rb
+++ b/app/models/diff_viewer/image.rb
@@ -6,7 +6,7 @@ module DiffViewer
include ClientSide
self.partial_name = 'image'
- self.extensions = UploaderHelper::IMAGE_EXT
+ self.extensions = UploaderHelper::SAFE_IMAGE_EXT
self.binary = true
self.switcher_icon = 'picture-o'
self.switcher_title = _('image diff')
diff --git a/app/models/environment.rb b/app/models/environment.rb
index fe438b142b2..af0c219d9a0 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -6,7 +6,8 @@ class Environment < ApplicationRecord
belongs_to :project, required: true
- has_many :deployments, -> { success }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :deployments, -> { visible }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :successful_deployments, -> { success }, class_name: 'Deployment'
has_one :last_deployment, -> { success.order('deployments.id DESC') }, class_name: 'Deployment'
@@ -81,6 +82,10 @@ class Environment < ApplicationRecord
pluck(:name)
end
+ def self.find_or_create_by_name(name)
+ find_or_create_by(name: name)
+ end
+
def predefined_variables
Gitlab::Ci::Variables::Collection.new
.append(key: 'CI_ENVIRONMENT_NAME', value: name)
diff --git a/app/models/event.rb b/app/models/event.rb
index 205e1f71c74..9611019adb8 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -77,15 +77,6 @@ class Event < ApplicationRecord
scope :recent, -> { reorder(id: :desc) }
scope :code_push, -> { where(action: PUSHED) }
- scope :in_projects, -> (projects) do
- sub_query = projects
- .except(:order)
- .select(1)
- .where('projects.id = events.project_id')
-
- where('EXISTS (?)', sub_query).recent
- end
-
scope :with_associations, -> do
# We're using preload for "push_event_payload" as otherwise the association
# is not always available (depending on the query being built).
diff --git a/app/models/event_collection.rb b/app/models/event_collection.rb
index a4c69b11781..4778f74568e 100644
--- a/app/models/event_collection.rb
+++ b/app/models/event_collection.rb
@@ -6,6 +6,8 @@
# in a controller), it's not suitable for building queries that are used for
# building other queries.
class EventCollection
+ include Gitlab::Utils::StrongMemoize
+
# To prevent users from putting too much pressure on the database by cycling
# through thousands of events we put a limit on the number of pages.
MAX_PAGE = 10
@@ -13,57 +15,52 @@ class EventCollection
# projects - An ActiveRecord::Relation object that returns the projects for
# which to retrieve events.
# filter - An EventFilter instance to use for filtering events.
- def initialize(projects, limit: 20, offset: 0, filter: nil)
+ def initialize(projects, limit: 20, offset: 0, filter: nil, groups: nil)
@projects = projects
@limit = limit
@offset = offset
@filter = filter
+ @groups = groups
end
# Returns an Array containing the events.
def to_a
return [] if current_page > MAX_PAGE
- relation = if Gitlab::Database.join_lateral_supported?
- relation_with_join_lateral
+ relation = if groups
+ project_and_group_events
else
- relation_without_join_lateral
+ relation_with_join_lateral('project_id', projects)
end
+ relation = paginate_events(relation)
relation.with_associations.to_a
end
private
- # Returns the events relation to use when JOIN LATERAL is not supported.
- #
- # This relation simply gets all the events for all authorized projects, then
- # limits that set.
- def relation_without_join_lateral
- events = filtered_events.in_projects(projects)
+ def project_and_group_events
+ project_events = relation_with_join_lateral('project_id', projects)
+ group_events = relation_with_join_lateral('group_id', groups)
- paginate_events(events)
+ Event.from_union([project_events, group_events]).recent
end
- # Returns the events relation to use when JOIN LATERAL is supported.
- #
# This relation is built using JOIN LATERAL, producing faster queries than a
# regular LIMIT + OFFSET approach.
- def relation_with_join_lateral
- projects_for_lateral = projects.select(:id).to_sql
+ def relation_with_join_lateral(parent_column, parents)
+ parents_for_lateral = parents.select(:id).to_sql
lateral = filtered_events
.limit(limit_for_join_lateral)
- .where('events.project_id = projects_for_lateral.id')
+ .where("events.#{parent_column} = parents_for_lateral.id") # rubocop:disable GitlabSecurity/SqlInjection
.to_sql
# The outer query does not need to re-apply the filters since the JOIN
# LATERAL body already takes care of this.
- outer = base_relation
- .from("(#{projects_for_lateral}) projects_for_lateral")
+ base_relation
+ .from("(#{parents_for_lateral}) parents_for_lateral")
.joins("JOIN LATERAL (#{lateral}) AS #{Event.table_name} ON true")
-
- paginate_events(outer)
end
def filtered_events
@@ -97,4 +94,10 @@ class EventCollection
def projects
@projects.except(:order)
end
+
+ def groups
+ strong_memoize(:groups) do
+ groups.except(:order) if @groups
+ end
+ end
end
diff --git a/app/models/evidence.rb b/app/models/evidence.rb
new file mode 100644
index 00000000000..69a00f1cb3f
--- /dev/null
+++ b/app/models/evidence.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class Evidence < ApplicationRecord
+ include ShaAttribute
+
+ belongs_to :release
+
+ before_validation :generate_summary_and_sha
+
+ default_scope { order(created_at: :asc) }
+
+ sha_attribute :summary_sha
+
+ def milestones
+ @milestones ||= release.milestones.includes(:issues)
+ end
+
+ private
+
+ def generate_summary_and_sha
+ summary = Evidences::EvidenceSerializer.new.represent(self) # rubocop: disable CodeReuse/Serializer
+ return unless summary
+
+ self.summary = summary
+ self.summary_sha = Gitlab::CryptoHelper.sha256(summary)
+ end
+end
diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb
index 1d553fc8312..7d766e1f25c 100644
--- a/app/models/global_milestone.rb
+++ b/app/models/global_milestone.rb
@@ -11,7 +11,7 @@ class GlobalMilestone
delegate :title, :state, :due_date, :start_date, :participants, :project,
:group, :expires_at, :closed?, :iid, :group_milestone?, :safe_title,
- :milestoneish_id, :parent, to: :milestone
+ :milestoneish_id, :resource_parent, to: :milestone
def to_hash
{
diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb
index 46cac1d41bb..0c36e51120f 100644
--- a/app/models/gpg_signature.rb
+++ b/app/models/gpg_signature.rb
@@ -23,6 +23,8 @@ class GpgSignature < ApplicationRecord
validates :project_id, presence: true
validates :gpg_key_primary_keyid, presence: true
+ scope :by_commit_sha, ->(shas) { where(commit_sha: shas) }
+
def self.with_key_and_subkeys(gpg_key)
subkey_ids = gpg_key.subkeys.pluck(:id)
diff --git a/app/models/grafana_integration.rb b/app/models/grafana_integration.rb
new file mode 100644
index 00000000000..51cc398394d
--- /dev/null
+++ b/app/models/grafana_integration.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class GrafanaIntegration < ApplicationRecord
+ belongs_to :project
+
+ attr_encrypted :token,
+ mode: :per_attribute_iv,
+ algorithm: 'aes-256-gcm',
+ key: Settings.attr_encrypted_db_key_base_32
+
+ validates :grafana_url,
+ length: { maximum: 1024 },
+ addressable_url: { enforce_sanitization: true, ascii_only: true }
+
+ validates :token, :project, presence: true
+
+ def client
+ @client ||= ::Grafana::Client.new(api_url: grafana_url.chomp('/'), token: token)
+ end
+end
diff --git a/app/models/group.rb b/app/models/group.rb
index 1b62db04ab7..042201ffa14 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -14,6 +14,7 @@ class Group < Namespace
include TokenAuthenticatable
include WithUploads
include Gitlab::Utils::StrongMemoize
+ include GroupAPICompatibility
ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10
@@ -258,6 +259,10 @@ class Group < Namespace
members_with_parents.maintainers.exists?(user_id: user)
end
+ def has_container_repositories?
+ container_repositories.exists?
+ end
+
# @deprecated
alias_method :has_master?, :has_maintainer?
@@ -435,6 +440,10 @@ class Group < Namespace
members.owners.order_recent_sign_in.limit(ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT)
end
+ def supports_events?
+ false
+ end
+
private
def update_two_factor_requirement
@@ -464,6 +473,12 @@ class Group < Namespace
errors.add(:visibility_level, "#{visibility} is not allowed since there are sub-groups with higher visibility.")
end
+
+ def self.groups_including_descendants_by(group_ids)
+ Gitlab::ObjectHierarchy
+ .new(Group.where(id: group_ids))
+ .base_and_descendants
+ end
end
Group.prepend_if_ee('EE::Group')
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index 16fc7fdbd48..e51b1c41059 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -13,7 +13,7 @@ class WebHook < ApplicationRecord
algorithm: 'aes-256-gcm',
key: Settings.attr_encrypted_db_key_base_32
- has_many :web_hook_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :web_hook_logs
validates :url, presence: true
validates :url, public_url: true, unless: ->(hook) { hook.is_a?(SystemHook) }
diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb
index 237401899db..8d3eeaf2461 100644
--- a/app/models/internal_id.rb
+++ b/app/models/internal_id.rb
@@ -16,6 +16,8 @@
# * Add `usage` value to enum
# * (Optionally) add columns to `internal_ids` if needed for scope.
class InternalId < ApplicationRecord
+ include Gitlab::Utils::StrongMemoize
+
belongs_to :project
belongs_to :namespace
@@ -47,10 +49,18 @@ class InternalId < ApplicationRecord
def update_and_save(&block)
lock!
yield
+ update_and_save_counter.increment(usage: usage, changed: last_value_changed?)
save!
last_value
end
+ # Instrumentation to track for-update locks
+ def update_and_save_counter
+ strong_memoize(:update_and_save_counter) do
+ Gitlab::Metrics.counter(:gitlab_internal_id_for_update_lock, 'Number of ROW SHARE (FOR UPDATE) locks on individual records from internal_ids')
+ end
+ end
+
class << self
def track_greatest(subject, scope, usage, new_value, init)
return new_value unless available?
diff --git a/app/models/issue.rb b/app/models/issue.rb
index d0b2165fcc7..b9b481ac29b 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -71,7 +71,7 @@ class Issue < ApplicationRecord
attr_spammable :title, spam_title: true
attr_spammable :description, spam_description: true
- state_machine :state, initial: :opened do
+ state_machine :state_id, initial: :opened do
event :close do
transition [:opened] => :closed
end
@@ -80,8 +80,8 @@ class Issue < ApplicationRecord
transition closed: :opened
end
- state :opened
- state :closed
+ state :opened, value: Issue.available_states[:opened]
+ state :closed, value: Issue.available_states[:closed]
before_transition any => :closed do |issue|
issue.closed_at = issue.system_note_timestamp
@@ -93,6 +93,13 @@ class Issue < ApplicationRecord
end
end
+ # Alias to state machine .with_state_id method
+ # This needs to be defined after the state machine block to avoid errors
+ class << self
+ alias_method :with_state, :with_state_id
+ alias_method :with_states, :with_state_ids
+ end
+
def self.relative_positioning_query_base(issue)
in_projects(issue.parent_ids)
end
diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb
index 60b11ad9356..535c3cf2ba1 100644
--- a/app/models/lfs_object.rb
+++ b/app/models/lfs_object.rb
@@ -2,6 +2,7 @@
class LfsObject < ApplicationRecord
include AfterCommitQueue
+ include Checksummable
include EachBatch
include ObjectStorage::BackgroundMove
@@ -9,6 +10,7 @@ class LfsObject < ApplicationRecord
has_many :projects, -> { distinct }, through: :lfs_objects_projects
scope :with_files_stored_locally, -> { where(file_store: LfsObjectUploader::Store::LOCAL) }
+ scope :with_files_stored_remotely, -> { where(file_store: LfsObjectUploader::Store::REMOTE) }
validates :oid, presence: true, uniqueness: true
@@ -23,7 +25,13 @@ class LfsObject < ApplicationRecord
end
def project_allowed_access?(project)
- projects.exists?(project.lfs_storage_project.id)
+ if project.fork_network_member
+ lfs_objects_projects
+ .where("EXISTS(?)", project.fork_network.fork_network_members.select(1).where("fork_network_members.project_id = lfs_objects_projects.project_id"))
+ .exists?
+ else
+ lfs_objects_projects.where(project_id: project.id).exists?
+ end
end
def local_store?
@@ -39,7 +47,7 @@ class LfsObject < ApplicationRecord
# rubocop: enable DestroyAll
def self.calculate_oid(path)
- Digest::SHA256.file(path).hexdigest
+ self.hexdigest(path)
end
end
diff --git a/app/models/list.rb b/app/models/list.rb
index b4a4631b397..13c42b55bf7 100644
--- a/app/models/list.rb
+++ b/app/models/list.rb
@@ -21,20 +21,10 @@ class List < ApplicationRecord
scope :destroyable, -> { where(list_type: list_types.slice(*destroyable_types).values) }
scope :movable, -> { where(list_type: list_types.slice(*movable_types).values) }
- scope :preload_associations, -> (user) do
- preload(:board, label: :priorities)
- end
+ scope :preload_associations, -> { preload(:board, label: :priorities) }
scope :ordered, -> { order(:list_type, :position) }
- # Loads list with preferences for given user
- # if preferences exists for user or not
- scope :with_preferences_for, -> (user) do
- return unless user
-
- includes(:list_user_preferences).where(list_user_preferences: { user_id: [user.id, nil] })
- end
-
alias_method :preferences, :list_user_preferences
class << self
@@ -45,25 +35,25 @@ class List < ApplicationRecord
def movable_types
[:label]
end
+
+ def preload_preferences_for_user(lists, user)
+ return unless user
+
+ lists.each { |list| list.preferences_for(user) }
+ end
end
def preferences_for(user)
return preferences.build unless user
- if preferences.loaded?
- preloaded_preferences_for(user)
- else
- preferences.find_or_initialize_by(user: user)
- end
- end
+ BatchLoader.for(list_id: id, user_id: user.id).batch(default_value: preferences.build(user: user)) do |items, loader|
+ list_ids = items.map { |i| i[:list_id] }
+ user_ids = items.map { |i| i[:user_id] }
- def preloaded_preferences_for(user)
- user_preferences =
- preferences.find do |preference|
- preference.user_id == user.id
+ ListUserPreference.where(list_id: list_ids.uniq, user_id: user_ids.uniq).find_each do |preference|
+ loader.call({ list_id: preference.list_id, user_id: preference.user_id }, preference)
end
-
- user_preferences || preferences.build(user: user)
+ end
end
def update_preferences_for(user, preferences = {})
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 63133ca285b..7cdaa3e3ca7 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -85,7 +85,13 @@ class MergeRequest < ApplicationRecord
# when creating new merge request
attr_accessor :can_be_created, :compare_commits, :diff_options, :compare
- state_machine :state, initial: :opened do
+ # Keep states definition to be evaluated before the state_machine block to avoid spec failures.
+ # If this gets evaluated after, the `merged` and `locked` states which are overrided can be nil.
+ def self.available_state_names
+ super + [:merged, :locked]
+ end
+
+ state_machine :state_id, initial: :opened do
event :close do
transition [:opened] => :closed
end
@@ -116,10 +122,17 @@ class MergeRequest < ApplicationRecord
end
end
- state :opened
- state :closed
- state :merged
- state :locked
+ state :opened, value: MergeRequest.available_states[:opened]
+ state :closed, value: MergeRequest.available_states[:closed]
+ state :merged, value: MergeRequest.available_states[:merged]
+ state :locked, value: MergeRequest.available_states[:locked]
+ end
+
+ # Alias to state machine .with_state_id method
+ # This needs to be defined after the state machine block to avoid errors
+ class << self
+ alias_method :with_state, :with_state_id
+ alias_method :with_states, :with_state_ids
end
state_machine :merge_status, initial: :unchecked do
@@ -196,6 +209,10 @@ class MergeRequest < ApplicationRecord
scope :by_target_branch, ->(branch_name) { where(target_branch: branch_name) }
scope :preload_source_project, -> { preload(:source_project) }
+ scope :with_open_merge_when_pipeline_succeeds, -> do
+ with_state(:opened).where(merge_when_pipeline_succeeds: true)
+ end
+
after_save :keep_around_commit
alias_attribute :project, :target_project
@@ -207,10 +224,6 @@ class MergeRequest < ApplicationRecord
'!'
end
- def self.available_states
- @available_states ||= super.merge(merged: 3, locked: 4)
- end
-
# Returns the top 100 target branches
#
# The returned value is a Array containing branch names
@@ -450,6 +463,15 @@ class MergeRequest < ApplicationRecord
merge_request_diffs.where.not(id: merge_request_diff.id)
end
+ # Overwritten in EE
+ def note_positions_for_paths(paths, _user = nil)
+ positions = notes.new_diff_notes.joins(:note_diff_file)
+ .where('note_diff_files.old_path IN (?) OR note_diff_files.new_path IN (?)', paths, paths)
+ .positions
+
+ Gitlab::Diff::PositionCollection.new(positions, diff_head_sha)
+ end
+
def preloads_discussion_diff_highlighting?
true
end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 8b5f10ce159..735ad046f22 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -83,7 +83,7 @@ class MergeRequestDiff < ApplicationRecord
metrics_join = mr_diffs.join(mr_metrics).on(metrics_join_condition)
- condition = MergeRequest.arel_table[:state].eq(:merged)
+ condition = MergeRequest.arel_table[:state_id].eq(MergeRequest.available_states[:merged])
.and(MergeRequest::Metrics.arel_table[:merged_at].lteq(before))
.and(MergeRequest::Metrics.arel_table[:merged_at].not_eq(nil))
@@ -91,7 +91,7 @@ class MergeRequestDiff < ApplicationRecord
end
scope :old_closed_diffs, -> (before) do
- condition = MergeRequest.arel_table[:state].eq(:closed)
+ condition = MergeRequest.arel_table[:state_id].eq(MergeRequest.available_states[:closed])
.and(MergeRequest::Metrics.arel_table[:latest_closed_at].lteq(before))
joins(merge_request: :metrics).where(condition)
@@ -136,6 +136,7 @@ class MergeRequestDiff < ApplicationRecord
# All diff information is collected from repository after object is created.
# It allows you to override variables like head_commit_sha before getting diff.
after_create :save_git_content, unless: :importing?
+ after_create_commit :set_as_latest_diff
after_save :update_external_diff_store, if: -> { !importing? && saved_change_to_external_diff? }
@@ -150,10 +151,6 @@ class MergeRequestDiff < ApplicationRecord
# Collect information about commits and diff from repository
# and save it to the database as serialized data
def save_git_content
- MergeRequest
- .where('id = ? AND COALESCE(latest_merge_request_diff_id, 0) < ?', self.merge_request_id, self.id)
- .update_all(latest_merge_request_diff_id: self.id)
-
ensure_commit_shas
save_commits
save_diffs
@@ -168,6 +165,12 @@ class MergeRequestDiff < ApplicationRecord
keep_around_commits
end
+ def set_as_latest_diff
+ MergeRequest
+ .where('id = ? AND COALESCE(latest_merge_request_diff_id, 0) < ?', self.merge_request_id, self.id)
+ .update_all(latest_merge_request_diff_id: self.id)
+ end
+
def ensure_commit_shas
self.start_commit_sha ||= merge_request.target_branch_sha
self.head_commit_sha ||= merge_request.source_branch_sha
@@ -297,6 +300,13 @@ class MergeRequestDiff < ApplicationRecord
base_commit_sha? && head_commit_sha? && start_commit_sha?
end
+ def diffs_in_batch(batch_page, batch_size, diff_options:)
+ Gitlab::Diff::FileCollection::MergeRequestDiffBatch.new(self,
+ batch_page,
+ batch_size,
+ diff_options: diff_options)
+ end
+
def diffs(diff_options = nil)
if without_files? && comparison = diff_refs&.compare_in(project)
# It should fetch the repository when diffs are cleaned by the system.
@@ -495,11 +505,6 @@ class MergeRequestDiff < ApplicationRecord
merge_request.closed? && merge_request.metrics.latest_closed_at < EXTERNAL_DIFF_CUTOFF.ago
end
- # We can't rely on `merge_request.latest_merge_request_diff_id` because that
- # may have been changed in `save_git_content` without being reflected in
- # the association's instance. This query is always subject to races, but
- # the worst case is that we *don't* make a diff external when we could. The
- # background worker will make it external at a later date.
def old_version?
latest_id = MergeRequest
.where(id: merge_request_id)
@@ -507,7 +512,7 @@ class MergeRequestDiff < ApplicationRecord
.pluck(:latest_merge_request_diff_id)
.first
- self.id != latest_id
+ latest_id && self.id < latest_id
end
def load_diffs(options)
@@ -584,3 +589,5 @@ class MergeRequestDiff < ApplicationRecord
end
end
end
+
+MergeRequestDiff.prepend_if_ee('EE::MergeRequestDiff')
diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb
index a532c1e6356..14c86ec69da 100644
--- a/app/models/merge_request_diff_file.rb
+++ b/app/models/merge_request_diff_file.rb
@@ -5,6 +5,7 @@ class MergeRequestDiffFile < ApplicationRecord
include DiffFile
belongs_to :merge_request_diff, inverse_of: :merge_request_diff_files
+ alias_attribute :index, :relative_order
def utf8_diff
return '' if diff.blank?
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index 916c11a8d03..2fa0cfc9b93 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -257,10 +257,9 @@ class Milestone < ApplicationRecord
title.to_slug.normalize.to_s
end
- def parent
+ def resource_parent
group || project
end
- alias_method :resource_parent, :parent
def group_milestone?
group_id.present?
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 9a7c3dc03c3..5663ebf8ba1 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -120,6 +120,13 @@ class Namespace < ApplicationRecord
uniquify = Uniquify.new
uniquify.string(path) { |s| Namespace.find_by_path_or_name(s) }
end
+
+ def find_by_pages_host(host)
+ gitlab_host = "." + Settings.pages.host.downcase
+ name = host.downcase.delete_suffix(gitlab_host)
+
+ Namespace.find_by_full_path(name)
+ end
end
def visibility_level_field
@@ -175,7 +182,7 @@ class Namespace < ApplicationRecord
# any ancestor can disable emails for all descendants
def emails_disabled?
strong_memoize(:emails_disabled) do
- Feature.enabled?(:emails_disabled, self, default_enabled: true) && self_and_ancestors.where(emails_disabled: true).exists?
+ self_and_ancestors.where(emails_disabled: true).exists?
end
end
@@ -249,7 +256,7 @@ class Namespace < ApplicationRecord
end
def has_parent?
- parent.present?
+ parent_id.present? || parent.present?
end
def root_ancestor
@@ -305,8 +312,28 @@ class Namespace < ApplicationRecord
aggregation_schedule.present?
end
+ def pages_virtual_domain
+ Pages::VirtualDomain.new(all_projects_with_pages, trim_prefix: full_path)
+ end
+
+ def closest_setting(name)
+ self_and_ancestors(hierarchy_order: :asc)
+ .find { |n| !n.read_attribute(name).nil? }
+ .try(name)
+ end
+
private
+ def all_projects_with_pages
+ if all_projects.pages_metadata_not_migrated.exists?
+ Gitlab::BackgroundMigration::MigratePagesMetadata.new.perform_on_relation(
+ all_projects.pages_metadata_not_migrated
+ )
+ end
+
+ all_projects.with_pages_deployed
+ end
+
def parent_changed?
parent_id_changed?
end
diff --git a/app/models/note.rb b/app/models/note.rb
index b1829e71017..43f349c6fa2 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -24,7 +24,7 @@ class Note < ApplicationRecord
class << self
def values
- constants.map {|const| self.const_get(const)}
+ constants.map {|const| self.const_get(const, false)}
end
def value?(val)
@@ -104,6 +104,8 @@ class Note < ApplicationRecord
end
end
+ validate :does_not_exceed_notes_limit?, on: :create, unless: [:system?, :importing?]
+
# @deprecated attachments are handler by the MarkdownUploader
mount_uploader :attachment, AttachmentUploader
@@ -143,6 +145,9 @@ class Note < ApplicationRecord
end
scope :with_metadata, -> { includes(:system_note_metadata) }
+ scope :for_note_or_capitalized_note, ->(text) { where(note: [text, text.capitalize]) }
+ scope :like_note_or_capitalized_note, ->(text) { where('(note LIKE ? OR note LIKE ?)', text, text.capitalize) }
+
after_initialize :ensure_discussion_id
before_validation :nullify_blank_type, :nullify_blank_line_code
before_validation :set_discussion_id, on: :create
@@ -193,6 +198,12 @@ class Note < ApplicationRecord
groups
end
+ def positions
+ where.not(position: nil)
+ .select(:id, :type, :position) # ActiveRecord needs id and type for typecasting.
+ .map(&:position)
+ end
+
def count_for_collection(ids, type)
user.select('noteable_id', 'COUNT(*) as count')
.group(:noteable_id)
@@ -215,7 +226,7 @@ class Note < ApplicationRecord
if force_cross_reference_regex_check?
matches_cross_reference_regex?
else
- SystemNoteService.cross_reference?(note)
+ ::SystemNotes::IssuablesService.cross_reference?(note)
end
end
# rubocop: enable CodeReuse/ServiceClass
@@ -472,10 +483,9 @@ class Note < ApplicationRecord
Upload.find_by(model: self, path: paths)
end
- def parent
+ def resource_parent
project
end
- alias_method :resource_parent, :parent
private
@@ -519,6 +529,12 @@ class Note < ApplicationRecord
system_note_metadata&.cross_reference_types&.include?(system_note_metadata&.action)
end
+
+ def does_not_exceed_notes_limit?
+ return unless noteable
+
+ errors.add(:base, _('Maximum number of comments exceeded')) if noteable.notes.count >= Noteable::MAX_NOTES_LIMIT
+ end
end
Note.prepend_if_ee('EE::Note')
diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb
index 981590b688f..2b3443f24d7 100644
--- a/app/models/notification_setting.rb
+++ b/app/models/notification_setting.rb
@@ -25,6 +25,7 @@ class NotificationSetting < ApplicationRecord
end
EMAIL_EVENTS = [
+ :new_release,
:new_note,
:new_issue,
:reopen_issue,
@@ -46,6 +47,10 @@ class NotificationSetting < ApplicationRecord
EMAIL_EVENTS
end
+ def self.allowed_fields(source = nil)
+ NotificationSetting.email_events(source).dup + %i(level notification_email)
+ end
+
def email_events
self.class.email_events(source)
end
diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb
index 1b3183a2a43..51c496c77d3 100644
--- a/app/models/pages/lookup_path.rb
+++ b/app/models/pages/lookup_path.rb
@@ -2,9 +2,10 @@
module Pages
class LookupPath
- def initialize(project, domain: nil)
+ def initialize(project, trim_prefix: nil, domain: nil)
@project = project
@domain = domain
+ @trim_prefix = trim_prefix || project.full_path
end
def project_id
@@ -28,11 +29,15 @@ module Pages
end
def prefix
- '/'
+ if project.pages_group_root?
+ '/'
+ else
+ project.full_path.delete_prefix(trim_prefix) + '/'
+ end
end
private
- attr_reader :project, :domain
+ attr_reader :project, :trim_prefix, :domain
end
end
diff --git a/app/models/pages/virtual_domain.rb b/app/models/pages/virtual_domain.rb
index 3a876dc06a2..7e42b8e6ae2 100644
--- a/app/models/pages/virtual_domain.rb
+++ b/app/models/pages/virtual_domain.rb
@@ -2,8 +2,9 @@
module Pages
class VirtualDomain
- def initialize(projects, domain: nil)
+ def initialize(projects, trim_prefix: nil, domain: nil)
@projects = projects
+ @trim_prefix = trim_prefix
@domain = domain
end
@@ -17,12 +18,12 @@ module Pages
def lookup_paths
projects.map do |project|
- project.pages_lookup_path(domain: domain)
+ project.pages_lookup_path(trim_prefix: trim_prefix, domain: domain)
end.sort_by(&:prefix).reverse
end
private
- attr_reader :projects, :domain
+ attr_reader :projects, :trim_prefix, :domain
end
end
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index 22a6bae7cf7..7903a2182dd 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -186,11 +186,27 @@ class PagesDomain < ApplicationRecord
end
def pages_virtual_domain
+ return unless pages_deployed?
+
Pages::VirtualDomain.new([project], domain: self)
end
private
+ def pages_deployed?
+ # TODO: remove once `pages_metadatum` is migrated
+ # https://gitlab.com/gitlab-org/gitlab/issues/33106
+ unless project.pages_metadatum
+ Gitlab::BackgroundMigration::MigratePagesMetadata
+ .new
+ .perform_on_relation(Project.where(id: project_id))
+
+ project.reset
+ end
+
+ project.pages_metadatum&.deployed?
+ end
+
def set_verification_code
return if self.verification_code.present?
diff --git a/app/models/project.rb b/app/models/project.rb
index 5c3bf4a3b5d..3525f37f8d5 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -68,7 +68,7 @@ class Project < ApplicationRecord
:snippets_access_level, :builds_access_level, :repository_access_level,
to: :project_feature, allow_nil: true
- delegate :base_dir, :disk_path, :ensure_storage_path_exists, to: :storage
+ delegate :base_dir, :disk_path, to: :storage
delegate :scheduled?, :started?, :in_progress?,
:failed?, :finished?,
@@ -104,6 +104,9 @@ class Project < ApplicationRecord
unless: :ci_cd_settings,
if: proc { ProjectCiCdSetting.available? }
+ after_create :create_pages_metadatum,
+ unless: :pages_metadatum
+
after_create :set_timestamps_for_create
after_update :update_forks_visibility_level
@@ -119,8 +122,6 @@ class Project < ApplicationRecord
# Storage specific hooks
after_initialize :use_hashed_storage
after_create :check_repository_absence!
- after_create :ensure_storage_path_exists
- after_save :ensure_storage_path_exists, if: :saved_change_to_namespace_id?
acts_as_ordered_taggable
@@ -192,6 +193,7 @@ class Project < ApplicationRecord
has_one :project_repository, inverse_of: :project
has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting'
has_one :metrics_setting, inverse_of: :project, class_name: 'ProjectMetricsSetting'
+ has_one :grafana_integration, inverse_of: :project
# Merge Requests for target project should be removed with it
has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project
@@ -242,8 +244,8 @@ class Project < ApplicationRecord
has_one :cluster_project, class_name: 'Clusters::Project'
has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster'
- has_many :cluster_ingresses, through: :clusters, source: :application_ingress, class_name: 'Clusters::Applications::Ingress'
has_many :kubernetes_namespaces, class_name: 'Clusters::KubernetesNamespace'
+ has_many :management_clusters, class_name: 'Clusters::Cluster', foreign_key: :management_project_id, inverse_of: :management_project
has_many :prometheus_metrics
@@ -273,12 +275,13 @@ class Project < ApplicationRecord
has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :build_trace_section_names, class_name: 'Ci::BuildTraceSectionName'
has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks
+ has_many :job_artifacts, class_name: 'Ci::JobArtifact'
has_many :runner_projects, class_name: 'Ci::RunnerProject', inverse_of: :project
has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
has_many :variables, class_name: 'Ci::Variable'
has_many :triggers, class_name: 'Ci::Trigger'
has_many :environments
- has_many :deployments, -> { success }
+ has_many :deployments
has_many :pipeline_schedules, class_name: 'Ci::PipelineSchedule'
has_many :project_deploy_tokens
has_many :deploy_tokens, through: :project_deploy_tokens
@@ -294,6 +297,11 @@ class Project < ApplicationRecord
has_many :external_pull_requests, inverse_of: :project
+ has_many :sourced_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :source_project_id
+ has_many :source_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :project_id
+
+ has_one :pages_metadatum, class_name: 'ProjectPagesMetadatum', inverse_of: :project
+
accepts_nested_attributes_for :variables, allow_destroy: true
accepts_nested_attributes_for :project_feature, update_only: true
accepts_nested_attributes_for :import_data
@@ -306,6 +314,7 @@ class Project < ApplicationRecord
accepts_nested_attributes_for :error_tracking_setting, update_only: true
accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true
+ accepts_nested_attributes_for :grafana_integration, update_only: true, allow_destroy: true
delegate :name, to: :owner, allow_nil: true, prefix: true
delegate :members, to: :team, prefix: true
@@ -424,6 +433,15 @@ class Project < ApplicationRecord
.where(project_ci_cd_settings: { group_runners_enabled: true })
end
+ scope :with_pages_deployed, -> do
+ joins(:pages_metadatum).merge(ProjectPagesMetadatum.deployed)
+ end
+
+ scope :pages_metadata_not_migrated, -> do
+ left_outer_joins(:pages_metadatum)
+ .where(project_pages_metadata: { project_id: nil })
+ end
+
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
chronic_duration_attr :build_timeout_human_readable, :build_timeout,
@@ -650,7 +668,7 @@ class Project < ApplicationRecord
def emails_disabled?
strong_memoize(:emails_disabled) do
# disabling in the namespace overrides the project setting
- Feature.enabled?(:emails_disabled, self, default_enabled: true) && (super || namespace.emails_disabled?)
+ super || namespace.emails_disabled?
end
end
@@ -1018,8 +1036,8 @@ class Project < ApplicationRecord
end
end
- def web_url
- Gitlab::Routing.url_helpers.project_url(self)
+ def web_url(only_path: nil)
+ Gitlab::Routing.url_helpers.project_url(self, only_path: only_path)
end
def readme_url
@@ -1298,7 +1316,18 @@ class Project < ApplicationRecord
end
def http_url_to_repo
- "#{web_url}.git"
+ custom_root = Gitlab::CurrentSettings.custom_http_clone_url_root
+
+ project_url = if custom_root.present?
+ Gitlab::Utils.append_path(
+ custom_root,
+ web_url(only_path: true)
+ )
+ else
+ web_url
+ end
+
+ "#{project_url}.git"
end
# Is overridden in EE
@@ -1647,6 +1676,10 @@ class Project < ApplicationRecord
"#{url}/#{url_path}"
end
+ def pages_group_root?
+ pages_group_url == pages_url
+ end
+
def pages_subdomain
full_path.partition('/').first
end
@@ -1685,6 +1718,7 @@ class Project < ApplicationRecord
# Projects with a missing namespace cannot have their pages removed
return unless namespace
+ mark_pages_as_not_deployed unless destroyed?
::Projects::UpdatePagesConfigurationService.new(self).execute
# 1. We rename pages to temporary directory
@@ -1698,6 +1732,14 @@ class Project < ApplicationRecord
end
# rubocop: enable CodeReuse/ServiceClass
+ def mark_pages_as_deployed
+ ensure_pages_metadatum.update!(deployed: true)
+ end
+
+ def mark_pages_as_not_deployed
+ ensure_pages_metadatum.update!(deployed: false)
+ end
+
# rubocop:disable Gitlab/RailsLogger
def write_repository_config(gl_full_path: full_path)
# We'd need to keep track of project full path otherwise directory tree
@@ -1821,6 +1863,7 @@ class Project < ApplicationRecord
Gitlab::Ci::Variables::Collection.new
.append(key: 'CI_PROJECT_ID', value: id.to_s)
.append(key: 'CI_PROJECT_NAME', value: path)
+ .append(key: 'CI_PROJECT_TITLE', value: title)
.append(key: 'CI_PROJECT_PATH', value: full_path)
.append(key: 'CI_PROJECT_PATH_SLUG', value: full_path_slug)
.append(key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path)
@@ -2217,12 +2260,37 @@ class Project < ApplicationRecord
members.maintainers.order_recent_sign_in.limit(ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT)
end
- def pages_lookup_path(domain: nil)
- Pages::LookupPath.new(self, domain: domain)
+ def pages_lookup_path(trim_prefix: nil, domain: nil)
+ Pages::LookupPath.new(self, trim_prefix: trim_prefix, domain: domain)
+ end
+
+ def closest_setting(name)
+ setting = read_attribute(name)
+ setting = closest_namespace_setting(name) if setting.nil?
+ setting = app_settings_for(name) if setting.nil?
+ setting
+ end
+
+ def drop_visibility_level!
+ if group && group.visibility_level < visibility_level
+ self.visibility_level = group.visibility_level
+ end
+
+ if Gitlab::CurrentSettings.restricted_visibility_levels.include?(visibility_level)
+ self.visibility_level = Gitlab::VisibilityLevel::PRIVATE
+ end
end
private
+ def closest_namespace_setting(name)
+ namespace.closest_setting(name)
+ end
+
+ def app_settings_for(name)
+ Gitlab::CurrentSettings.send(name) # rubocop:disable GitlabSecurity/PublicSend
+ end
+
def merge_requests_allowing_collaboration(source_branch = nil)
relation = source_of_merge_requests.opened.where(allow_collaboration: true)
relation = relation.where(source_branch: source_branch) if source_branch
@@ -2268,7 +2336,7 @@ class Project < ApplicationRecord
end
def repository_with_same_path_already_exists?
- gitlab_shell.exists?(repository_storage, "#{disk_path}.git")
+ gitlab_shell.repository_exists?(repository_storage, "#{disk_path}.git")
end
def set_timestamps_for_create
@@ -2346,6 +2414,13 @@ class Project < ApplicationRecord
def services_templates
@services_templates ||= Service.where(template: true)
end
+
+ def ensure_pages_metadatum
+ pages_metadatum || create_pages_metadatum!
+ rescue ActiveRecord::RecordNotUnique
+ reset
+ retry
+ end
end
Project.prepend_if_ee('EE::Project')
diff --git a/app/models/project_pages_metadatum.rb b/app/models/project_pages_metadatum.rb
new file mode 100644
index 00000000000..1fda388b1ae
--- /dev/null
+++ b/app/models/project_pages_metadatum.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class ProjectPagesMetadatum < ApplicationRecord
+ self.primary_key = :project_id
+
+ belongs_to :project, inverse_of: :pages_metadatum
+
+ scope :deployed, -> { where(deployed: true) }
+end
diff --git a/app/models/project_services/data_fields.rb b/app/models/project_services/data_fields.rb
index 46136556ade..cffb493d569 100644
--- a/app/models/project_services/data_fields.rb
+++ b/app/models/project_services/data_fields.rb
@@ -5,7 +5,7 @@ module DataFields
class_methods do
# Provide convenient accessor methods for data fields.
- # TODO: Simplify as part of https://gitlab.com/gitlab-org/gitlab-foss/issues/63084
+ # TODO: Simplify as part of https://gitlab.com/gitlab-org/gitlab/issues/29404
def data_field(*args)
args.each do |arg|
self.class_eval <<-RUBY, __FILE__, __LINE__ + 1
diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb
index 3320405e9e9..019bd54f48c 100644
--- a/app/models/project_services/hipchat_service.rb
+++ b/app/models/project_services/hipchat_service.rb
@@ -73,7 +73,7 @@ class HipchatService < Service
private
def gate
- options = { api_version: api_version.present? ? api_version : 'v2' }
+ options = { api_version: api_version.presence || 'v2' }
options[:server_url] = server unless server.blank?
@gate ||= HipChat::Client.new(token, options)
end
@@ -161,7 +161,7 @@ class HipchatService < Service
obj_attr = data[:object_attributes]
obj_attr = HashWithIndifferentAccess.new(obj_attr)
title = render_line(obj_attr[:title])
- state = obj_attr[:state]
+ state = Issue.available_states.key(obj_attr[:state_id])
issue_iid = obj_attr[:iid]
issue_url = obj_attr[:url]
description = obj_attr[:description]
diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb
index fb76bc89c98..4a6c8339625 100644
--- a/app/models/project_services/irker_service.rb
+++ b/app/models/project_services/irker_service.rb
@@ -36,8 +36,8 @@ class IrkerService < Service
def settings
{
- server_host: server_host.present? ? server_host : 'localhost',
- server_port: server_port.present? ? server_port : 6659
+ server_host: server_host.presence || 'localhost',
+ server_port: server_port.presence || 6659
}
end
diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb
index 3ecd5390d79..9e1393196ff 100644
--- a/app/models/project_services/issue_tracker_service.rb
+++ b/app/models/project_services/issue_tracker_service.rb
@@ -4,7 +4,7 @@ class IssueTrackerService < Service
validate :one_issue_tracker, if: :activated?, on: :manual_change
# TODO: we can probably just delegate as part of
- # https://gitlab.com/gitlab-org/gitlab-foss/issues/63084
+ # https://gitlab.com/gitlab-org/gitlab/issues/29404
data_field :project_url, :issues_url, :new_issue_url
default_value_for :category, 'issue_tracker'
@@ -25,7 +25,7 @@ class IssueTrackerService < Service
end
end
- # this will be removed as part of https://gitlab.com/gitlab-org/gitlab-foss/issues/63084
+ # this will be removed as part of https://gitlab.com/gitlab-org/gitlab/issues/29404
def title
if title_attribute = read_attribute(:title)
title_attribute
@@ -36,7 +36,7 @@ class IssueTrackerService < Service
end
end
- # this will be removed as part of https://gitlab.com/gitlab-org/gitlab-foss/issues/63084
+ # this will be removed as part of https://gitlab.com/gitlab-org/gitlab/issues/29404
def description
if description_attribute = read_attribute(:description)
description_attribute
@@ -49,7 +49,7 @@ class IssueTrackerService < Service
def handle_properties
# this has been moved from initialize_properties and should be improved
- # as part of https://gitlab.com/gitlab-org/gitlab-foss/issues/63084
+ # as part of https://gitlab.com/gitlab-org/gitlab/issues/29404
return unless properties
@legacy_properties_data = properties.dup
@@ -62,6 +62,7 @@ class IssueTrackerService < Service
end
data_values.reject! { |key| data_fields.changed.include?(key) }
+ data_values.slice!(*data_fields.attributes.keys)
data_fields.assign_attributes(data_values) if data_values.present?
self.properties = {}
@@ -71,6 +72,10 @@ class IssueTrackerService < Service
@legacy_properties_data ||= {}
end
+ def supports_data_fields?
+ true
+ end
+
def data_fields
issue_tracker_data || self.build_issue_tracker_data
end
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index a76970bfa2a..ba61810e26f 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -19,7 +19,7 @@ class JiraService < IssueTrackerService
# for more information check: https://gitlab.com/gitlab-org/gitlab-foss/issues/49936.
# TODO: we can probably just delegate as part of
- # https://gitlab.com/gitlab-org/gitlab-foss/issues/63084
+ # https://gitlab.com/gitlab-org/gitlab/issues/29404
data_field :username, :password, :url, :api_url, :jira_issue_transition_id
before_update :reset_password
@@ -64,7 +64,7 @@ class JiraService < IssueTrackerService
url = URI.parse(client_url)
{
- username: username,
+ username: username&.strip,
password: password,
site: URI.join(url, '/').to_s, # Intended to find the root
context_path: url.path,
@@ -122,9 +122,13 @@ class JiraService < IssueTrackerService
end
alias_method :original_url, :url
-
def url
- original_url&.chomp('/')
+ original_url&.delete_suffix('/')
+ end
+
+ alias_method :original_api_url, :api_url
+ def api_url
+ original_api_url&.delete_suffix('/')
end
def execute(push)
@@ -137,10 +141,9 @@ class JiraService < IssueTrackerService
return if issue.nil? || has_resolution?(issue) || !jira_issue_transition_id.present?
- commit_id = if entity.is_a?(Commit)
- entity.id
- elsif entity.is_a?(MergeRequest)
- entity.diff_head_sha
+ commit_id = case entity
+ when Commit then entity.id
+ when MergeRequest then entity.diff_head_sha
end
commit_url = build_entity_url(:commit, commit_id)
@@ -298,7 +301,7 @@ class JiraService < IssueTrackerService
title: title,
status: status,
icon: {
- title: 'GitLab', url16x16: asset_url(Gitlab::Favicon.main, host: gitlab_config.url)
+ title: 'GitLab', url16x16: asset_url(Gitlab::Favicon.main, host: gitlab_config.base_url)
}
}
}
@@ -331,7 +334,6 @@ class JiraService < IssueTrackerService
# Handle errors when doing Jira API calls
def jira_request
yield
-
rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => e
@error = e.message
log_error("Error sending message", client_url: client_url, error: @error)
@@ -339,7 +341,7 @@ class JiraService < IssueTrackerService
end
def client_url
- api_url.present? ? api_url : url
+ api_url.presence || url
end
def reset_password?
diff --git a/app/models/project_services/packagist_service.rb b/app/models/project_services/packagist_service.rb
index 003884bb7ac..35dbedd1341 100644
--- a/app/models/project_services/packagist_service.rb
+++ b/app/models/project_services/packagist_service.rb
@@ -59,7 +59,7 @@ class PackagistService < Service
end
def hook_url
- base_url = server.present? ? server : 'https://packagist.org'
+ base_url = server.presence || 'https://packagist.org'
"#{base_url}/api/update-package?username=#{username}&apiToken=#{token}"
end
end
diff --git a/app/models/project_services/slash_commands_service.rb b/app/models/project_services/slash_commands_service.rb
index 5bfd06476f0..d436176a52c 100644
--- a/app/models/project_services/slash_commands_service.rb
+++ b/app/models/project_services/slash_commands_service.rb
@@ -33,9 +33,12 @@ class SlashCommandsService < Service
return unless valid_token?(params[:token])
chat_user = find_chat_user(params)
+ user = chat_user&.user
+
+ if user
+ unless user.can?(:use_slash_commands)
+ return Gitlab::SlashCommands::Presenters::Access.new.deactivated if user.deactivated?
- if chat_user&.user
- unless chat_user.user.can?(:use_slash_commands)
return Gitlab::SlashCommands::Presenters::Access.new.access_denied(project)
end
diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb
index 218be974218..bb222ac7629 100644
--- a/app/models/project_wiki.rb
+++ b/app/models/project_wiki.rb
@@ -54,7 +54,7 @@ class ProjectWiki
end
def http_url_to_repo
- "#{Gitlab.config.gitlab.url}/#{full_path}.git"
+ @project.http_url_to_repo.sub(%r{git\z}, 'wiki.git')
end
def wiki_base_path
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 8769d3eb916..1857a59e01c 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -40,6 +40,11 @@ class ProtectedBranch < ApplicationRecord
def self.protected_refs(project)
project.protected_branches.select(:name)
end
+
+ def self.branch_requires_code_owner_approval?(project, branch_name)
+ # NOOP
+ #
+ end
end
ProtectedBranch.prepend_if_ee('EE::ProtectedBranch')
diff --git a/app/models/push_event.rb b/app/models/push_event.rb
index 4698df39730..5cab686f20b 100644
--- a/app/models/push_event.rb
+++ b/app/models/push_event.rb
@@ -26,6 +26,8 @@ class PushEvent < Event
delegate :commit_count, to: :push_event_payload
alias_method :commits_count, :commit_count
+ delegate :ref_count, to: :push_event_payload
+
# Returns events of pushes that either pushed to an existing ref or created a
# new one.
def self.created_or_pushed
@@ -52,7 +54,7 @@ class PushEvent < Event
.select(1)
.where('merge_requests.source_project_id = events.project_id')
.where('merge_requests.source_branch = push_event_payloads.ref')
- .where(state: :opened)
+ .with_state(:opened)
# For reasons unknown the use of #eager_load will result in the
# "push_event_payload" association not being set. Because of this we're
diff --git a/app/models/release.rb b/app/models/release.rb
index cd63b4d5fef..5a7bfe2d495 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -14,6 +14,7 @@ class Release < ApplicationRecord
has_many :milestone_releases
has_many :milestones, through: :milestone_releases
+ has_one :evidence
default_value_for :released_at, allows_nil: false do
Time.zone.now
@@ -22,13 +23,16 @@ class Release < ApplicationRecord
accepts_nested_attributes_for :links, allow_destroy: true
validates :description, :project, :tag, presence: true
- validates :name, presence: true, on: :create
validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") }
scope :sorted, -> { order(released_at: :desc) }
+ scope :with_project_and_namespace, -> { includes(project: :namespace) }
delegate :repository, to: :project
+ after_commit :create_evidence!, on: :create
+ after_commit :notify_new_release, on: :create
+
def commit
strong_memoize(:commit) do
repository.commit(actual_sha)
@@ -67,4 +71,14 @@ class Release < ApplicationRecord
repository.find_tag(tag)
end
end
+
+ def create_evidence!
+ CreateEvidenceWorker.perform_async(self.id)
+ end
+
+ def notify_new_release
+ NewReleaseWorker.perform_async(id)
+ end
end
+
+Release.prepend_if_ee('EE::Release')
diff --git a/app/models/repository.rb b/app/models/repository.rb
index f084a314392..b9f57169ea5 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -6,6 +6,7 @@ class Repository
REF_MERGE_REQUEST = 'merge-requests'
REF_KEEP_AROUND = 'keep-around'
REF_ENVIRONMENTS = 'environments'
+ REF_PIPELINES = 'pipelines'
ARCHIVE_CACHE_TIME = 60 # Cache archives referred to by a (mutable) ref for 1 minute
ARCHIVE_CACHE_TIME_IMMUTABLE = 3600 # Cache archives referred to by an immutable reference for 1 hour
@@ -16,7 +17,7 @@ class Repository
replace
#{REF_ENVIRONMENTS}
#{REF_KEEP_AROUND}
- #{REF_ENVIRONMENTS}
+ #{REF_PIPELINES}
].freeze
include Gitlab::RepositoryCacheAdapter
@@ -133,18 +134,28 @@ class Repository
end
end
- def commits(ref = nil, path: nil, limit: nil, offset: nil, skip_merges: false, after: nil, before: nil, all: nil)
+ # the opts are:
+ # - :path
+ # - :limit
+ # - :offset
+ # - :skip_merges
+ # - :after
+ # - :before
+ # - :all
+ # - :first_parent
+ def commits(ref = nil, opts = {})
options = {
repo: raw_repository,
ref: ref,
- path: path,
- limit: limit,
- offset: offset,
- after: after,
- before: before,
- follow: Array(path).length == 1,
- skip_merges: skip_merges,
- all: all
+ path: opts[:path],
+ follow: Array(opts[:path]).length == 1,
+ limit: opts[:limit],
+ offset: opts[:offset],
+ skip_merges: !!opts[:skip_merges],
+ after: opts[:after],
+ before: opts[:before],
+ all: !!opts[:all],
+ first_parent: !!opts[:first_parent]
}
commits = Gitlab::Git::Commit.where(options)
@@ -239,13 +250,13 @@ class Repository
def branch_exists?(branch_name)
return false unless raw_repository
- branch_names.include?(branch_name)
+ branch_names_include?(branch_name)
end
def tag_exists?(tag_name)
return false unless raw_repository
- tag_names.include?(tag_name)
+ tag_names_include?(tag_name)
end
def ref_exists?(ref)
@@ -549,15 +560,15 @@ class Repository
end
delegate :branch_names, to: :raw_repository
- cache_method :branch_names, fallback: []
+ cache_method_as_redis_set :branch_names, fallback: []
delegate :tag_names, to: :raw_repository
- cache_method :tag_names, fallback: []
+ cache_method_as_redis_set :tag_names, fallback: []
delegate :branch_count, :tag_count, :has_visible_content?, to: :raw_repository
cache_method :branch_count, fallback: 0
cache_method :tag_count, fallback: 0
- cache_method :has_visible_content?, fallback: false
+ cache_method_asymmetrically :has_visible_content?
def avatar
# n+1: https://gitlab.com/gitlab-org/gitlab-foss/issues/38327
@@ -1088,6 +1099,8 @@ class Repository
raw.create_repository
after_create
+
+ true
end
def blobs_metadata(paths, ref = 'HEAD')
diff --git a/app/models/repository_language.rb b/app/models/repository_language.rb
index e6867f905e2..e468d716239 100644
--- a/app/models/repository_language.rb
+++ b/app/models/repository_language.rb
@@ -7,7 +7,7 @@ class RepositoryLanguage < ApplicationRecord
default_scope { includes(:programming_language) }
validates :project, presence: true
- validates :share, inclusion: { in: 0..100, message: "The share of a lanuage is between 0 and 100" }
+ validates :share, inclusion: { in: 0..100, message: "The share of a language is between 0 and 100" }
validates :programming_language, uniqueness: { scope: :project_id }
delegate :name, :color, to: :programming_language
diff --git a/app/models/service.rb b/app/models/service.rb
index 43ed0c7dfaa..305cf7b78a2 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -291,6 +291,12 @@ class Service < ApplicationRecord
def self.build_from_template(project_id, template)
service = template.dup
+
+ if template.supports_data_fields?
+ data_fields = template.data_fields.dup
+ data_fields.service = service
+ end
+
service.template = false
service.project_id = project_id
service.active = false if service.active? && !service.valid?
@@ -309,6 +315,11 @@ class Service < ApplicationRecord
find_by(template: true)
end
+ # override if needed
+ def supports_data_fields?
+ false
+ end
+
private
def cache_project_has_external_issue_tracker
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 1e84b9fa12e..4010a3e2167 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -14,6 +14,7 @@ class Snippet < ApplicationRecord
include Editable
include Gitlab::SQL::Pattern
include FromUnion
+ extend ::Gitlab::Utils::Override
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description
@@ -32,8 +33,6 @@ class Snippet < ApplicationRecord
default_content_html_invalidator || file_name_changed?
end
- default_value_for(:visibility_level) { Gitlab::CurrentSettings.default_snippet_visibility }
-
belongs_to :author, class_name: 'User'
belongs_to :project
@@ -72,7 +71,7 @@ class Snippet < ApplicationRecord
end
end
- def self.only_global_snippets
+ def self.only_personal_snippets
where(project_id: nil)
end
@@ -138,6 +137,24 @@ class Snippet < ApplicationRecord
@link_reference_pattern ||= super("snippets", /(?<snippet>\d+)/)
end
+ def initialize(attributes = {})
+ # We can't use default_value_for because the database has a default
+ # value of 0 for visibility_level. If someone attempts to create a
+ # private snippet, default_value_for will assume that the
+ # visibility_level hasn't changed and will use the application
+ # setting default, which could be internal or public.
+ #
+ # To fix the problem, we assign the actual snippet default if no
+ # explicit visibility has been initialized.
+ attributes ||= {}
+
+ unless visibility_attribute_present?(attributes)
+ attributes[:visibility_level] = Gitlab::CurrentSettings.default_snippet_visibility
+ end
+
+ super
+ end
+
def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{id}"
@@ -191,6 +208,12 @@ class Snippet < ApplicationRecord
(public? && (title_changed? || content_changed?))
end
+ # snippers are the biggest sources of spam
+ override :allow_possible_spam?
+ def allow_possible_spam?
+ false
+ end
+
def spammable_entity_type
'snippet'
end
diff --git a/app/models/storage/hashed_project.rb b/app/models/storage/hashed_project.rb
index f5d0d6fab3b..9a38b06b2f9 100644
--- a/app/models/storage/hashed_project.rb
+++ b/app/models/storage/hashed_project.rb
@@ -27,10 +27,6 @@ module Storage
"#{base_dir}/#{disk_hash}" if disk_hash
end
- def ensure_storage_path_exists
- gitlab_shell.add_namespace(repository_storage, base_dir)
- end
-
def rename_repo(old_full_path: nil, new_full_path: nil)
true
end
diff --git a/app/models/storage/legacy_project.rb b/app/models/storage/legacy_project.rb
index 928c773c307..345172cca76 100644
--- a/app/models/storage/legacy_project.rb
+++ b/app/models/storage/legacy_project.rb
@@ -23,12 +23,6 @@ module Storage
project.full_path
end
- def ensure_storage_path_exists
- return unless namespace
-
- gitlab_shell.add_namespace(repository_storage, base_dir)
- end
-
def rename_repo(old_full_path: nil, new_full_path: nil)
old_full_path ||= project.full_path_before_last_save
new_full_path ||= project.build_full_path
diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb
index 22e2f11230d..96ffec90c00 100644
--- a/app/models/suggestion.rb
+++ b/app/models/suggestion.rb
@@ -41,7 +41,6 @@ class Suggestion < ApplicationRecord
!applied? &&
noteable.opened? &&
!outdated?(cached: cached) &&
- note.supports_suggestion? &&
different_content? &&
note.active?
end
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index 8ec90ca25d3..11cbeb60bba 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -23,6 +23,7 @@ class SystemNoteMetadata < ApplicationRecord
validates :action, inclusion: { in: :icon_types }, allow_nil: true
belongs_to :note
+ belongs_to :description_version
def icon_types
ICON_TYPES
diff --git a/app/models/todo.rb b/app/models/todo.rb
index 6b71845856a..1927b54510e 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -75,13 +75,13 @@ class Todo < ApplicationRecord
after_save :keep_around_commit, if: :commit_id
class << self
- # Returns all todos for the given group and its descendants.
+ # Returns all todos for the given group ids and their descendants.
#
- # group - A `Group` to retrieve todos for.
+ # group_ids - Group Ids to retrieve todos for.
#
# Returns an `ActiveRecord::Relation`.
- def for_group_and_descendants(group)
- groups = group.self_and_descendants
+ def for_group_ids_and_descendants(group_ids)
+ groups = Group.groups_including_descendants_by(group_ids)
from_union([
for_project(Project.for_group(groups)),
@@ -144,10 +144,9 @@ class Todo < ApplicationRecord
end
end
- def parent
+ def resource_parent
project
end
- alias_method :resource_parent, :parent
def unmergeable?
action == UNMERGEABLE
diff --git a/app/models/upload.rb b/app/models/upload.rb
index 7560002ada8..8c409641452 100644
--- a/app/models/upload.rb
+++ b/app/models/upload.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class Upload < ApplicationRecord
+ include Checksummable
# Upper limit for foreground checksum processing
CHECKSUM_THRESHOLD = 100.megabytes
@@ -15,16 +16,12 @@ class Upload < ApplicationRecord
scope :with_files_stored_remotely, -> { where(store: ObjectStorage::Store::REMOTE) }
before_save :calculate_checksum!, if: :foreground_checksummable?
- after_commit :schedule_checksum, if: :checksummable?
+ after_commit :schedule_checksum, if: :needs_checksum?
# as the FileUploader is not mounted, the default CarrierWave ActiveRecord
# hooks are not executed and the file will not be deleted
after_destroy :delete_file!, if: -> { uploader_class <= FileUploader }
- def self.hexdigest(path)
- Digest::SHA256.file(path).hexdigest
- end
-
class << self
##
# FastDestroyAll concerns
@@ -53,20 +50,41 @@ class Upload < ApplicationRecord
def calculate_checksum!
self.checksum = nil
- return unless checksummable?
+ return unless needs_checksum?
- self.checksum = Digest::SHA256.file(absolute_path).hexdigest
+ self.checksum = self.class.hexdigest(absolute_path)
end
+ # Initialize the associated Uploader class with current model
+ #
+ # @param [String] mounted_as
+ # @return [GitlabUploader] one of the subclasses, defined at the model's uploader attribute
def build_uploader(mounted_as = nil)
uploader_class.new(model, mounted_as || mount_point).tap do |uploader|
uploader.upload = self
+ end
+ end
+
+ # Initialize the associated Uploader class with current model and
+ # retrieve existing file from the store to a local cache
+ #
+ # @param [String] mounted_as
+ # @return [GitlabUploader] one of the subclasses, defined at the model's uploader attribute
+ def retrieve_uploader(mounted_as = nil)
+ build_uploader(mounted_as).tap do |uploader|
uploader.retrieve_from_store!(identifier)
end
end
+ # This checks for existence of the upload on storage
+ #
+ # @return [Boolean] whether upload exists on storage
def exist?
- exist = File.exist?(absolute_path)
+ exist = if local?
+ File.exist?(absolute_path)
+ else
+ retrieve_uploader.exists?
+ end
# Help sysadmins find missing upload files
if persisted? && !exist
@@ -91,18 +109,24 @@ class Upload < ApplicationRecord
store == ObjectStorage::Store::LOCAL
end
+ # Returns whether generating checksum is needed
+ #
+ # This takes into account whether file exists, if any checksum exists
+ # or if the storage has checksum generation code implemented
+ #
+ # @return [Boolean] whether generating a checksum is needed
+ def needs_checksum?
+ checksum.nil? && local? && exist?
+ end
+
private
def delete_file!
- build_uploader.remove!
- end
-
- def checksummable?
- checksum.nil? && local? && exist?
+ retrieve_uploader.remove!
end
def foreground_checksummable?
- checksummable? && size <= CHECKSUM_THRESHOLD
+ needs_checksum? && size <= CHECKSUM_THRESHOLD
end
def schedule_checksum
@@ -114,7 +138,7 @@ class Upload < ApplicationRecord
end
def uploader_class
- Object.const_get(uploader)
+ Object.const_get(uploader, false)
end
def identifier
diff --git a/app/models/user.rb b/app/models/user.rb
index a69db121a0b..321a4080484 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -59,6 +59,8 @@ class User < ApplicationRecord
# Removed in GitLab 12.3. Keep until after 2019-09-22.
self.ignored_columns += %i[support_bot]
+ MINIMUM_INACTIVE_DAYS = 14
+
# Override Devise::Models::Trackable#update_tracked_fields!
# to limit database writes to at most once every hour
# rubocop: disable CodeReuse/ServiceClass
@@ -97,6 +99,7 @@ class User < ApplicationRecord
has_many :u2f_registrations, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :chat_names, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :user_synced_attributes_metadata, autosave: true
+ has_one :aws_role, class_name: 'Aws::Role'
# Groups
has_many :members
@@ -228,6 +231,10 @@ class User < ApplicationRecord
# Note: When adding an option, it MUST go on the end of the array.
enum project_view: [:readme, :activity, :files]
+ # User's role
+ # Note: When adding an option, it MUST go on the end of the array.
+ enum role: [:software_developer, :development_team_lead, :devops_engineer, :systems_administrator, :security_analyst, :data_analyst, :product_manager, :product_designer, :other], _suffix: true
+
delegate :path, to: :namespace, allow_nil: true, prefix: true
delegate :notes_filter_for, to: :user_preference
delegate :set_notes_filter, to: :user_preference
@@ -242,18 +249,25 @@ class User < ApplicationRecord
state_machine :state, initial: :active do
event :block do
transition active: :blocked
+ transition deactivated: :blocked
transition ldap_blocked: :blocked
end
event :ldap_block do
transition active: :ldap_blocked
+ transition deactivated: :ldap_blocked
end
event :activate do
+ transition deactivated: :active
transition blocked: :active
transition ldap_blocked: :active
end
+ event :deactivate do
+ transition active: :deactivated
+ end
+
state :blocked, :ldap_blocked do
def blocked?
true
@@ -284,6 +298,7 @@ class User < ApplicationRecord
scope :blocked, -> { with_states(:blocked, :ldap_blocked) }
scope :external, -> { where(external: true) }
scope :active, -> { with_state(:active).non_internal }
+ scope :deactivated, -> { with_state(:deactivated).non_internal }
scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) }
scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) }
scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) }
@@ -431,6 +446,8 @@ class User < ApplicationRecord
without_projects
when 'external'
external
+ when 'deactivated'
+ deactivated
else
active
end
@@ -444,6 +461,7 @@ class User < ApplicationRecord
#
# Returns an ActiveRecord::Relation.
def search(query)
+ query = query&.delete_prefix('@')
return none if query.blank?
query = query.downcase
@@ -520,7 +538,7 @@ class User < ApplicationRecord
# Returns a user for the given SSH key.
def find_by_ssh_key_id(key_id)
- Key.find_by(id: key_id)&.user
+ find_by('EXISTS (?)', Key.select(1).where('keys.user_id = users.id').where(id: key_id))
end
def find_by_full_path(path, follow_redirects: false)
@@ -1303,14 +1321,27 @@ class User < ApplicationRecord
notification_group&.notification_email_for(self) || notification_email
end
- def notification_settings_for(source)
+ def notification_settings_for(source, inherit: false)
if notification_settings.loaded?
notification_settings.find do |notification|
notification.source_type == source.class.base_class.name &&
notification.source_id == source.id
end
else
- notification_settings.find_or_initialize_by(source: source)
+ notification_settings.find_or_initialize_by(source: source) do |ns|
+ next unless source.is_a?(Group) && inherit
+
+ # If we're here it means we're trying to create a NotificationSetting for a group that doesn't have one.
+ # Find the closest parent with a notification_setting that's not Global level, or that has an email set.
+ ancestor_ns = source
+ .notification_settings(hierarchy_order: :asc)
+ .where(user: self)
+ .find_by('level != ? OR notification_email IS NOT NULL', NotificationSetting.levels[:global])
+ # Use it to seed the settings
+ ns.assign_attributes(ancestor_ns&.slice(*NotificationSetting.allowed_fields))
+ ns.source = source
+ ns.user = self
+ end
end
end
@@ -1529,6 +1560,35 @@ class User < ApplicationRecord
todos.find_by(target: target, state: :pending)
end
+ def password_expired?
+ !!(password_expires_at && password_expires_at < Time.now)
+ end
+
+ def can_be_deactivated?
+ active? && no_recent_activity?
+ end
+
+ def last_active_at
+ last_activity = last_activity_on&.to_time&.in_time_zone
+ last_sign_in = current_sign_in_at
+
+ [last_activity, last_sign_in].compact.max
+ end
+
+ # Below is used for the signup_flow experiment. Should be removed
+ # when experiment finishes.
+ # See https://gitlab.com/gitlab-org/growth/engineering/issues/64
+ REQUIRES_ROLE_VALUE = 99
+
+ def role_required?
+ role_before_type_cast == REQUIRES_ROLE_VALUE
+ end
+
+ def set_role_required!
+ update_column(:role, REQUIRES_ROLE_VALUE)
+ end
+ # End of signup_flow experiment methods
+
# @deprecated
alias_method :owned_or_masters_groups, :owned_or_maintainers_groups
@@ -1678,6 +1738,10 @@ class User < ApplicationRecord
::Group.where(id: developer_groups_hierarchy.select(:id),
project_creation_level: project_creation_levels)
end
+
+ def no_recent_activity?
+ last_active_at.to_i <= MINIMUM_INACTIVE_DAYS.days.ago.to_i
+ end
end
User.prepend_if_ee('EE::User')
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index cd4c7895587..1fa29e5b933 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -77,11 +77,7 @@ class WikiPage
# The escaped URL path of this page.
def slug
- if @attributes[:slug].present?
- @attributes[:slug]
- else
- wiki.wiki.preview_slug(title, format)
- end
+ @attributes[:slug].presence || wiki.wiki.preview_slug(title, format)
end
alias_method :to_param, :slug
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index 78379516062..18c23cbd13a 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -5,12 +5,22 @@ require_dependency 'declarative_policy'
class BasePolicy < DeclarativePolicy::Base
desc "User is an instance admin"
with_options scope: :user, score: 0
- condition(:admin) { @user&.admin? }
+ condition(:admin) do
+ if Feature.enabled?(:user_mode_in_session)
+ Gitlab::Auth::CurrentUserMode.new(@user).admin_mode?
+ else
+ @user&.admin?
+ end
+ end
desc "User is blocked"
with_options scope: :user, score: 0
condition(:blocked) { @user&.blocked? }
+ desc "User is deactivated"
+ with_options scope: :user, score: 0
+ condition(:deactivated) { @user&.deactivated? }
+
desc "User has access to all private groups & projects"
with_options scope: :user, score: 0
condition(:full_private_access) { @user&.full_private_access? }
diff --git a/app/policies/board_policy.rb b/app/policies/board_policy.rb
index b8435dad3f1..e2b16249c85 100644
--- a/app/policies/board_policy.rb
+++ b/app/policies/board_policy.rb
@@ -3,7 +3,7 @@
class BoardPolicy < BasePolicy
include FindGroupProjects
- delegate { @subject.parent }
+ delegate { @subject.resource_parent }
condition(:is_group_board) { @subject.group_board? }
condition(:is_project_board) { @subject.project_board? }
@@ -19,7 +19,7 @@ class BoardPolicy < BasePolicy
condition(:reporter_of_group_projects) do
next unless @user
- group_projects_for(user: @user, group: @subject.parent)
+ group_projects_for(user: @user, group: @subject.resource_parent)
.visible_to_user_and_access_level(@user, ::Gitlab::Access::REPORTER)
.exists?
end
diff --git a/app/policies/clusters/instance_policy.rb b/app/policies/clusters/instance_policy.rb
index c8e6c973bf5..d8e8f9ff2c1 100644
--- a/app/policies/clusters/instance_policy.rb
+++ b/app/policies/clusters/instance_policy.rb
@@ -12,3 +12,5 @@ module Clusters
end
end
end
+
+Clusters::InstancePolicy.prepend_if_ee('EE::Clusters::InstancePolicy')
diff --git a/app/policies/deploy_keys_project_policy.rb b/app/policies/deploy_keys_project_policy.rb
new file mode 100644
index 00000000000..368377048a4
--- /dev/null
+++ b/app/policies/deploy_keys_project_policy.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class DeployKeysProjectPolicy < BasePolicy
+ delegate { @subject.project }
+
+ with_options scope: :subject, score: 0
+ condition(:public_deploy_key) { @subject.deploy_key.public? }
+
+ rule { public_deploy_key & can?(:admin_project) }.enable :update_deploy_keys_project
+end
diff --git a/app/policies/deployment_policy.rb b/app/policies/deployment_policy.rb
index d4f2f3c52b1..1a92b735e36 100644
--- a/app/policies/deployment_policy.rb
+++ b/app/policies/deployment_policy.rb
@@ -7,8 +7,20 @@ class DeploymentPolicy < BasePolicy
can?(:update_build, @subject.deployable)
end
- rule { ~can_retry_deployable }.policy do
+ condition(:has_deployable) do
+ @subject.deployable.present?
+ end
+
+ condition(:can_update_deployment) do
+ can?(:update_deployment, @subject.environment)
+ end
+
+ rule { has_deployable & ~can_retry_deployable }.policy do
prevent :create_deployment
prevent :update_deployment
end
+
+ rule { ~can_update_deployment }.policy do
+ prevent :update_deployment
+ end
end
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index 659fde574fc..eca73f0a241 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -44,6 +44,13 @@ class GlobalPolicy < BasePolicy
prevent :use_slash_commands
end
+ rule { deactivated }.policy do
+ prevent :access_git
+ prevent :access_api
+ prevent :receive_notifications
+ prevent :use_slash_commands
+ end
+
rule { required_terms_not_accepted }.policy do
prevent :access_api
prevent :access_git
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index f56ac0a5279..9e8ee3acf00 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -44,25 +44,28 @@ class GroupPolicy < BasePolicy
rule { public_group }.policy do
enable :read_group
- enable :read_list
- enable :read_label
end
rule { logged_in_viewable }.enable :read_group
rule { guest }.policy do
enable :read_group
- enable :read_list
enable :upload_file
- enable :read_label
end
- rule { admin }.enable :read_group
+ rule { admin }.policy do
+ enable :read_group
+ enable :update_max_artifacts_size
+ end
rule { has_projects }.policy do
+ enable :read_group
+ end
+
+ rule { can?(:read_group) }.policy do
+ enable :read_milestone
enable :read_list
enable :read_label
- enable :read_group
end
rule { has_access }.enable :read_namespace
diff --git a/app/policies/milestone_policy.rb b/app/policies/milestone_policy.rb
index ac4f5b08504..9cea8ddd7b3 100644
--- a/app/policies/milestone_policy.rb
+++ b/app/policies/milestone_policy.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
class MilestonePolicy < BasePolicy
- delegate { @subject.project }
+ delegate { @subject.resource_parent }
end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index e6f8d1052ed..ea2be37d7e6 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -137,6 +137,8 @@ class ProjectPolicy < BasePolicy
# not.
rule { guest | admin }.enable :read_project_for_iids
+ rule { admin }.enable :update_max_artifacts_size
+
rule { guest }.enable :guest_access
rule { reporter }.enable :reporter_access
rule { developer }.enable :developer_access
@@ -260,6 +262,7 @@ class ProjectPolicy < BasePolicy
enable :destroy_container_image
enable :create_environment
enable :create_deployment
+ enable :update_deployment
enable :create_release
enable :update_release
end
diff --git a/app/policies/todo_policy.rb b/app/policies/todo_policy.rb
new file mode 100644
index 00000000000..f8644217f04
--- /dev/null
+++ b/app/policies/todo_policy.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class TodoPolicy < BasePolicy
+ desc 'User can only read own todos'
+ condition(:own_todo) do
+ @user && @subject.user_id == @user.id
+ end
+
+ rule { own_todo }.enable :read_todo
+end
diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb
index 5231a8efa55..8e469795581 100644
--- a/app/presenters/ci/build_runner_presenter.rb
+++ b/app/presenters/ci/build_runner_presenter.rb
@@ -34,7 +34,8 @@ module Ci
def refspecs
specs = []
- specs << refspec_for_merge_request_ref if merge_request_ref?
+ specs << refspec_for_pipeline_ref if merge_request_ref?
+ specs << refspec_for_persistent_ref if persistent_ref_exist?
if git_depth > 0
specs << refspec_for_branch(ref) if branch? || legacy_detached_merge_request_pipeline?
@@ -86,10 +87,22 @@ module Ci
"+#{Gitlab::Git::TAG_REF_PREFIX}#{ref}:#{RUNNER_REMOTE_TAG_PREFIX}#{ref}"
end
- def refspec_for_merge_request_ref
+ def refspec_for_pipeline_ref
"+#{ref}:#{ref}"
end
+ def refspec_for_persistent_ref
+ "+#{persistent_ref_path}:#{persistent_ref_path}"
+ end
+
+ def persistent_ref_exist?
+ pipeline.persistent_ref.exist?
+ end
+
+ def persistent_ref_path
+ pipeline.persistent_ref.path
+ end
+
def git_depth_variable
strong_memoize(:git_depth_variable) do
variables&.find { |variable| variable[:key] == 'GIT_DEPTH' }
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
index 73a048dfa56..d81b1e6c522 100644
--- a/app/presenters/ci/pipeline_presenter.rb
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -48,15 +48,35 @@ module Ci
def ref_text
if pipeline.detached_merge_request_pipeline?
- _("for %{link_to_merge_request} with %{link_to_merge_request_source_branch}").html_safe % { link_to_merge_request: link_to_merge_request, link_to_merge_request_source_branch: link_to_merge_request_source_branch }
+ _("for %{link_to_merge_request} with %{link_to_merge_request_source_branch}")
+ .html_safe % {
+ link_to_merge_request: link_to_merge_request,
+ link_to_merge_request_source_branch: link_to_merge_request_source_branch
+ }
elsif pipeline.merge_request_pipeline?
- _("for %{link_to_merge_request} with %{link_to_merge_request_source_branch} into %{link_to_merge_request_target_branch}").html_safe % { link_to_merge_request: link_to_merge_request, link_to_merge_request_source_branch: link_to_merge_request_source_branch, link_to_merge_request_target_branch: link_to_merge_request_target_branch }
+ _("for %{link_to_merge_request} with %{link_to_merge_request_source_branch} into %{link_to_merge_request_target_branch}")
+ .html_safe % {
+ link_to_merge_request: link_to_merge_request,
+ link_to_merge_request_source_branch: link_to_merge_request_source_branch,
+ link_to_merge_request_target_branch: link_to_merge_request_target_branch
+ }
+ elsif pipeline.ref && pipeline.ref_exists?
+ _("for %{link_to_pipeline_ref}")
+ .html_safe % { link_to_pipeline_ref: link_to_pipeline_ref }
elsif pipeline.ref
- if pipeline.ref_exists?
- _("for %{link_to_pipeline_ref}").html_safe % { link_to_pipeline_ref: link_to_pipeline_ref }
- else
- _("for %{ref}").html_safe % { ref: content_tag(:span, pipeline.ref, class: 'ref-name') }
- end
+ _("for %{ref}").html_safe % { ref: plain_ref_name }
+ end
+ end
+
+ def all_related_merge_request_text
+ if all_related_merge_requests.none?
+ 'No related merge requests found.'
+ else
+ _("%{count} related %{pluralized_subject}: %{links}" % {
+ count: all_related_merge_requests.count,
+ pluralized_subject: 'merge request'.pluralize(all_related_merge_requests.count),
+ links: all_related_merge_request_links.join(', ')
+ }).html_safe
end
end
@@ -84,10 +104,30 @@ module Ci
private
+ def plain_ref_name
+ content_tag(:span, pipeline.ref, class: 'ref-name')
+ end
+
def merge_request_presenter
- return unless pipeline.triggered_by_merge_request?
+ strong_memoize(:merge_request_presenter) do
+ if pipeline.triggered_by_merge_request?
+ pipeline.merge_request.present(current_user: current_user)
+ end
+ end
+ end
- @merge_request_presenter ||= pipeline.merge_request.present(current_user: current_user)
+ def all_related_merge_request_links
+ all_related_merge_requests.map do |merge_request|
+ mr_path = project_merge_request_path(merge_request.project, merge_request)
+
+ link_to "#{merge_request.to_reference} #{merge_request.title}", mr_path, class: 'mr-iid'
+ end
+ end
+
+ def all_related_merge_requests
+ strong_memoize(:all_related_merge_requests) do
+ pipeline.ref ? pipeline.all_merge_requests_by_recency.to_a : []
+ end
end
end
end
diff --git a/app/presenters/commit_presenter.rb b/app/presenters/commit_presenter.rb
index fc9853733c1..94fc8ac8e39 100644
--- a/app/presenters/commit_presenter.rb
+++ b/app/presenters/commit_presenter.rb
@@ -6,14 +6,29 @@ class CommitPresenter < Gitlab::View::Presenter::Delegated
presents :commit
def status_for(ref)
- can?(current_user, :read_commit_status, commit.project) && commit.status(ref)
+ return unless can?(current_user, :read_commit_status, commit.project)
+
+ commit.latest_pipeline(ref)&.detailed_status(current_user)
end
def any_pipelines?
- can?(current_user, :read_pipeline, commit.project) && commit.pipelines.any?
+ return false unless can?(current_user, :read_pipeline, commit.project)
+
+ commit.pipelines.any?
end
def web_url
Gitlab::UrlBuilder.new(commit).url
end
+
+ def signature_html
+ return unless commit.has_signature?
+
+ ApplicationController.renderer.render(
+ 'projects/commit/_signature',
+ locals: { signature: commit.signature },
+ layout: false,
+ formats: [:html]
+ )
+ end
end
diff --git a/app/presenters/issue_presenter.rb b/app/presenters/issue_presenter.rb
index c9dc0dbf443..3d55b00ac3b 100644
--- a/app/presenters/issue_presenter.rb
+++ b/app/presenters/issue_presenter.rb
@@ -11,6 +11,10 @@ class IssuePresenter < Gitlab::View::Presenter::Delegated
url_builder.issue_path(issue)
end
+ def subscribed?
+ issue.subscribed?(current_user, issue.project)
+ end
+
private
def url_builder
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index 6c300cd8be1..6d370f6241c 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -51,7 +51,8 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
new_file_anchor_data,
readme_anchor_data,
changelog_anchor_data,
- contribution_guide_anchor_data
+ contribution_guide_anchor_data,
+ gitlab_ci_anchor_data
].compact.reject { |item| item.is_link }
end
diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb
index 6f8c4e1f902..9bb7fe13593 100644
--- a/app/presenters/projects/settings/deploy_keys_presenter.rb
+++ b/app/presenters/projects/settings/deploy_keys_presenter.rb
@@ -40,7 +40,7 @@ module Projects
def as_json
serializer = DeployKeySerializer.new # rubocop: disable CodeReuse/Serializer
- opts = { user: current_user }
+ opts = { user: current_user, project: project }
{
enabled_keys: serializer.represent(enabled_keys.with_projects, opts),
diff --git a/app/presenters/todo_presenter.rb b/app/presenters/todo_presenter.rb
new file mode 100644
index 00000000000..b57fc712c5a
--- /dev/null
+++ b/app/presenters/todo_presenter.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class TodoPresenter < Gitlab::View::Presenter::Delegated
+ include GlobalID::Identification
+
+ presents :todo
+end
diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb
index 0c754157267..480a8cab6ff 100644
--- a/app/serializers/build_details_entity.rb
+++ b/app/serializers/build_details_entity.rb
@@ -121,4 +121,28 @@ class BuildDetailsEntity < JobEntity
def can_admin_build?
can?(request.current_user, :admin_build, project)
end
+
+ def callout_message
+ return super unless build.failure_reason.to_sym == :missing_dependency_failure
+
+ docs_url = "https://docs.gitlab.com/ce/ci/yaml/README.html#dependencies"
+
+ [
+ failure_message.html_safe,
+ help_message(docs_url).html_safe
+ ].join("<br />")
+ end
+
+ def invalid_dependencies
+ build.invalid_dependencies.map(&:name).join(', ')
+ end
+
+ def failure_message
+ _("This job depends on other jobs with expired/erased artifacts: %{invalid_dependencies}") %
+ { invalid_dependencies: invalid_dependencies }
+ end
+
+ def help_message(docs_url)
+ _("Please refer to <a href=\"%{docs_url}\">%{docs_url}</a>") % { docs_url: docs_url }
+ end
end
diff --git a/app/serializers/build_trace_entity.rb b/app/serializers/build_trace_entity.rb
new file mode 100644
index 00000000000..b5bac8a5d64
--- /dev/null
+++ b/app/serializers/build_trace_entity.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class BuildTraceEntity < Grape::Entity
+ expose :build_id, as: :id
+ expose :build_status, as: :status
+ expose :build_complete?, as: :complete
+
+ expose :state
+ expose :append
+ expose :truncated
+ expose :offset
+ expose :size
+ expose :total
+
+ expose :json_lines, as: :lines, if: ->(*) { object.json? }
+ expose :html_lines, as: :html, if: ->(*) { object.html? }
+end
diff --git a/app/serializers/build_trace_serializer.rb b/app/serializers/build_trace_serializer.rb
new file mode 100644
index 00000000000..c95158f10a4
--- /dev/null
+++ b/app/serializers/build_trace_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class BuildTraceSerializer < BaseSerializer
+ entity BuildTraceEntity
+end
diff --git a/app/serializers/commit_entity.rb b/app/serializers/commit_entity.rb
index a94e32478ce..ae3f1c6bbf5 100644
--- a/app/serializers/commit_entity.rb
+++ b/app/serializers/commit_entity.rb
@@ -35,8 +35,8 @@ class CommitEntity < API::Entities::Commit
pipeline_project = options[:pipeline_project] || commit.project
next unless pipeline_ref && pipeline_project
- status = commit.status_for_project(pipeline_ref, pipeline_project)
- next unless status
+ pipeline = commit.latest_pipeline_for_project(pipeline_ref, pipeline_project)
+ next unless pipeline&.status
pipelines_project_commit_path(pipeline_project, commit.id, ref: pipeline_ref)
end
diff --git a/app/serializers/container_repository_entity.rb b/app/serializers/container_repository_entity.rb
index cc746698a05..db9cf1c7835 100644
--- a/app/serializers/container_repository_entity.rb
+++ b/app/serializers/container_repository_entity.rb
@@ -18,7 +18,7 @@ class ContainerRepositoryEntity < Grape::Entity
alias_method :repository, :object
def project
- request.project
+ request.respond_to?(:project) ? request.project : object.project
end
def can_destroy?
diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb
index e47d6454780..9a558d12bec 100644
--- a/app/serializers/deploy_key_entity.rb
+++ b/app/serializers/deploy_key_entity.rb
@@ -20,6 +20,7 @@ class DeployKeyEntity < Grape::Entity
private
def can_edit
- Ability.allowed?(options[:user], :update_deploy_key, object)
+ Ability.allowed?(options[:user], :update_deploy_key, object) ||
+ Ability.allowed?(options[:user], :update_deploy_keys_project, object.deploy_keys_project_for(options[:project]))
end
end
diff --git a/app/serializers/diff_file_metadata_entity.rb b/app/serializers/diff_file_metadata_entity.rb
new file mode 100644
index 00000000000..500a844b170
--- /dev/null
+++ b/app/serializers/diff_file_metadata_entity.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class DiffFileMetadataEntity < Grape::Entity
+ expose :added_lines
+ expose :removed_lines
+ expose :new_path
+ expose :old_path
+ expose :new_file?, as: :new_file
+ expose :deleted_file?, as: :deleted_file
+end
diff --git a/app/serializers/diffs_entity.rb b/app/serializers/diffs_entity.rb
index 1763fe5b6ab..19875a1287c 100644
--- a/app/serializers/diffs_entity.rb
+++ b/app/serializers/diffs_entity.rb
@@ -53,7 +53,7 @@ class DiffsEntity < Grape::Entity
# rubocop: enable CodeReuse/ActiveRecord
expose :render_overflow_warning do |diffs|
- render_overflow_warning?(diffs.diff_files)
+ render_overflow_warning?(diffs)
end
expose :email_patch_path, if: -> (*) { merge_request } do |diffs|
diff --git a/app/serializers/diffs_metadata_entity.rb b/app/serializers/diffs_metadata_entity.rb
new file mode 100644
index 00000000000..c82c686e8ef
--- /dev/null
+++ b/app/serializers/diffs_metadata_entity.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class DiffsMetadataEntity < DiffsEntity
+ unexpose :diff_files
+ expose :diff_files, using: DiffFileMetadataEntity
+end
diff --git a/app/serializers/diffs_metadata_serializer.rb b/app/serializers/diffs_metadata_serializer.rb
new file mode 100644
index 00000000000..9f3ba7b778d
--- /dev/null
+++ b/app/serializers/diffs_metadata_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class DiffsMetadataSerializer < BaseSerializer
+ entity DiffsMetadataEntity
+end
diff --git a/app/serializers/evidences/evidence_entity.rb b/app/serializers/evidences/evidence_entity.rb
new file mode 100644
index 00000000000..9689ae10895
--- /dev/null
+++ b/app/serializers/evidences/evidence_entity.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Evidences
+ class EvidenceEntity < Grape::Entity
+ expose :release, using: Evidences::ReleaseEntity
+ end
+end
diff --git a/app/serializers/evidences/evidence_serializer.rb b/app/serializers/evidences/evidence_serializer.rb
new file mode 100644
index 00000000000..d03032bc65c
--- /dev/null
+++ b/app/serializers/evidences/evidence_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Evidences
+ class EvidenceSerializer < BaseSerializer
+ entity EvidenceEntity
+ end
+end
diff --git a/app/serializers/evidences/issue_entity.rb b/app/serializers/evidences/issue_entity.rb
new file mode 100644
index 00000000000..2f1f5dc3d18
--- /dev/null
+++ b/app/serializers/evidences/issue_entity.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Evidences
+ class IssueEntity < Grape::Entity
+ expose :id
+ expose :title
+ expose :description
+ expose :state
+ expose :iid
+ expose :confidential
+ expose :created_at
+ expose :due_date
+ end
+end
diff --git a/app/serializers/evidences/milestone_entity.rb b/app/serializers/evidences/milestone_entity.rb
new file mode 100644
index 00000000000..eeb3d58d4c7
--- /dev/null
+++ b/app/serializers/evidences/milestone_entity.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Evidences
+ class MilestoneEntity < Grape::Entity
+ expose :id
+ expose :title
+ expose :description
+ expose :state
+ expose :iid
+ expose :created_at
+ expose :due_date
+ expose :issues, using: Evidences::IssueEntity
+ end
+end
diff --git a/app/serializers/evidences/project_entity.rb b/app/serializers/evidences/project_entity.rb
new file mode 100644
index 00000000000..2a859c2afdc
--- /dev/null
+++ b/app/serializers/evidences/project_entity.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Evidences
+ class ProjectEntity < Grape::Entity
+ expose :id
+ expose :name
+ expose :description
+ expose :created_at
+ end
+end
diff --git a/app/serializers/evidences/release_entity.rb b/app/serializers/evidences/release_entity.rb
new file mode 100644
index 00000000000..59e379a3c08
--- /dev/null
+++ b/app/serializers/evidences/release_entity.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Evidences
+ class ReleaseEntity < Grape::Entity
+ expose :id
+ expose :tag, as: :tag_name
+ expose :name
+ expose :description
+ expose :created_at
+ expose :project, using: Evidences::ProjectEntity
+ expose :milestones, using: Evidences::MilestoneEntity
+ end
+end
diff --git a/app/serializers/evidences/release_serializer.rb b/app/serializers/evidences/release_serializer.rb
new file mode 100644
index 00000000000..35a3bbc2275
--- /dev/null
+++ b/app/serializers/evidences/release_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Evidences
+ class ReleaseSerializer < BaseSerializer
+ entity ReleaseEntity
+ end
+end
diff --git a/app/serializers/paginated_diff_entity.rb b/app/serializers/paginated_diff_entity.rb
new file mode 100644
index 00000000000..622da926c69
--- /dev/null
+++ b/app/serializers/paginated_diff_entity.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+# Serializes diffs with pagination data.
+#
+# Avoid adding more keys to this serializer as processing the
+# diff file serialization is not cheap.
+#
+class PaginatedDiffEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :diff_files do |diffs, options|
+ submodule_links = Gitlab::SubmoduleLinks.new(merge_request.project.repository)
+ DiffFileEntity.represent(diffs.diff_files, options.merge(submodule_links: submodule_links))
+ end
+
+ expose :pagination do
+ expose :current_page
+ expose :next_page
+ expose :total_pages
+ expose :next_page_href do |diffs|
+ next unless next_page
+
+ project = merge_request.target_project
+
+ diffs_batch_namespace_project_json_merge_request_path(
+ namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ id: merge_request.iid,
+ page: next_page,
+ format: :json
+ )
+ end
+ end
+
+ private
+
+ %i[current_page next_page total_pages].each do |method|
+ define_method method do
+ pagination_data[method]
+ end
+ end
+
+ def pagination_data
+ options.fetch(:pagination_data, {})
+ end
+
+ def merge_request
+ options[:merge_request]
+ end
+end
diff --git a/app/serializers/paginated_diff_serializer.rb b/app/serializers/paginated_diff_serializer.rb
new file mode 100644
index 00000000000..9b40fbf7843
--- /dev/null
+++ b/app/serializers/paginated_diff_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class PaginatedDiffSerializer < BaseSerializer
+ entity PaginatedDiffEntity
+end
diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb
index 808e87c3fcf..71589ac8315 100644
--- a/app/serializers/pipeline_details_entity.rb
+++ b/app/serializers/pipeline_details_entity.rb
@@ -10,6 +10,7 @@ class PipelineDetailsEntity < PipelineEntity
expose :manual_actions, using: BuildActionEntity
expose :scheduled_actions, using: BuildActionEntity
end
-end
-PipelineDetailsEntity.prepend_if_ee('EE::PipelineDetailsEntity')
+ expose :triggered_by_pipeline, as: :triggered_by, with: TriggeredPipelineEntity
+ expose :triggered_pipelines, as: :triggered, using: TriggeredPipelineEntity
+end
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index eaaeaf040a2..fc3160e3c69 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -54,9 +54,9 @@ class PipelineSerializer < BaseSerializer
artifacts: {
project: [:route, { namespace: :route }]
}
- }
+ },
+ { triggered_by_pipeline: [:project, :user] },
+ { triggered_pipelines: [:project, :user] }
]
end
end
-
-PipelineSerializer.prepend_if_ee('EE::PipelineSerializer')
diff --git a/app/serializers/projects/serverless/service_entity.rb b/app/serializers/projects/serverless/service_entity.rb
index a46f8af1466..a1e0bf02d11 100644
--- a/app/serializers/projects/serverless/service_entity.rb
+++ b/app/serializers/projects/serverless/service_entity.rb
@@ -44,7 +44,7 @@ module Projects
end
expose :url do |service|
- "http://#{service.dig('status', 'domain')}"
+ service.dig('status', 'url') || "http://#{service.dig('status', 'domain')}"
end
expose :description do |service|
diff --git a/app/serializers/test_report_entity.rb b/app/serializers/test_report_entity.rb
new file mode 100644
index 00000000000..9eb487da60a
--- /dev/null
+++ b/app/serializers/test_report_entity.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class TestReportEntity < Grape::Entity
+ expose :total_time
+ expose :total_count
+
+ expose :success_count
+ expose :failed_count
+ expose :skipped_count
+ expose :error_count
+
+ expose :test_suites, using: TestSuiteEntity do |report|
+ report.test_suites.values
+ end
+end
diff --git a/app/serializers/test_report_serializer.rb b/app/serializers/test_report_serializer.rb
new file mode 100644
index 00000000000..8906b09bc6a
--- /dev/null
+++ b/app/serializers/test_report_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class TestReportSerializer < BaseSerializer
+ entity TestReportEntity
+end
diff --git a/app/serializers/test_suite_entity.rb b/app/serializers/test_suite_entity.rb
new file mode 100644
index 00000000000..0f88a496c77
--- /dev/null
+++ b/app/serializers/test_suite_entity.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class TestSuiteEntity < Grape::Entity
+ expose :name
+ expose :total_time
+ expose :total_count
+
+ expose :success_count
+ expose :failed_count
+ expose :skipped_count
+ expose :error_count
+
+ expose :test_cases, using: TestCaseEntity do |test_suite|
+ test_suite.test_cases.values.flat_map(&:values)
+ end
+end
diff --git a/app/serializers/triggered_pipeline_entity.rb b/app/serializers/triggered_pipeline_entity.rb
new file mode 100644
index 00000000000..fd7e4454abf
--- /dev/null
+++ b/app/serializers/triggered_pipeline_entity.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+class TriggeredPipelineEntity < Grape::Entity
+ include RequestAwareEntity
+
+ MAX_EXPAND_DEPTH = 3
+
+ expose :id
+ expose :user, using: UserEntity
+ expose :active?, as: :active
+ expose :coverage
+ expose :source
+
+ expose :path do |pipeline|
+ project_pipeline_path(pipeline.project, pipeline)
+ end
+
+ expose :details do
+ expose :detailed_status, as: :status, with: DetailedStatusEntity
+
+ expose :ordered_stages,
+ as: :stages, using: StageEntity,
+ if: -> (_, opts) { can_read_details? && expand?(opts) }
+ end
+
+ expose :triggered_by_pipeline,
+ as: :triggered_by, with: TriggeredPipelineEntity,
+ if: -> (_, opts) { can_read_details? && expand_for_path?(opts) }
+
+ expose :triggered_pipelines,
+ as: :triggered, using: TriggeredPipelineEntity,
+ if: -> (_, opts) { can_read_details? && expand_for_path?(opts) }
+
+ expose :project, using: ProjectEntity
+
+ private
+
+ alias_method :pipeline, :object
+
+ def can_read_details?
+ can?(request.current_user, :read_pipeline, pipeline)
+ end
+
+ def detailed_status
+ pipeline.detailed_status(request.current_user)
+ end
+
+ def expand?(opts)
+ opts[:expanded].to_a.include?(pipeline.id)
+ end
+
+ def expand_for_path?(opts)
+ # The `opts[:attr_path]` holds a list of all `exposes` in path
+ # The check ensures that we always expand only `triggered_by`, `triggered_by`, ...
+ # but not the `triggered_by`, `triggered` which would result in dead loop
+ attr_path = opts[:attr_path]
+ current_expose = attr_path.last
+
+ # We expand at most to depth of MAX_DEPTH
+ # We ensure that we expand in one direction: triggered_by,... or triggered, ...
+ attr_path.length < MAX_EXPAND_DEPTH &&
+ attr_path.all?(current_expose) &&
+ expand?(opts)
+ end
+end
diff --git a/app/services/application_settings/update_service.rb b/app/services/application_settings/update_service.rb
index 6400b182715..df9217bea32 100644
--- a/app/services/application_settings/update_service.rb
+++ b/app/services/application_settings/update_service.rb
@@ -20,7 +20,11 @@ module ApplicationSettings
add_to_outbound_local_requests_whitelist(@params.delete(:add_to_outbound_local_requests_whitelist))
if params.key?(:performance_bar_allowed_group_path)
- params[:performance_bar_allowed_group_id] = performance_bar_allowed_group_id
+ group_id = process_performance_bar_allowed_group_id
+
+ return false if application_setting.errors.any?
+
+ params[:performance_bar_allowed_group_id] = group_id
end
if usage_stats_updated? && !params.delete(:skip_usage_stats_user)
@@ -65,12 +69,27 @@ module ApplicationSettings
@application_setting.reset_memoized_terms
end
- def performance_bar_allowed_group_id
- performance_bar_enabled = !params.key?(:performance_bar_enabled) || params.delete(:performance_bar_enabled)
+ def process_performance_bar_allowed_group_id
group_full_path = params.delete(:performance_bar_allowed_group_path)
- return unless Gitlab::Utils.to_boolean(performance_bar_enabled)
+ enable_param_on = Gitlab::Utils.to_boolean(params.delete(:performance_bar_enabled))
+ performance_bar_enabled = enable_param_on.nil? || enable_param_on # Default to true
+
+ return if group_full_path.blank?
+ return if enable_param_on == false # Explicitly disabling
+
+ unless performance_bar_enabled
+ application_setting.errors.add(:performance_bar_allowed_group_id, 'not allowed when performance bar is disabled')
+ return
+ end
+
+ group = Group.find_by_full_path(group_full_path.chomp('/'))
+
+ unless group
+ application_setting.errors.add(:performance_bar_allowed_group_id, 'not found')
+ return
+ end
- Group.find_by_full_path(group_full_path)&.id if group_full_path.present?
+ group.id
end
def bypass_external_auth?
diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb
index 10eb1141f59..37a74cd1b00 100644
--- a/app/services/boards/issues/list_service.rb
+++ b/app/services/boards/issues/list_service.rb
@@ -11,9 +11,11 @@ module Boards
# rubocop: disable CodeReuse/ActiveRecord
def metadata
+ issues = Issue.arel_table
keys = metadata_fields.keys
- columns = metadata_fields.values_at(*keys).join(', ')
- results = Issue.where(id: fetch_issues.select('issues.id')).pluck(columns)
+ # TODO: eliminate need for SQL literal fragment
+ columns = Arel.sql(metadata_fields.values_at(*keys).join(', '))
+ results = Issue.where(id: fetch_issues.select(issues[:id])).pluck(columns)
Hash[keys.zip(results.flatten)]
end
diff --git a/app/services/boards/lists/create_service.rb b/app/services/boards/lists/create_service.rb
index eb417ac4f5f..6f9a063cb16 100644
--- a/app/services/boards/lists/create_service.rb
+++ b/app/services/boards/lists/create_service.rb
@@ -43,7 +43,11 @@ module Boards
end
def create_list(board, type, target, position)
- board.lists.create(type => target, list_type: type, position: position)
+ board.lists.create(create_list_attributes(type, target, position))
+ end
+
+ def create_list_attributes(type, target, position)
+ { type => target, list_type: type, position: position }
end
end
end
diff --git a/app/services/boards/lists/list_service.rb b/app/services/boards/lists/list_service.rb
index 3609d9c6283..82cba1b68c4 100644
--- a/app/services/boards/lists/list_service.rb
+++ b/app/services/boards/lists/list_service.rb
@@ -6,7 +6,7 @@ module Boards
def execute(board)
board.lists.create(list_type: :backlog) unless board.lists.backlog.exists?
- board.lists.preload_associations(current_user)
+ board.lists.preload_associations
end
end
end
diff --git a/app/services/boards/lists/update_service.rb b/app/services/boards/lists/update_service.rb
index ad96e42f756..4a463372c82 100644
--- a/app/services/boards/lists/update_service.rb
+++ b/app/services/boards/lists/update_service.rb
@@ -4,16 +4,22 @@ module Boards
module Lists
class UpdateService < Boards::BaseService
def execute(list)
- update_preferences_result = update_preferences(list) if can_read?(list)
- update_position_result = update_position(list) if can_admin?(list)
-
- if update_preferences_result || update_position_result
+ if execute_by_params(list)
success(list: list)
else
error(list.errors.messages, 422)
end
end
+ private
+
+ def execute_by_params(list)
+ update_preferences_result = update_preferences(list) if can_read?(list)
+ update_position_result = update_position(list) if can_admin?(list)
+
+ update_preferences_result || update_position_result
+ end
+
def update_preferences(list)
return unless preferences?
@@ -50,3 +56,5 @@ module Boards
end
end
end
+
+Boards::Lists::UpdateService.prepend_if_ee('EE::Boards::Lists::UpdateService')
diff --git a/app/services/bulk_push_event_payload_service.rb b/app/services/bulk_push_event_payload_service.rb
new file mode 100644
index 00000000000..54157bc23f9
--- /dev/null
+++ b/app/services/bulk_push_event_payload_service.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class BulkPushEventPayloadService
+ def initialize(event, push_data)
+ @event = event
+ @push_data = push_data
+ end
+
+ def execute
+ @event.build_push_event_payload(
+ action: @push_data[:action],
+ commit_count: 0,
+ ref_count: @push_data[:ref_count],
+ ref_type: @push_data[:ref_type]
+ )
+
+ @event.push_event_payload.tap(&:save!)
+ end
+end
diff --git a/app/services/ci/pipeline_trigger_service.rb b/app/services/ci/pipeline_trigger_service.rb
index 0e99f142492..37b9b4c362c 100644
--- a/app/services/ci/pipeline_trigger_service.rb
+++ b/app/services/ci/pipeline_trigger_service.rb
@@ -38,11 +38,34 @@ module Ci
end
def create_pipeline_from_job(job)
- # overridden in EE
+ # this check is to not leak the presence of the project if user cannot read it
+ return unless can?(job.user, :read_project, project)
+
+ return error("400 Job has to be running", 400) unless job.running?
+
+ pipeline = Ci::CreatePipelineService.new(project, job.user, ref: params[:ref])
+ .execute(:pipeline, ignore_skip_ci: true) do |pipeline|
+ source = job.sourced_pipelines.build(
+ source_pipeline: job.pipeline,
+ source_project: job.project,
+ pipeline: pipeline,
+ project: project)
+
+ pipeline.source_pipeline = source
+ pipeline.variables.build(variables)
+ end
+
+ if pipeline.persisted?
+ success(pipeline: pipeline)
+ else
+ error(pipeline.errors.messages, 400)
+ end
end
def job_from_token
- # overridden in EE
+ strong_memoize(:job) do
+ Ci::Build.find_by_token(params[:token].to_s)
+ end
end
def variables
@@ -52,5 +75,3 @@ module Ci
end
end
end
-
-Ci::PipelineTriggerService.prepend_if_ee('EE::Ci::PipelineTriggerService')
diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb
index 3b145a65d79..039670f58c8 100644
--- a/app/services/ci/process_pipeline_service.rb
+++ b/app/services/ci/process_pipeline_service.rb
@@ -2,6 +2,8 @@
module Ci
class ProcessPipelineService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
attr_reader :pipeline
def execute(pipeline, trigger_build_ids = nil)
@@ -33,9 +35,9 @@ module Ci
return unless HasStatus::COMPLETED_STATUSES.include?(current_status)
- created_processables_in_stage_without_needs(index).select do |build|
+ created_processables_in_stage_without_needs(index).find_each.select do |build|
process_build(build, current_status)
- end
+ end.any?
end
def process_builds_with_needs(trigger_build_ids)
@@ -92,6 +94,7 @@ module Ci
def created_processables_in_stage_without_needs(index)
created_processables_without_needs
+ .with_preloads
.for_stage(index)
end
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index 338495ba030..7a5e33c61ba 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -39,9 +39,18 @@ module Ci
.where(name: build.name)
.update_all(retried: true)
- project.builds.create!(Hash[attributes])
+ create_build!(attributes)
end
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ private
+
+ def create_build!(attributes)
+ build = project.builds.new(Hash[attributes])
+ build.deployment = ::Gitlab::Ci::Pipeline::Seed::Deployment.new(build).to_resource
+ build.save!
+ build
+ end
end
end
diff --git a/app/services/clusters/gcp/finalize_creation_service.rb b/app/services/clusters/gcp/finalize_creation_service.rb
index c5cde831964..0aff1bcc8b9 100644
--- a/app/services/clusters/gcp/finalize_creation_service.rb
+++ b/app/services/clusters/gcp/finalize_creation_service.rb
@@ -11,6 +11,7 @@ module Clusters
configure_provider
create_gitlab_service_account!
configure_kubernetes
+ configure_pre_installed_knative if provider.knative_pre_installed?
cluster.save!
rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e
log_service_error(e.class.name, provider.id, e.message)
@@ -48,6 +49,13 @@ module Clusters
token: request_kubernetes_token)
end
+ def configure_pre_installed_knative
+ knative = cluster.build_application_knative(
+ hostname: 'example.com'
+ )
+ knative.make_pre_installed!
+ end
+
def request_kubernetes_token
Clusters::Kubernetes::FetchKubernetesTokenService.new(
kube_client,
diff --git a/app/services/clusters/gcp/provision_service.rb b/app/services/clusters/gcp/provision_service.rb
index 80040511ec2..7dc2d3c32f1 100644
--- a/app/services/clusters/gcp/provision_service.rb
+++ b/app/services/clusters/gcp/provision_service.rb
@@ -3,6 +3,8 @@
module Clusters
module Gcp
class ProvisionService
+ CLOUD_RUN_ADDONS = %i[http_load_balancing istio_config cloud_run_config].freeze
+
attr_reader :provider
def execute(provider)
@@ -22,13 +24,16 @@ module Clusters
private
def get_operation_id
+ enable_addons = provider.cloud_run? ? CLOUD_RUN_ADDONS : []
+
operation = provider.api_client.projects_zones_clusters_create(
provider.gcp_project_id,
provider.zone,
provider.cluster.name,
provider.num_nodes,
machine_type: provider.machine_type,
- legacy_abac: provider.legacy_abac
+ legacy_abac: provider.legacy_abac,
+ enable_addons: enable_addons
)
unless operation.status == 'PENDING' || operation.status == 'RUNNING'
diff --git a/app/services/concerns/git/change_params.rb b/app/services/concerns/git/change_params.rb
new file mode 100644
index 00000000000..32faf805b5e
--- /dev/null
+++ b/app/services/concerns/git/change_params.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Git
+ module ChangeParams
+ private
+
+ %i[oldrev newrev ref].each do |method|
+ define_method method do
+ change[method]
+ end
+ end
+
+ def change
+ @change ||= params.fetch(:change, {})
+ end
+ end
+end
diff --git a/app/services/deployments/after_create_service.rb b/app/services/deployments/after_create_service.rb
new file mode 100644
index 00000000000..2572802e6a1
--- /dev/null
+++ b/app/services/deployments/after_create_service.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module Deployments
+ class AfterCreateService
+ attr_reader :deployment
+ attr_reader :deployable
+
+ delegate :environment, to: :deployment
+ delegate :variables, to: :deployable
+ delegate :options, to: :deployable, allow_nil: true
+
+ def initialize(deployment)
+ @deployment = deployment
+ @deployable = deployment.deployable
+ end
+
+ def execute
+ deployment.create_ref
+ deployment.invalidate_cache
+
+ update_environment(deployment)
+
+ deployment
+ end
+
+ def update_environment(deployment)
+ ActiveRecord::Base.transaction do
+ if (url = expanded_environment_url)
+ environment.external_url = url
+ end
+
+ environment.fire_state_event(action)
+
+ if environment.save && !environment.stopped?
+ deployment.update_merge_request_metrics!
+ end
+ end
+ end
+
+ private
+
+ def environment_options
+ options&.dig(:environment) || {}
+ end
+
+ def expanded_environment_url
+ ExpandVariables.expand(environment_url, -> { variables }) if environment_url
+ end
+
+ def environment_url
+ environment_options[:url]
+ end
+
+ def action
+ environment_options[:action] || 'start'
+ end
+ end
+end
+
+Deployments::AfterCreateService.prepend_if_ee('EE::Deployments::AfterCreateService')
diff --git a/app/services/deployments/create_service.rb b/app/services/deployments/create_service.rb
new file mode 100644
index 00000000000..89e3f7c8b83
--- /dev/null
+++ b/app/services/deployments/create_service.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Deployments
+ class CreateService
+ attr_reader :environment, :current_user, :params
+
+ def initialize(environment, current_user, params)
+ @environment = environment
+ @current_user = current_user
+ @params = params
+ end
+
+ def execute
+ create_deployment.tap do |deployment|
+ AfterCreateService.new(deployment).execute if deployment.persisted?
+ end
+ end
+
+ def create_deployment
+ environment.deployments.create(deployment_attributes)
+ end
+
+ def deployment_attributes
+ # We use explicit parameters here so we never by accident allow parameters
+ # to be set that one should not be able to set (e.g. the row ID).
+ {
+ cluster_id: environment.deployment_platform&.cluster_id,
+ project_id: environment.project_id,
+ environment_id: environment.id,
+ ref: params[:ref],
+ tag: params[:tag],
+ sha: params[:sha],
+ user: current_user,
+ on_stop: params[:on_stop],
+ status: params[:status]
+ }
+ end
+ end
+end
diff --git a/app/services/deployments/update_service.rb b/app/services/deployments/update_service.rb
new file mode 100644
index 00000000000..7c8215d28f2
--- /dev/null
+++ b/app/services/deployments/update_service.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Deployments
+ class UpdateService
+ attr_reader :deployment, :params
+
+ def initialize(deployment, params)
+ @deployment = deployment
+ @params = params
+ end
+
+ def execute
+ deployment.update(status: params[:status])
+ end
+ end
+end
diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb
index 395c5fe09ac..f7282c22a52 100644
--- a/app/services/event_create_service.rb
+++ b/app/services/event_create_service.rb
@@ -73,15 +73,27 @@ class EventCreateService
end
def push(project, current_user, push_data)
+ create_push_event(PushEventPayloadService, project, current_user, push_data)
+ end
+
+ def bulk_push(project, current_user, push_data)
+ create_push_event(BulkPushEventPayloadService, project, current_user, push_data)
+ end
+
+ private
+
+ def create_record_event(record, current_user, status)
+ create_event(record.resource_parent, current_user, status, target_id: record.id, target_type: record.class.name)
+ end
+
+ def create_push_event(service_class, project, current_user, push_data)
# We're using an explicit transaction here so that any errors that may occur
# when creating push payload data will result in the event creation being
# rolled back as well.
event = Event.transaction do
new_event = create_event(project, current_user, Event::PUSHED)
- PushEventPayloadService
- .new(new_event, push_data)
- .execute
+ service_class.new(new_event, push_data).execute
new_event
end
@@ -92,12 +104,6 @@ class EventCreateService
Users::ActivityService.new(current_user, 'push').execute
end
- private
-
- def create_record_event(record, current_user, status)
- create_event(record.resource_parent, current_user, status, target_id: record.id, target_type: record.class.name)
- end
-
def create_event(resource_parent, current_user, status, attributes = {})
attributes.reverse_merge!(
action: status,
diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb
index 35a4d2015fa..0801fd4d03f 100644
--- a/app/services/git/base_hooks_service.rb
+++ b/app/services/git/base_hooks_service.rb
@@ -3,6 +3,7 @@
module Git
class BaseHooksService < ::BaseService
include Gitlab::Utils::StrongMemoize
+ include ChangeParams
# The N most recent commits to process in a single push payload.
PROCESS_COMMIT_LIMIT = 100
@@ -15,8 +16,6 @@ module Git
# Not a hook, but it needs access to the list of changed commits
enqueue_invalidate_cache
- update_remote_mirrors
-
success
end
@@ -49,6 +48,8 @@ module Git
# Push events in the activity feed only show information for the
# last commit.
def create_events
+ return unless params.fetch(:create_push_event, true)
+
EventCreateService.new.push(project, current_user, event_push_data)
end
@@ -63,6 +64,8 @@ module Git
end
def execute_project_hooks
+ return unless params.fetch(:execute_project_hooks, true)
+
# Creating push_data invokes one CommitDelta RPC per commit. Only
# build this data if we actually need it.
project.execute_hooks(push_data, hook_name) if project.has_active_hooks?(hook_name)
@@ -79,20 +82,20 @@ module Git
def pipeline_params
{
- before: params[:oldrev],
- after: params[:newrev],
- ref: params[:ref],
+ before: oldrev,
+ after: newrev,
+ ref: ref,
push_options: params[:push_options] || {},
checkout_sha: Gitlab::DataBuilder::Push.checkout_sha(
- project.repository, params[:newrev], params[:ref])
+ project.repository, newrev, ref)
}
end
def push_data_params(commits:, with_changed_files: true)
{
- oldrev: params[:oldrev],
- newrev: params[:newrev],
- ref: params[:ref],
+ oldrev: oldrev,
+ newrev: newrev,
+ ref: ref,
project: project,
user: current_user,
commits: commits,
@@ -121,13 +124,6 @@ module Git
{}
end
- def update_remote_mirrors
- return unless project.has_remote_mirror?
-
- project.mark_stuck_remote_mirrors_as_failed!
- project.update_remote_mirrors
- end
-
def log_pipeline_errors(exception)
data = {
class: self.class.name,
diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb
index c633cff2822..69f1f9eb31f 100644
--- a/app/services/git/branch_hooks_service.rb
+++ b/app/services/git/branch_hooks_service.rb
@@ -20,15 +20,15 @@ module Git
strong_memoize(:commits) do
if creating_default_branch?
# The most recent PROCESS_COMMIT_LIMIT commits in the default branch
- project.repository.commits(params[:newrev], limit: PROCESS_COMMIT_LIMIT)
+ project.repository.commits(newrev, limit: PROCESS_COMMIT_LIMIT)
elsif creating_branch?
# Use the pushed commits that aren't reachable by the default branch
# as a heuristic. This may include more commits than are actually
# pushed, but that shouldn't matter because we check for existing
# cross-references later.
- project.repository.commits_between(project.default_branch, params[:newrev])
+ project.repository.commits_between(project.default_branch, newrev)
elsif updating_branch?
- project.repository.commits_between(params[:oldrev], params[:newrev])
+ project.repository.commits_between(oldrev, newrev)
else # removing branch
[]
end
@@ -70,7 +70,7 @@ module Git
def branch_update_hooks
# Update the bare repositories info/attributes file using the contents of
# the default branch's .gitattributes file
- project.repository.copy_gitattributes(params[:ref]) if default_branch?
+ project.repository.copy_gitattributes(ref) if default_branch?
end
def branch_change_hooks
@@ -118,7 +118,7 @@ module Git
# https://gitlab.com/gitlab-org/gitlab-foss/issues/59257
def creating_branch?
strong_memoize(:creating_branch) do
- Gitlab::Git.blank_ref?(params[:oldrev]) ||
+ Gitlab::Git.blank_ref?(oldrev) ||
!project.repository.branch_exists?(branch_name)
end
end
@@ -128,7 +128,7 @@ module Git
end
def removing_branch?
- Gitlab::Git.blank_ref?(params[:newrev])
+ Gitlab::Git.blank_ref?(newrev)
end
def creating_default_branch?
@@ -137,7 +137,7 @@ module Git
def count_commits_in_branch
strong_memoize(:count_commits_in_branch) do
- project.repository.commit_count_for_ref(params[:ref])
+ project.repository.commit_count_for_ref(ref)
end
end
@@ -148,7 +148,7 @@ module Git
end
def branch_name
- strong_memoize(:branch_name) { Gitlab::Git.ref_name(params[:ref]) }
+ strong_memoize(:branch_name) { Gitlab::Git.ref_name(ref) }
end
def upstream_commit_ids(commits)
diff --git a/app/services/git/branch_push_service.rb b/app/services/git/branch_push_service.rb
index 49c54e42b7c..da45bcc7eaa 100644
--- a/app/services/git/branch_push_service.rb
+++ b/app/services/git/branch_push_service.rb
@@ -4,6 +4,7 @@ module Git
class BranchPushService < ::BaseService
include Gitlab::Access
include Gitlab::Utils::StrongMemoize
+ include ChangeParams
# This method will be called after each git update
# and only if the provided user and project are present in GitLab.
@@ -19,7 +20,7 @@ module Git
# 6. Checks if the project's main language has changed
#
def execute
- return unless Gitlab::Git.branch_ref?(params[:ref])
+ return unless Gitlab::Git.branch_ref?(ref)
enqueue_update_mrs
enqueue_detect_repository_languages
@@ -38,9 +39,9 @@ module Git
UpdateMergeRequestsWorker.perform_async(
project.id,
current_user.id,
- params[:oldrev],
- params[:newrev],
- params[:ref]
+ oldrev,
+ newrev,
+ ref
)
end
@@ -57,13 +58,6 @@ module Git
Ci::StopEnvironmentsService.new(project, current_user).execute(branch_name)
end
- def update_remote_mirrors
- return unless project.has_remote_mirror?
-
- project.mark_stuck_remote_mirrors_as_failed!
- project.update_remote_mirrors
- end
-
def execute_related_hooks
BranchHooksService.new(project, current_user, params).execute
end
@@ -76,11 +70,11 @@ module Git
end
def removing_branch?
- Gitlab::Git.blank_ref?(params[:newrev])
+ Gitlab::Git.blank_ref?(newrev)
end
def branch_name
- strong_memoize(:branch_name) { Gitlab::Git.ref_name(params[:ref]) }
+ strong_memoize(:branch_name) { Gitlab::Git.ref_name(ref) }
end
def default_branch?
diff --git a/app/services/git/process_ref_changes_service.rb b/app/services/git/process_ref_changes_service.rb
new file mode 100644
index 00000000000..3052bed51bc
--- /dev/null
+++ b/app/services/git/process_ref_changes_service.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+module Git
+ class ProcessRefChangesService < BaseService
+ PIPELINE_PROCESS_LIMIT = 4
+
+ def execute
+ changes = params[:changes]
+
+ process_changes_by_action(:branch, changes.branch_changes)
+ process_changes_by_action(:tag, changes.tag_changes)
+ end
+
+ private
+
+ def process_changes_by_action(ref_type, changes)
+ changes_by_action = group_changes_by_action(changes)
+
+ changes_by_action.each do |action, changes|
+ process_changes(ref_type, action, changes, execute_project_hooks: execute_project_hooks?(changes)) if changes.any?
+ end
+ end
+
+ def group_changes_by_action(changes)
+ changes.group_by do |change|
+ change_action(change)
+ end
+ end
+
+ def change_action(change)
+ return :created if Gitlab::Git.blank_ref?(change[:oldrev])
+ return :removed if Gitlab::Git.blank_ref?(change[:newrev])
+
+ :pushed
+ end
+
+ def execute_project_hooks?(changes)
+ (changes.size <= Gitlab::CurrentSettings.push_event_hooks_limit) || Feature.enabled?(:git_push_execute_all_project_hooks, project)
+ end
+
+ def process_changes(ref_type, action, changes, execute_project_hooks:)
+ push_service_class = push_service_class_for(ref_type)
+
+ create_bulk_push_event = changes.size > Gitlab::CurrentSettings.push_event_activities_limit
+
+ changes.each do |change|
+ push_service_class.new(
+ project,
+ current_user,
+ change: change,
+ push_options: params[:push_options],
+ create_pipelines: change[:index] < PIPELINE_PROCESS_LIMIT || Feature.enabled?(:git_push_create_all_pipelines, project),
+ execute_project_hooks: execute_project_hooks,
+ create_push_event: !create_bulk_push_event
+ ).execute
+ end
+
+ create_bulk_push_event(ref_type, action, changes) if create_bulk_push_event
+ end
+
+ def create_bulk_push_event(ref_type, action, changes)
+ EventCreateService.new.bulk_push(
+ project,
+ current_user,
+ Gitlab::DataBuilder::Push.build_bulk(action: action, ref_type: ref_type, changes: changes)
+ )
+ end
+
+ def push_service_class_for(ref_type)
+ return Git::TagPushService if ref_type == :tag
+
+ Git::BranchPushService
+ end
+ end
+end
diff --git a/app/services/git/tag_hooks_service.rb b/app/services/git/tag_hooks_service.rb
index e5b109c79d6..0e5e1bbc992 100644
--- a/app/services/git/tag_hooks_service.rb
+++ b/app/services/git/tag_hooks_service.rb
@@ -18,12 +18,12 @@ module Git
def tag
strong_memoize(:tag) do
- next if Gitlab::Git.blank_ref?(params[:newrev])
+ next if Gitlab::Git.blank_ref?(newrev)
- tag_name = Gitlab::Git.ref_name(params[:ref])
+ tag_name = Gitlab::Git.ref_name(ref)
tag = project.repository.find_tag(tag_name)
- tag if tag && tag.target == params[:newrev]
+ tag if tag && tag.target == newrev
end
end
diff --git a/app/services/git/tag_push_service.rb b/app/services/git/tag_push_service.rb
index ee4166dccd0..9a266f7d74c 100644
--- a/app/services/git/tag_push_service.rb
+++ b/app/services/git/tag_push_service.rb
@@ -2,8 +2,10 @@
module Git
class TagPushService < ::BaseService
+ include ChangeParams
+
def execute
- return unless Gitlab::Git.tag_ref?(params[:ref])
+ return unless Gitlab::Git.tag_ref?(ref)
project.repository.before_push_tag
TagHooksService.new(project, current_user, params).execute
diff --git a/app/services/grafana/proxy_service.rb b/app/services/grafana/proxy_service.rb
new file mode 100644
index 00000000000..74fcdc750b0
--- /dev/null
+++ b/app/services/grafana/proxy_service.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+# Proxies calls to a Grafana-integrated Prometheus instance
+# through the Grafana proxy API
+
+# This allows us to fetch and render metrics in GitLab from a Prometheus
+# instance for which dashboards are configured in Grafana
+module Grafana
+ class ProxyService < BaseService
+ include ReactiveCaching
+
+ self.reactive_cache_key = ->(service) { service.cache_key }
+ self.reactive_cache_lease_timeout = 30.seconds
+ self.reactive_cache_refresh_interval = 30.seconds
+ self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
+
+ attr_accessor :project, :datasource_id, :proxy_path, :query_params
+
+ # @param project_id [Integer] Project id for which grafana is configured.
+ #
+ # See #initialize for other parameters.
+ def self.from_cache(project_id, datasource_id, proxy_path, query_params)
+ project = Project.find(project_id)
+
+ new(project, datasource_id, proxy_path, query_params)
+ end
+
+ # @param project [Project] Project for which grafana is configured.
+ # @param datasource_id [String] Grafana datasource id for Prometheus instance
+ # @param proxy_path [String] Path to Prometheus endpoint; EX) 'api/v1/query_range'
+ # @param query_params [Hash<String, String>] Supported params: [query, start, end, step]
+ def initialize(project, datasource_id, proxy_path, query_params)
+ @project = project
+ @datasource_id = datasource_id
+ @proxy_path = proxy_path
+ @query_params = query_params
+ end
+
+ def execute
+ return cannot_proxy_response unless client
+
+ with_reactive_cache(*cache_key) { |result| result }
+ end
+
+ def calculate_reactive_cache(*)
+ return cannot_proxy_response unless client
+
+ response = client.proxy_datasource(
+ datasource_id: datasource_id,
+ proxy_path: proxy_path,
+ query: query_params
+ )
+
+ success(http_status: response.code, body: response.body)
+ rescue ::Grafana::Client::Error => error
+ service_unavailable_response(error)
+ end
+
+ # Required for ReactiveCaching; Usage overridden by
+ # self.reactive_cache_worker_finder
+ def id
+ nil
+ end
+
+ def cache_key
+ [project.id, datasource_id, proxy_path, query_params]
+ end
+
+ private
+
+ def client
+ project.grafana_integration&.client
+ end
+
+ def service_unavailable_response(exception)
+ error(exception.message, :service_unavailable)
+ end
+
+ def cannot_proxy_response
+ error('Proxy support for this API is not available currently')
+ end
+ end
+end
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index 61bd50616b8..8cc31200689 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -9,6 +9,7 @@ module Groups
def execute
remove_unallowed_params
+ set_visibility_level
@group = Group.new(params)
@@ -68,6 +69,12 @@ module Groups
true
end
+
+ def set_visibility_level
+ return if visibility_level.present?
+
+ params[:visibility_level] = Gitlab::CurrentSettings.current_application_settings.default_group_visibility
+ end
end
end
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index fe7e07ef9f0..6902b7bd529 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -7,7 +7,8 @@ module Groups
namespace_with_same_path: s_('TransferGroup|The parent group already has a subgroup with the same path.'),
group_is_already_root: s_('TransferGroup|Group is already a root group.'),
same_parent_as_current: s_('TransferGroup|Group is already associated to the parent group.'),
- invalid_policies: s_("TransferGroup|You don't have enough permissions.")
+ invalid_policies: s_("TransferGroup|You don't have enough permissions."),
+ group_contains_images: s_('TransferGroup|Cannot update the path because there are projects under this group that contain Docker images in their Container Registry. Please remove the images from your projects first and try again.')
}.freeze
TransferError = Class.new(StandardError)
@@ -46,6 +47,7 @@ module Groups
raise_transfer_error(:same_parent_as_current) if same_parent?
raise_transfer_error(:invalid_policies) unless valid_policies?
raise_transfer_error(:namespace_with_same_path) if namespace_with_same_path?
+ raise_transfer_error(:group_contains_images) if group_projects_contain_registry_images?
end
def group_is_already_root?
@@ -72,6 +74,10 @@ module Groups
end
# rubocop: enable CodeReuse/ActiveRecord
+ def group_projects_contain_registry_images?
+ @group.has_container_repositories?
+ end
+
def update_group_attributes
if @new_parent_group && @new_parent_group.visibility_level < @group.visibility_level
update_children_and_projects_visibility
diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb
index 534de601e20..be7502a193e 100644
--- a/app/services/groups/update_service.rb
+++ b/app/services/groups/update_service.rb
@@ -8,6 +8,11 @@ module Groups
reject_parent_id!
remove_unallowed_params
+ if renaming_group_with_container_registry_images?
+ group.errors.add(:base, container_images_error)
+ return false
+ end
+
return false unless valid_visibility_level_change?(group, params[:visibility_level])
return false unless valid_share_with_group_lock_change?
@@ -35,6 +40,17 @@ module Groups
# overridden in EE
end
+ def renaming_group_with_container_registry_images?
+ new_path = params[:path]
+
+ new_path && new_path != group.path &&
+ group.has_container_repositories?
+ end
+
+ def container_images_error
+ s_("GroupSettings|Cannot update the path because there are projects under this group that contain Docker images in their Container Registry. Please remove the images from your projects first and try again.")
+ end
+
def after_update
if group.previous_changes.include?(:visibility_level) && group.private?
# don't enqueue immediately to prevent todos removal in case of a mistake
diff --git a/app/services/import_export_clean_up_service.rb b/app/services/import_export_clean_up_service.rb
index 3ecb51b60d0..66ac7dac4ca 100644
--- a/app/services/import_export_clean_up_service.rb
+++ b/app/services/import_export_clean_up_service.rb
@@ -12,16 +12,20 @@ class ImportExportCleanUpService
def execute
Gitlab::Metrics.measure(:import_export_clean_up) do
- clean_up_export_object_files
-
- break unless File.directory?(path)
-
- clean_up_export_files
+ execute_cleanup
end
end
private
+ def execute_cleanup
+ clean_up_export_object_files
+ ensure
+ # We don't want a failure in cleaning up object storage from
+ # blocking us from cleaning up temporary storage.
+ clean_up_export_files if File.directory?(path)
+ end
+
def clean_up_export_files
Gitlab::Popen.popen(%W(find #{path} -not -path #{path} -mmin +#{mmin} -delete))
end
diff --git a/app/services/issuable/clone/content_rewriter.rb b/app/services/issuable/clone/content_rewriter.rb
index f75b51c4be3..67d2f9fd3fe 100644
--- a/app/services/issuable/clone/content_rewriter.rb
+++ b/app/services/issuable/clone/content_rewriter.rb
@@ -39,6 +39,10 @@ module Issuable
if note.system_note_metadata
new_params[:system_note_metadata] = note.system_note_metadata.dup
+
+ # TODO: Implement copying of description versions when an issue is moved
+ # https://gitlab.com/gitlab-org/gitlab/issues/32300
+ new_params[:system_note_metadata].description_version = nil
end
new_note.update(new_params)
diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb
index 805721212ba..14585c2850b 100644
--- a/app/services/issues/close_service.rb
+++ b/app/services/issues/close_service.rb
@@ -4,7 +4,7 @@ module Issues
class CloseService < Issues::BaseService
# Closes the supplied issue if the current user is able to do so.
def execute(issue, commit: nil, notifications: true, system_note: true)
- return issue unless can?(current_user, :update_issue, issue)
+ return issue unless can?(current_user, :update_issue, issue) || issue.is_a?(ExternalIssue)
close_issue(issue,
closed_via: commit,
@@ -18,7 +18,7 @@ module Issues
# The code calling this method is responsible for ensuring that a user is
# allowed to close the given issue.
def close_issue(issue, closed_via: nil, notifications: true, system_note: true)
- if project.jira_tracker? && project.jira_service.active && issue.is_a?(ExternalIssue)
+ if project.jira_tracker_active? && issue.is_a?(ExternalIssue)
project.jira_service.close_issue(closed_via, issue)
todo_service.close_issue(issue, current_user)
return issue
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index dc3c363f650..528b1ea61b3 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -56,7 +56,7 @@ module Issues
handle_milestone_change(issue)
- added_mentions = issue.mentioned_users - old_mentioned_users
+ added_mentions = issue.mentioned_users(current_user) - old_mentioned_users
if added_mentions.present?
notification_service.async.new_mentions_in_issue(issue, added_mentions, current_user)
diff --git a/app/services/issues/zoom_link_service.rb b/app/services/issues/zoom_link_service.rb
index a061ab22875..561c86475e5 100644
--- a/app/services/issues/zoom_link_service.rb
+++ b/app/services/issues/zoom_link_service.rb
@@ -10,6 +10,7 @@ module Issues
def add_link(link)
if can_add_link? && (link = parse_link(link))
+ track_meeting_added_event
success(_('Zoom meeting added'), append_to_description(link))
else
error(_('Failed to add a Zoom meeting'))
@@ -17,11 +18,12 @@ module Issues
end
def can_add_link?
- available? && !link_in_issue_description?
+ can? && !link_in_issue_description?
end
def remove_link
if can_remove_link?
+ track_meeting_removed_event
success(_('Zoom meeting removed'), remove_from_description)
else
error(_('Failed to remove a Zoom meeting'))
@@ -29,7 +31,7 @@ module Issues
end
def can_remove_link?
- available? && link_in_issue_description?
+ can? && link_in_issue_description?
end
def parse_link(link)
@@ -44,6 +46,14 @@ module Issues
issue.description || ''
end
+ def track_meeting_added_event
+ ::Gitlab::Tracking.event('IncidentManagement::ZoomIntegration', 'add_zoom_meeting', label: 'Issue ID', value: issue.id)
+ end
+
+ def track_meeting_removed_event
+ ::Gitlab::Tracking.event('IncidentManagement::ZoomIntegration', 'remove_zoom_meeting', label: 'Issue ID', value: issue.id)
+ end
+
def success(message, description)
ServiceResponse
.success(message: message, payload: { description: description })
@@ -75,14 +85,6 @@ module Issues
issue_description[/(\S+)\z/, 1]
end
- def available?
- feature_enabled? && can?
- end
-
- def feature_enabled?
- Feature.enabled?(:issue_zoom_integration, project)
- end
-
def can?
current_user.can?(:update_issue, project)
end
diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb
index fbe6c48ac28..0364c0dd479 100644
--- a/app/services/merge_requests/post_merge_service.rb
+++ b/app/services/merge_requests/post_merge_service.rb
@@ -29,9 +29,7 @@ module MergeRequests
closed_issues = merge_request.visible_closing_issues_for(current_user)
closed_issues.each do |issue|
- if can?(current_user, :update_issue, issue)
- Issues::CloseService.new(project, current_user, {}).execute(issue, commit: merge_request)
- end
+ Issues::CloseService.new(project, current_user).execute(issue, commit: merge_request)
end
end
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index edcfc3bf33f..b32499629ff 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -25,6 +25,7 @@ module MergeRequests
outdate_suggestions
refresh_pipelines_on_merge_requests
abort_auto_merges
+ abort_ff_merge_requests_with_when_pipeline_succeeds
mark_pending_todos_done
cache_merge_requests_closing_issues
@@ -148,6 +149,31 @@ module MergeRequests
end
end
+ def abort_ff_merge_requests_with_when_pipeline_succeeds
+ return unless @project.ff_merge_must_be_possible?
+
+ requests_with_auto_merge_enabled_to(@push.branch_name).each do |merge_request|
+ next unless merge_request.should_be_rebased?
+
+ abort_auto_merge_with_todo(merge_request, 'target branch was updated')
+ end
+ end
+
+ def abort_auto_merge_with_todo(merge_request, reason)
+ response = abort_auto_merge(merge_request, reason)
+ response = ServiceResponse.new(response)
+ return unless response.success?
+
+ todo_service.merge_request_became_unmergeable(merge_request)
+ end
+
+ def requests_with_auto_merge_enabled_to(target_branch)
+ @project
+ .merge_requests
+ .by_target_branch(target_branch)
+ .with_open_merge_when_pipeline_succeeds
+ end
+
def mark_pending_todos_done
merge_requests_for_source_branch.each do |merge_request|
todo_service.merge_request_push(merge_request, @current_user)
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 4acc3f1981a..ae678d4c036 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -69,7 +69,8 @@ module MergeRequests
)
end
- added_mentions = merge_request.mentioned_users - old_mentioned_users
+ added_mentions = merge_request.mentioned_users(current_user) - old_mentioned_users
+
if added_mentions.present?
notification_service.async.new_mentions_in_merge_request(
merge_request,
diff --git a/app/services/note_summary.rb b/app/services/note_summary.rb
index 60a68568833..6fe14939aaa 100644
--- a/app/services/note_summary.rb
+++ b/app/services/note_summary.rb
@@ -10,6 +10,10 @@ class NoteSummary
project: project, author: author, note: body }
@metadata = { action: action, commit_count: commit_count }.compact
+ if action == 'description' && noteable.saved_description_version
+ @metadata[:description_version] = noteable.saved_description_version
+ end
+
set_commit_params if note[:noteable].is_a?(Commit)
end
diff --git a/app/services/notes/quick_actions_service.rb b/app/services/notes/quick_actions_service.rb
index 076df10bf6f..7e6568b5b25 100644
--- a/app/services/notes/quick_actions_service.rb
+++ b/app/services/notes/quick_actions_service.rb
@@ -50,7 +50,7 @@ module Notes
return if update_params.empty?
return unless supported?(note)
- self.class.noteable_update_service(note).new(note.parent, current_user, update_params).execute(note.noteable)
+ self.class.noteable_update_service(note).new(note.resource_parent, current_user, update_params).execute(note.noteable)
end
end
end
diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb
index 853faed9d85..573be8fbe8b 100644
--- a/app/services/notes/update_service.rb
+++ b/app/services/notes/update_service.rb
@@ -5,7 +5,7 @@ module Notes
def execute(note)
return note unless note.editable?
- old_mentioned_users = note.mentioned_users.to_a
+ old_mentioned_users = note.mentioned_users(current_user).to_a
note.update(params.merge(updated_by: current_user))
diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb
index fca64270cae..9afbb678f5d 100644
--- a/app/services/notification_recipient_service.rb
+++ b/app/services/notification_recipient_service.rb
@@ -28,6 +28,10 @@ module NotificationRecipientService
Builder::ProjectMaintainers.new(*args).notification_recipients
end
+ def self.build_new_release_recipients(*args)
+ Builder::NewRelease.new(*args).notification_recipients
+ end
+
module Builder
class Base
def initialize(*)
@@ -359,6 +363,26 @@ module NotificationRecipientService
end
end
+ class NewRelease < Base
+ attr_reader :target
+
+ def initialize(target)
+ @target = target
+ end
+
+ def build!
+ add_recipients(target.project.authorized_users, :custom, nil)
+ end
+
+ def custom_action
+ :new_release
+ end
+
+ def acting_user
+ target.author
+ end
+ end
+
class MergeRequestUnmergeable < Base
attr_reader :target
def initialize(merge_request)
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index ed357aa0392..b56b2cf14e3 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -289,6 +289,15 @@ class NotificationService
end
end
+ # Notify users when a new release is created
+ def send_new_release_notifications(release)
+ recipients = NotificationRecipientService.build_new_release_recipients(release)
+
+ recipients.each do |recipient|
+ mailer.new_release_email(recipient.user.id, release, recipient.reason).deliver_later
+ end
+ end
+
# Members
def new_access_request(member)
return true unless member.notifiable?(:subscription)
diff --git a/app/services/projects/after_import_service.rb b/app/services/projects/after_import_service.rb
index e30da0f26df..6fc15db9b4c 100644
--- a/app/services/projects/after_import_service.rb
+++ b/app/services/projects/after_import_service.rb
@@ -9,9 +9,16 @@ module Projects
end
def execute
- Projects::HousekeepingService.new(@project).execute do
+ service = Projects::HousekeepingService.new(@project)
+
+ service.execute do
repository.delete_all_refs_except(RESERVED_REF_PREFIXES)
end
+
+ # Right now we don't actually have a way to know if a project
+ # import actually changed, so we increment the counter to avoid
+ # causing GC to run every time.
+ service.increment!
rescue Projects::HousekeepingService::LeaseTaken => e
Rails.logger.info( # rubocop:disable Gitlab/RailsLogger
"Could not perform housekeeping for project #{@project.full_path} (#{@project.id}): #{e}")
diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb
index d1d9b9f22e8..1b880a7aab1 100644
--- a/app/services/projects/container_repository/cleanup_tags_service.rb
+++ b/app/services/projects/container_repository/cleanup_tags_service.rb
@@ -40,7 +40,7 @@ module Projects
return unless tags.count == other_tags.count
# delete all tags
- tags.map(&:delete)
+ tags.map(&:unsafe_delete)
end
def group_by_digest(tags)
diff --git a/app/services/projects/container_repository/delete_tags_service.rb b/app/services/projects/container_repository/delete_tags_service.rb
new file mode 100644
index 00000000000..5129e2269a8
--- /dev/null
+++ b/app/services/projects/container_repository/delete_tags_service.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+module Projects
+ module ContainerRepository
+ class DeleteTagsService < BaseService
+ def execute(container_repository)
+ return error('access denied') unless can?(current_user, :destroy_container_image, project)
+
+ tag_names = params[:tags]
+ return error('not tags specified') if tag_names.blank?
+
+ if can_use?
+ smart_delete(container_repository, tag_names)
+ else
+ unsafe_delete(container_repository, tag_names)
+ end
+ end
+
+ private
+
+ def unsafe_delete(container_repository, tag_names)
+ deleted_tags = tag_names.select do |tag_name|
+ container_repository.tag(tag_name).unsafe_delete
+ end
+
+ return error('could not delete tags') if deleted_tags.empty?
+
+ success(deleted: deleted_tags)
+ end
+
+ # Replace a tag on the registry with a dummy tag.
+ # This is a hack as the registry doesn't support deleting individual
+ # tags. This code effectively pushes a dummy image and assigns the tag to it.
+ # This way when the tag is deleted only the dummy image is affected.
+ # See https://gitlab.com/gitlab-org/gitlab/issues/15737 for a discussion
+ def smart_delete(container_repository, tag_names)
+ # generates the blobs for the dummy image
+ dummy_manifest = container_repository.client.generate_empty_manifest(container_repository.path)
+
+ # update the manifests of the tags with the new dummy image
+ tag_digests = tag_names.map do |name|
+ container_repository.client.put_tag(container_repository.path, name, dummy_manifest)
+ end
+
+ # make sure the digests are the same (it should always be)
+ tag_digests.uniq!
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ Gitlab::Sentry.track_exception(ArgumentError.new('multiple tag digests')) if tag_digests.many?
+
+ # Deletes the dummy image
+ # All created tag digests are the same since they all have the same dummy image.
+ # a single delete is sufficient to remove all tags with it
+ if container_repository.delete_tag_by_digest(tag_digests.first)
+ success(deleted: tag_names)
+ else
+ error('could not delete tags')
+ end
+ end
+
+ def can_use?
+ Feature.enabled?(:container_registry_smart_delete, project, default_enabled: true)
+ end
+ end
+ end
+end
diff --git a/app/services/projects/create_from_template_service.rb b/app/services/projects/create_from_template_service.rb
index 91ece024e13..a207fd2c574 100644
--- a/app/services/projects/create_from_template_service.rb
+++ b/app/services/projects/create_from_template_service.rb
@@ -4,8 +4,11 @@ module Projects
class CreateFromTemplateService < BaseService
include Gitlab::Utils::StrongMemoize
+ attr_reader :template_name
+
def initialize(user, params)
@current_user, @params = user, params.to_h.dup
+ @template_name = @params.delete(:template_name).presence
end
def execute
@@ -21,12 +24,6 @@ module Projects
file&.close
end
- def template_name
- strong_memoize(:template_name) do
- params.delete(:template_name).presence
- end
- end
-
private
def validate_template!
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 728eb039b54..ef06545b27d 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -13,7 +13,7 @@ module Projects
end
def execute
- if @params[:template_name].present?
+ if create_from_template?
return ::Projects::CreateFromTemplateService.new(current_user, params).execute
end
@@ -184,6 +184,10 @@ module Projects
private
+ def create_from_template?
+ @params[:template_name].present? || @params[:template_project_id].present?
+ end
+
def import_schedule
if @project.errors.empty?
@project.import_state.schedule if @project.import? && !@project.bare_repository_import?
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index 5fdf98c3c5e..90e703e7050 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -123,11 +123,9 @@ module Projects
mv_repository(old_path, new_path)
end
- # rubocop: disable CodeReuse/ActiveRecord
def repo_exists?(path)
- gitlab_shell.exists?(project.repository_storage, path + '.git')
+ gitlab_shell.repository_exists?(project.repository_storage, path + '.git')
end
- # rubocop: enable CodeReuse/ActiveRecord
def mv_repository(from_path, to_path)
return true unless repo_exists?(from_path)
diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb
index 17686b45900..47ab7f9a8a0 100644
--- a/app/services/projects/fork_service.rb
+++ b/app/services/projects/fork_service.rb
@@ -43,6 +43,7 @@ module Projects
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
diff --git a/app/services/projects/hashed_storage/base_repository_service.rb b/app/services/projects/hashed_storage/base_repository_service.rb
index f97a28b8c3b..b7e9d3e8791 100644
--- a/app/services/projects/hashed_storage/base_repository_service.rb
+++ b/app/services/projects/hashed_storage/base_repository_service.rb
@@ -20,16 +20,13 @@ module Projects
protected
- # rubocop: disable CodeReuse/ActiveRecord
def has_wiki?
- gitlab_shell.exists?(project.repository_storage, "#{old_wiki_disk_path}.git")
+ gitlab_shell.repository_exists?(project.repository_storage, "#{old_wiki_disk_path}.git")
end
- # rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def move_repository(from_name, to_name)
- from_exists = gitlab_shell.exists?(project.repository_storage, "#{from_name}.git")
- to_exists = gitlab_shell.exists?(project.repository_storage, "#{to_name}.git")
+ from_exists = gitlab_shell.repository_exists?(project.repository_storage, "#{from_name}.git")
+ to_exists = gitlab_shell.repository_exists?(project.repository_storage, "#{to_name}.git")
# If we don't find the repository on either original or target we should log that as it could be an issue if the
# project was not originally empty.
@@ -46,7 +43,6 @@ module Projects
gitlab_shell.mv_repository(project.repository_storage, from_name, to_name)
end
- # rubocop: enable CodeReuse/ActiveRecord
def rollback_folder_move
move_repository(new_disk_path, old_disk_path)
diff --git a/app/services/projects/hashed_storage/migrate_repository_service.rb b/app/services/projects/hashed_storage/migrate_repository_service.rb
index e248a13c702..0a0bd90cd20 100644
--- a/app/services/projects/hashed_storage/migrate_repository_service.rb
+++ b/app/services/projects/hashed_storage/migrate_repository_service.rb
@@ -8,7 +8,6 @@ module Projects
@old_storage_version = project.storage_version
project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository]
- project.ensure_storage_path_exists
@new_disk_path = project.disk_path
diff --git a/app/services/projects/hashed_storage/rollback_repository_service.rb b/app/services/projects/hashed_storage/rollback_repository_service.rb
index 67733f4770b..a705112ebe3 100644
--- a/app/services/projects/hashed_storage/rollback_repository_service.rb
+++ b/app/services/projects/hashed_storage/rollback_repository_service.rb
@@ -8,7 +8,6 @@ module Projects
@old_storage_version = project.storage_version
project.storage_version = nil
- project.ensure_storage_path_exists
@new_disk_path = project.disk_path
diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb
index 9c6d7ef41f6..d3638c57552 100644
--- a/app/services/projects/import_export/export_service.rb
+++ b/app/services/projects/import_export/export_service.rb
@@ -12,6 +12,8 @@ module Projects
private
+ attr_accessor :shared
+
def execute_after_export_action(after_export_strategy)
return unless after_export_strategy
@@ -21,50 +23,54 @@ module Projects
end
def save_all!
- if save_services
- Gitlab::ImportExport::Saver.save(project: project, shared: @shared)
+ if save_exporters
+ Gitlab::ImportExport::Saver.save(project: project, shared: shared)
notify_success
else
cleanup_and_notify_error!
end
end
- def save_services
- [version_saver, avatar_saver, project_tree_saver, uploads_saver, repo_saver, wiki_repo_saver, lfs_saver].all?(&:save)
+ def save_exporters
+ exporters.all?(&:save)
+ end
+
+ def exporters
+ [version_saver, avatar_saver, project_tree_saver, uploads_saver, repo_saver, wiki_repo_saver, lfs_saver]
end
def version_saver
- Gitlab::ImportExport::VersionSaver.new(shared: @shared)
+ Gitlab::ImportExport::VersionSaver.new(shared: shared)
end
def avatar_saver
- Gitlab::ImportExport::AvatarSaver.new(project: project, shared: @shared)
+ Gitlab::ImportExport::AvatarSaver.new(project: project, shared: shared)
end
def project_tree_saver
- Gitlab::ImportExport::ProjectTreeSaver.new(project: project, current_user: @current_user, shared: @shared, params: @params)
+ Gitlab::ImportExport::ProjectTreeSaver.new(project: project, current_user: current_user, shared: shared, params: params)
end
def uploads_saver
- Gitlab::ImportExport::UploadsSaver.new(project: project, shared: @shared)
+ Gitlab::ImportExport::UploadsSaver.new(project: project, shared: shared)
end
def repo_saver
- Gitlab::ImportExport::RepoSaver.new(project: project, shared: @shared)
+ Gitlab::ImportExport::RepoSaver.new(project: project, shared: shared)
end
def wiki_repo_saver
- Gitlab::ImportExport::WikiRepoSaver.new(project: project, shared: @shared)
+ Gitlab::ImportExport::WikiRepoSaver.new(project: project, shared: shared)
end
def lfs_saver
- Gitlab::ImportExport::LfsSaver.new(project: project, shared: @shared)
+ Gitlab::ImportExport::LfsSaver.new(project: project, shared: shared)
end
def cleanup_and_notify_error
- Rails.logger.error("Import/Export - Project #{project.name} with ID: #{project.id} export error - #{@shared.errors.join(', ')}") # rubocop:disable Gitlab/RailsLogger
+ Rails.logger.error("Import/Export - Project #{project.name} with ID: #{project.id} export error - #{shared.errors.join(', ')}") # rubocop:disable Gitlab/RailsLogger
- FileUtils.rm_rf(@shared.export_path)
+ FileUtils.rm_rf(shared.export_path)
notify_error
end
@@ -72,7 +78,7 @@ module Projects
def cleanup_and_notify_error!
cleanup_and_notify_error
- raise Gitlab::ImportExport::Error.new(@shared.errors.join(', '))
+ raise Gitlab::ImportExport::Error.new(shared.errors.to_sentence)
end
def notify_success
@@ -80,8 +86,10 @@ module Projects
end
def notify_error
- notification_service.project_not_exported(@project, @current_user, @shared.errors)
+ notification_service.project_not_exported(project, current_user, shared.errors)
end
end
end
end
+
+Projects::ImportExport::ExportService.prepend_if_ee('EE::Projects::ImportExport::ExportService')
diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb
index dd72c2844c2..64519501ff4 100644
--- a/app/services/projects/operations/update_service.rb
+++ b/app/services/projects/operations/update_service.rb
@@ -12,7 +12,9 @@ module Projects
private
def project_update_params
- error_tracking_params.merge(metrics_setting_params)
+ error_tracking_params
+ .merge(metrics_setting_params)
+ .merge(grafana_integration_params)
end
def metrics_setting_params
@@ -44,6 +46,14 @@ module Projects
}
}
end
+
+ def grafana_integration_params
+ return {} unless attrs = params[:grafana_integration_attributes]
+
+ destroy = attrs[:grafana_url].blank? && attrs[:token].blank?
+
+ { grafana_integration_attributes: attrs.merge(_destroy: destroy) }
+ end
end
end
end
diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index fa7a4f0ed82..e8a87fc4320 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -53,6 +53,7 @@ module Projects
def success
@status.success
+ @project.mark_pages_as_deployed
super
end
diff --git a/app/services/search/snippet_service.rb b/app/services/search/snippet_service.rb
index 7c6c6878400..e686d3bf7c2 100644
--- a/app/services/search/snippet_service.rb
+++ b/app/services/search/snippet_service.rb
@@ -1,17 +1,9 @@
# frozen_string_literal: true
module Search
- class SnippetService
- attr_accessor :current_user, :params
-
- def initialize(user, params)
- @current_user, @params = user, params.dup
- end
-
+ class SnippetService < Search::GlobalService
def execute
- snippets = SnippetsFinder.new(current_user).execute
-
- Gitlab::SnippetSearchResults.new(snippets, params[:search])
+ Gitlab::SnippetSearchResults.new(current_user, params[:search])
end
def scope
diff --git a/app/services/spam_service.rb b/app/services/spam_service.rb
index f2f133dae28..babe69cfdc8 100644
--- a/app/services/spam_service.rb
+++ b/app/services/spam_service.rb
@@ -37,7 +37,8 @@ class SpamService
else
# Otherwise, it goes to Akismet and check if it's a spam. If that's the
# case, it assigns spammable record as "spam" and create a SpamLog record.
- spammable.spam = check(api)
+ possible_spam = check(api)
+ spammable.spam = possible_spam unless spammable.allow_possible_spam?
spammable.spam_log = spam_log
end
end
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index c01094bd689..b3eee01ea7a 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -16,20 +16,9 @@ module SystemNoteService
# existing_commits - Array of Commits added in a previous push
# oldrev - Optional String SHA of a previous Commit
#
- # See new_commit_summary and existing_commit_summary.
- #
# Returns the created Note object
def add_commits(noteable, project, author, new_commits, existing_commits = [], oldrev = nil)
- total_count = new_commits.length + existing_commits.length
- commits_text = "#{total_count} commit".pluralize(total_count)
-
- text_parts = ["added #{commits_text}"]
- text_parts << commits_list(noteable, new_commits, existing_commits, oldrev)
- text_parts << "[Compare with previous version](#{diff_comparison_path(noteable, project, oldrev)})"
-
- body = text_parts.join("\n\n")
-
- create_note(NoteSummary.new(noteable, project, author, body, action: 'commit', commit_count: total_count))
+ ::SystemNotes::CommitService.new(noteable: noteable, project: project, author: author).add_commits(new_commits, existing_commits, oldrev)
end
# Called when a commit was tagged
@@ -41,84 +30,19 @@ module SystemNoteService
#
# Returns the created Note object
def tag_commit(noteable, project, author, tag_name)
- link = url_helpers.project_tag_path(project, id: tag_name)
- body = "tagged commit #{noteable.sha} to [`#{tag_name}`](#{link})"
-
- create_note(NoteSummary.new(noteable, project, author, body, action: 'tag'))
+ ::SystemNotes::CommitService.new(noteable: noteable, project: project, author: author).tag_commit(tag_name)
end
- # Called when the assignee of a Noteable is changed or removed
- #
- # noteable - Noteable object
- # project - Project owning noteable
- # author - User performing the change
- # assignee - User being assigned, or nil
- #
- # Example Note text:
- #
- # "removed assignee"
- #
- # "assigned to @rspeicher"
- #
- # Returns the created Note object
def change_assignee(noteable, project, author, assignee)
- body = assignee.nil? ? 'removed assignee' : "assigned to #{assignee.to_reference}"
-
- create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee'))
+ ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).change_assignee(assignee)
end
- # Called when the assignees of an issuable is changed or removed
- #
- # issuable - Issuable object (responds to assignees)
- # project - Project owning noteable
- # author - User performing the change
- # assignees - Users being assigned, or nil
- #
- # Example Note text:
- #
- # "removed all assignees"
- #
- # "assigned to @user1 additionally to @user2"
- #
- # "assigned to @user1, @user2 and @user3 and unassigned from @user4 and @user5"
- #
- # "assigned to @user1 and @user2"
- #
- # Returns the created Note object
def change_issuable_assignees(issuable, project, author, old_assignees)
- unassigned_users = old_assignees - issuable.assignees
- added_users = issuable.assignees.to_a - old_assignees
- text_parts = []
-
- Gitlab::I18n.with_default_locale do
- text_parts << "assigned to #{added_users.map(&:to_reference).to_sentence}" if added_users.any?
- text_parts << "unassigned #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any?
- end
-
- body = text_parts.join(' and ')
-
- create_note(NoteSummary.new(issuable, project, author, body, action: 'assignee'))
+ ::SystemNotes::IssuablesService.new(noteable: issuable, project: project, author: author).change_issuable_assignees(old_assignees)
end
- # Called when the milestone of a Noteable is changed
- #
- # noteable - Noteable object
- # project - Project owning noteable
- # author - User performing the change
- # milestone - Milestone being assigned, or nil
- #
- # Example Note text:
- #
- # "removed milestone"
- #
- # "changed milestone to 7.11"
- #
- # Returns the created Note object
def change_milestone(noteable, project, author, milestone)
- format = milestone&.group_milestone? ? :name : :iid
- body = milestone.nil? ? 'removed milestone' : "changed milestone to #{milestone.to_reference(project, format: format)}"
-
- create_note(NoteSummary.new(noteable, project, author, body, action: 'milestone'))
+ ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).change_milestone(milestone)
end
# Called when the due_date of a Noteable is changed
@@ -198,28 +122,8 @@ module SystemNoteService
create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking'))
end
- # Called when the status of a Noteable is changed
- #
- # noteable - Noteable object
- # project - Project owning noteable
- # author - User performing the change
- # status - String status
- # source - Mentionable performing the change, or nil
- #
- # Example Note text:
- #
- # "merged"
- #
- # "closed via bc17db76"
- #
- # Returns the created Note object
def change_status(noteable, project, author, status, source = nil)
- body = status.dup
- body << " via #{source.gfm_reference(project)}" if source
-
- action = status == 'reopened' ? 'opened' : status
-
- create_note(NoteSummary.new(noteable, project, author, body, action: action))
+ ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).change_status(status, source)
end
# Called when 'merge when pipeline succeeds' is executed
@@ -302,69 +206,16 @@ module SystemNoteService
note
end
- # Called when the title of a Noteable is changed
- #
- # noteable - Noteable object that responds to `title`
- # project - Project owning noteable
- # author - User performing the change
- # old_title - Previous String title
- #
- # Example Note text:
- #
- # "changed title from **Old** to **New**"
- #
- # Returns the created Note object
def change_title(noteable, project, author, old_title)
- new_title = noteable.title.dup
-
- old_diffs, new_diffs = Gitlab::Diff::InlineDiff.new(old_title, new_title).inline_diffs
-
- marked_old_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(old_title).mark(old_diffs, mode: :deletion)
- marked_new_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(new_title).mark(new_diffs, mode: :addition)
-
- body = "changed title from **#{marked_old_title}** to **#{marked_new_title}**"
-
- create_note(NoteSummary.new(noteable, project, author, body, action: 'title'))
+ ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).change_title(old_title)
end
- # Called when the description of a Noteable is changed
- #
- # noteable - Noteable object that responds to `description`
- # project - Project owning noteable
- # author - User performing the change
- #
- # Example Note text:
- #
- # "changed the description"
- #
- # Returns the created Note object
def change_description(noteable, project, author)
- body = 'changed the description'
-
- create_note(NoteSummary.new(noteable, project, author, body, action: 'description'))
+ ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).change_description
end
- # Called when the confidentiality changes
- #
- # issue - Issue object
- # project - Project owning the issue
- # author - User performing the change
- #
- # Example Note text:
- #
- # "made the issue confidential"
- #
- # Returns the created Note object
def change_issue_confidentiality(issue, project, author)
- if issue.confidential
- body = 'made the issue confidential'
- action = 'confidential'
- else
- body = 'made the issue visible to everyone'
- action = 'visible'
- end
-
- create_note(NoteSummary.new(issue, project, author, body, action: action))
+ ::SystemNotes::IssuablesService.new(noteable: issue, project: project, author: author).change_issue_confidentiality
end
# Called when a branch in Noteable is changed
@@ -433,195 +284,48 @@ module SystemNoteService
create_note(NoteSummary.new(issue, project, author, body, action: 'merge'))
end
- # Called when a Mentionable references a Noteable
- #
- # noteable - Noteable object being referenced
- # mentioner - Mentionable object
- # author - User performing the reference
- #
- # Example Note text:
- #
- # "mentioned in #1"
- #
- # "mentioned in !2"
- #
- # "mentioned in 54f7727c"
- #
- # See cross_reference_note_content.
- #
- # Returns the created Note object
def cross_reference(noteable, mentioner, author)
- return if cross_reference_disallowed?(noteable, mentioner)
-
- gfm_reference = mentioner.gfm_reference(noteable.project || noteable.group)
- body = cross_reference_note_content(gfm_reference)
-
- if noteable.is_a?(ExternalIssue)
- noteable.project.issues_tracker.create_cross_reference_note(noteable, mentioner, author)
- else
- create_note(NoteSummary.new(noteable, noteable.project, author, body, action: 'cross_reference'))
- end
+ ::SystemNotes::IssuablesService.new(noteable: noteable, author: author).cross_reference(mentioner)
end
- # Check if a cross-reference is disallowed
- #
- # This method prevents adding a "mentioned in !1" note on every single commit
- # in a merge request. Additionally, it prevents the creation of references to
- # external issues (which would fail).
- #
- # noteable - Noteable object being referenced
- # mentioner - Mentionable object
- #
- # Returns Boolean
- def cross_reference_disallowed?(noteable, mentioner)
- return true if noteable.is_a?(ExternalIssue) && !noteable.project.jira_tracker_active?
- return false unless mentioner.is_a?(MergeRequest)
- return false unless noteable.is_a?(Commit)
-
- mentioner.commits.include?(noteable)
- end
-
- # Check if a cross reference to a noteable from a mentioner already exists
- #
- # This method is used to prevent multiple notes being created for a mention
- # when a issue is updated, for example. The method also calls notes_for_mentioner
- # to check if the mentioner is a commit, and return matches only on commit hash
- # instead of project + commit, to avoid repeated mentions from forks.
- #
- # noteable - Noteable object being referenced
- # mentioner - Mentionable object
- #
- # Returns Boolean
def cross_reference_exists?(noteable, mentioner)
- notes = noteable.notes.system
- notes_for_mentioner(mentioner, noteable, notes).exists?
- end
-
- # Build an Array of lines detailing each commit added in a merge request
- #
- # new_commits - Array of new Commit objects
- #
- # Returns an Array of Strings
- def new_commit_summary(new_commits)
- new_commits.collect do |commit|
- content_tag('li', "#{commit.short_id} - #{commit.title}")
- end
+ ::SystemNotes::IssuablesService.new(noteable: noteable).cross_reference_exists?(mentioner)
end
- # Called when the status of a Task has changed
- #
- # noteable - Noteable object.
- # project - Project owning noteable
- # author - User performing the change
- # new_task - TaskList::Item object.
- #
- # Example Note text:
- #
- # "marked the task Whatever as completed."
- #
- # Returns the created Note object
def change_task_status(noteable, project, author, new_task)
- status_label = new_task.complete? ? Taskable::COMPLETED : Taskable::INCOMPLETE
- body = "marked the task **#{new_task.source}** as #{status_label}"
-
- create_note(NoteSummary.new(noteable, project, author, body, action: 'task'))
+ ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).change_task_status(new_task)
end
- # Called when noteable has been moved to another project
- #
- # direction - symbol, :to or :from
- # noteable - Noteable object
- # noteable_ref - Referenced noteable
- # author - User performing the move
- #
- # Example Note text:
- #
- # "moved to some_namespace/project_new#11"
- #
- # Returns the created Note object
def noteable_moved(noteable, project, noteable_ref, author, direction:)
- unless [:to, :from].include?(direction)
- raise ArgumentError, "Invalid direction `#{direction}`"
- end
-
- cross_reference = noteable_ref.to_reference(project)
- body = "moved #{direction} #{cross_reference}"
-
- create_note(NoteSummary.new(noteable, project, author, body, action: 'moved'))
+ ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).noteable_moved(noteable_ref, direction)
end
- # Called when a Noteable has been marked as a duplicate of another Issue
- #
- # noteable - Noteable object
- # project - Project owning noteable
- # author - User performing the change
- # canonical_issue - Issue that this is a duplicate of
- #
- # Example Note text:
- #
- # "marked this issue as a duplicate of #1234"
- #
- # "marked this issue as a duplicate of other_project#5678"
- #
- # Returns the created Note object
def mark_duplicate_issue(noteable, project, author, canonical_issue)
- body = "marked this issue as a duplicate of #{canonical_issue.to_reference(project)}"
- create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate'))
+ ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).mark_duplicate_issue(canonical_issue)
end
- # Called when a Noteable has been marked as the canonical Issue of a duplicate
- #
- # noteable - Noteable object
- # project - Project owning noteable
- # author - User performing the change
- # duplicate_issue - Issue that was a duplicate of this
- #
- # Example Note text:
- #
- # "marked #1234 as a duplicate of this issue"
- #
- # "marked other_project#5678 as a duplicate of this issue"
- #
- # Returns the created Note object
def mark_canonical_issue_of_duplicate(noteable, project, author, duplicate_issue)
- body = "marked #{duplicate_issue.to_reference(project)} as a duplicate of this issue"
- create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate'))
+ ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).mark_canonical_issue_of_duplicate(duplicate_issue)
end
def discussion_lock(issuable, author)
- action = issuable.discussion_locked? ? 'locked' : 'unlocked'
- body = "#{action} this #{issuable.class.to_s.titleize.downcase}"
-
- create_note(NoteSummary.new(issuable, issuable.project, author, body, action: action))
+ ::SystemNotes::IssuablesService.new(noteable: issuable, project: issuable.project, author: author).discussion_lock
end
- def cross_reference?(note_text)
- note_text =~ /\A#{cross_reference_note_prefix}/i
+ def cross_reference_disallowed?(noteable, mentioner)
+ ::SystemNotes::IssuablesService.new(noteable: noteable).cross_reference_disallowed?(mentioner)
end
def zoom_link_added(issue, project, author)
- create_note(NoteSummary.new(issue, project, author, _('added a Zoom call to this issue'), action: 'pinned_embed'))
+ ::SystemNotes::ZoomService.new(noteable: issue, project: project, author: author).zoom_link_added
end
def zoom_link_removed(issue, project, author)
- create_note(NoteSummary.new(issue, project, author, _('removed a Zoom call from this issue'), action: 'pinned_embed'))
+ ::SystemNotes::ZoomService.new(noteable: issue, project: project, author: author).zoom_link_removed
end
private
- # rubocop: disable CodeReuse/ActiveRecord
- def notes_for_mentioner(mentioner, noteable, notes)
- if mentioner.is_a?(Commit)
- text = "#{cross_reference_note_prefix}%#{mentioner.to_reference(nil)}"
- notes.where('(note LIKE ? OR note LIKE ?)', text, text.capitalize)
- else
- gfm_reference = mentioner.gfm_reference(noteable.project || noteable.group)
- text = cross_reference_note_content(gfm_reference)
- notes.where(note: [text, text.capitalize])
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def create_note(note_summary)
note = Note.create(note_summary.note.merge(system: true))
note.system_note_metadata = SystemNoteMetadata.new(note_summary.metadata) if note_summary.metadata?
@@ -629,79 +333,10 @@ module SystemNoteService
note
end
- def cross_reference_note_prefix
- 'mentioned in '
- end
-
- def cross_reference_note_content(gfm_reference)
- "#{cross_reference_note_prefix}#{gfm_reference}"
- end
-
- # Builds a list of existing and new commits according to existing_commits and
- # new_commits methods.
- # Returns a String wrapped in `ul` and `li` tags.
- def commits_list(noteable, new_commits, existing_commits, oldrev)
- existing_commit_summary = existing_commit_summary(noteable, existing_commits, oldrev)
- new_commit_summary = new_commit_summary(new_commits).join
-
- content_tag('ul', "#{existing_commit_summary}#{new_commit_summary}".html_safe)
- end
-
- # Build a single line summarizing existing commits being added in a merge
- # request
- #
- # noteable - MergeRequest object
- # existing_commits - Array of existing Commit objects
- # oldrev - Optional String SHA of a previous Commit
- #
- # Examples:
- #
- # "* ea0f8418...2f4426b7 - 24 commits from branch `master`"
- #
- # "* ea0f8418..4188f0ea - 15 commits from branch `fork:master`"
- #
- # "* ea0f8418 - 1 commit from branch `feature`"
- #
- # Returns a newline-terminated String
- def existing_commit_summary(noteable, existing_commits, oldrev = nil)
- return '' if existing_commits.empty?
-
- count = existing_commits.size
-
- commit_ids = if count == 1
- existing_commits.first.short_id
- else
- if oldrev && !Gitlab::Git.blank_ref?(oldrev)
- "#{Commit.truncate_sha(oldrev)}...#{existing_commits.last.short_id}"
- else
- "#{existing_commits.first.short_id}..#{existing_commits.last.short_id}"
- end
- end
-
- commits_text = "#{count} commit".pluralize(count)
-
- branch = noteable.target_branch
- branch = "#{noteable.target_project_namespace}:#{branch}" if noteable.for_fork?
-
- branch_name = content_tag('code', branch)
- content_tag('li', "#{commit_ids} - #{commits_text} from branch #{branch_name}".html_safe)
- end
-
def url_helpers
@url_helpers ||= Gitlab::Routing.url_helpers
end
- def diff_comparison_path(merge_request, project, oldrev)
- diff_id = merge_request.merge_request_diff.id
-
- url_helpers.diffs_project_merge_request_path(
- project,
- merge_request,
- diff_id: diff_id,
- start_sha: oldrev
- )
- end
-
def content_tag(*args)
ActionController::Base.helpers.content_tag(*args)
end
diff --git a/app/services/system_notes/base_service.rb b/app/services/system_notes/base_service.rb
new file mode 100644
index 00000000000..7341a25b133
--- /dev/null
+++ b/app/services/system_notes/base_service.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module SystemNotes
+ class BaseService
+ attr_reader :noteable, :project, :author
+
+ def initialize(noteable: nil, author: nil, project: nil)
+ @noteable = noteable
+ @project = project
+ @author = author
+ end
+
+ protected
+
+ def create_note(note_summary)
+ note = Note.create(note_summary.note.merge(system: true))
+ note.system_note_metadata = SystemNoteMetadata.new(note_summary.metadata) if note_summary.metadata?
+
+ note
+ end
+
+ def content_tag(*args)
+ ActionController::Base.helpers.content_tag(*args)
+ end
+
+ def url_helpers
+ @url_helpers ||= Gitlab::Routing.url_helpers
+ end
+ end
+end
diff --git a/app/services/system_notes/commit_service.rb b/app/services/system_notes/commit_service.rb
new file mode 100644
index 00000000000..11119956e0f
--- /dev/null
+++ b/app/services/system_notes/commit_service.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+module SystemNotes
+ class CommitService < ::SystemNotes::BaseService
+ # Called when commits are added to a Merge Request
+ #
+ # new_commits - Array of Commits added since last push
+ # existing_commits - Array of Commits added in a previous push
+ # oldrev - Optional String SHA of a previous Commit
+ #
+ # See new_commit_summary and existing_commit_summary.
+ #
+ # Returns the created Note object
+ def add_commits(new_commits, existing_commits = [], oldrev = nil)
+ total_count = new_commits.length + existing_commits.length
+ commits_text = "#{total_count} commit".pluralize(total_count)
+
+ text_parts = ["added #{commits_text}"]
+ text_parts << commits_list(noteable, new_commits, existing_commits, oldrev)
+ text_parts << "[Compare with previous version](#{diff_comparison_path(noteable, project, oldrev)})"
+
+ body = text_parts.join("\n\n")
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'commit', commit_count: total_count))
+ end
+
+ # Called when a commit was tagged
+ #
+ # tag_name - The created tag name
+ #
+ # Returns the created Note object
+ def tag_commit(tag_name)
+ link = url_helpers.project_tag_path(project, id: tag_name)
+ body = "tagged commit #{noteable.sha} to [`#{tag_name}`](#{link})"
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'tag'))
+ end
+
+ # Build an Array of lines detailing each commit added in a merge request
+ #
+ # new_commits - Array of new Commit objects
+ #
+ # Returns an Array of Strings
+ def new_commit_summary(new_commits)
+ new_commits.collect do |commit|
+ content_tag('li', "#{commit.short_id} - #{commit.title}")
+ end
+ end
+
+ private
+
+ # Builds a list of existing and new commits according to existing_commits and
+ # new_commits methods.
+ # Returns a String wrapped in `ul` and `li` tags.
+ def commits_list(noteable, new_commits, existing_commits, oldrev)
+ existing_commit_summary = existing_commit_summary(noteable, existing_commits, oldrev)
+ new_commit_summary = new_commit_summary(new_commits).join
+
+ content_tag('ul', "#{existing_commit_summary}#{new_commit_summary}".html_safe)
+ end
+
+ # Build a single line summarizing existing commits being added in a merge
+ # request
+ #
+ # existing_commits - Array of existing Commit objects
+ # oldrev - Optional String SHA of a previous Commit
+ #
+ # Examples:
+ #
+ # "* ea0f8418...2f4426b7 - 24 commits from branch `master`"
+ #
+ # "* ea0f8418..4188f0ea - 15 commits from branch `fork:master`"
+ #
+ # "* ea0f8418 - 1 commit from branch `feature`"
+ #
+ # Returns a newline-terminated String
+ def existing_commit_summary(noteable, existing_commits, oldrev = nil)
+ return '' if existing_commits.empty?
+
+ count = existing_commits.size
+
+ commit_ids = if count == 1
+ existing_commits.first.short_id
+ else
+ if oldrev && !Gitlab::Git.blank_ref?(oldrev)
+ "#{Commit.truncate_sha(oldrev)}...#{existing_commits.last.short_id}"
+ else
+ "#{existing_commits.first.short_id}..#{existing_commits.last.short_id}"
+ end
+ end
+
+ commits_text = "#{count} commit".pluralize(count)
+
+ branch = noteable.target_branch
+ branch = "#{noteable.target_project_namespace}:#{branch}" if noteable.for_fork?
+
+ branch_name = content_tag('code', branch)
+ content_tag('li', "#{commit_ids} - #{commits_text} from branch #{branch_name}".html_safe)
+ end
+
+ def diff_comparison_path(merge_request, project, oldrev)
+ diff_id = merge_request.merge_request_diff.id
+
+ url_helpers.diffs_project_merge_request_path(
+ project,
+ merge_request,
+ diff_id: diff_id,
+ start_sha: oldrev
+ )
+ end
+ end
+end
diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb
new file mode 100644
index 00000000000..6fffd2ed4bf
--- /dev/null
+++ b/app/services/system_notes/issuables_service.rb
@@ -0,0 +1,312 @@
+# frozen_string_literal: true
+
+module SystemNotes
+ class IssuablesService < ::SystemNotes::BaseService
+ # Called when the assignee of a Noteable is changed or removed
+ #
+ # assignee - User being assigned, or nil
+ #
+ # Example Note text:
+ #
+ # "removed assignee"
+ #
+ # "assigned to @rspeicher"
+ #
+ # Returns the created Note object
+ def change_assignee(assignee)
+ body = assignee.nil? ? 'removed assignee' : "assigned to #{assignee.to_reference}"
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee'))
+ end
+
+ # Called when the assignees of an issuable is changed or removed
+ #
+ # assignees - Users being assigned, or nil
+ #
+ # Example Note text:
+ #
+ # "removed all assignees"
+ #
+ # "assigned to @user1 additionally to @user2"
+ #
+ # "assigned to @user1, @user2 and @user3 and unassigned from @user4 and @user5"
+ #
+ # "assigned to @user1 and @user2"
+ #
+ # Returns the created Note object
+ def change_issuable_assignees(old_assignees)
+ unassigned_users = old_assignees - noteable.assignees
+ added_users = noteable.assignees.to_a - old_assignees
+ text_parts = []
+
+ Gitlab::I18n.with_default_locale do
+ text_parts << "assigned to #{added_users.map(&:to_reference).to_sentence}" if added_users.any?
+ text_parts << "unassigned #{unassigned_users.map(&:to_reference).to_sentence}" if unassigned_users.any?
+ end
+
+ body = text_parts.join(' and ')
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'assignee'))
+ end
+
+ # Called when the milestone of a Noteable is changed
+ #
+ # milestone - Milestone being assigned, or nil
+ #
+ # Example Note text:
+ #
+ # "removed milestone"
+ #
+ # "changed milestone to 7.11"
+ #
+ # Returns the created Note object
+ def change_milestone(milestone)
+ format = milestone&.group_milestone? ? :name : :iid
+ body = milestone.nil? ? 'removed milestone' : "changed milestone to #{milestone.to_reference(project, format: format)}"
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'milestone'))
+ end
+
+ # Called when the title of a Noteable is changed
+ #
+ # old_title - Previous String title
+ #
+ # Example Note text:
+ #
+ # "changed title from **Old** to **New**"
+ #
+ # Returns the created Note object
+ def change_title(old_title)
+ new_title = noteable.title.dup
+
+ old_diffs, new_diffs = Gitlab::Diff::InlineDiff.new(old_title, new_title).inline_diffs
+
+ marked_old_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(old_title).mark(old_diffs, mode: :deletion)
+ marked_new_title = Gitlab::Diff::InlineDiffMarkdownMarker.new(new_title).mark(new_diffs, mode: :addition)
+
+ body = "changed title from **#{marked_old_title}** to **#{marked_new_title}**"
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'title'))
+ end
+
+ # Called when the description of a Noteable is changed
+ #
+ # noteable - Noteable object that responds to `description`
+ # project - Project owning noteable
+ # author - User performing the change
+ #
+ # Example Note text:
+ #
+ # "changed the description"
+ #
+ # Returns the created Note object
+ def change_description
+ body = 'changed the description'
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'description'))
+ end
+
+ # Called when a Mentionable references a Noteable
+ #
+ # mentioner - Mentionable object
+ #
+ # Example Note text:
+ #
+ # "mentioned in #1"
+ #
+ # "mentioned in !2"
+ #
+ # "mentioned in 54f7727c"
+ #
+ # See cross_reference_note_content.
+ #
+ # Returns the created Note object
+ def cross_reference(mentioner)
+ return if cross_reference_disallowed?(mentioner)
+
+ gfm_reference = mentioner.gfm_reference(noteable.project || noteable.group)
+ body = cross_reference_note_content(gfm_reference)
+
+ if noteable.is_a?(ExternalIssue)
+ noteable.project.issues_tracker.create_cross_reference_note(noteable, mentioner, author)
+ else
+ create_note(NoteSummary.new(noteable, noteable.project, author, body, action: 'cross_reference'))
+ end
+ end
+
+ # Check if a cross-reference is disallowed
+ #
+ # This method prevents adding a "mentioned in !1" note on every single commit
+ # in a merge request. Additionally, it prevents the creation of references to
+ # external issues (which would fail).
+ #
+ # mentioner - Mentionable object
+ #
+ # Returns Boolean
+ def cross_reference_disallowed?(mentioner)
+ return true if noteable.is_a?(ExternalIssue) && !noteable.project.jira_tracker_active?
+ return false unless mentioner.is_a?(MergeRequest)
+ return false unless noteable.is_a?(Commit)
+
+ mentioner.commits.include?(noteable)
+ end
+
+ # Called when the status of a Task has changed
+ #
+ # new_task - TaskList::Item object.
+ #
+ # Example Note text:
+ #
+ # "marked the task 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}"
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'task'))
+ end
+
+ # Called when noteable has been moved to another project
+ #
+ # noteable_ref - Referenced noteable
+ # direction - symbol, :to or :from
+ #
+ # Example Note text:
+ #
+ # "moved to some_namespace/project_new#11"
+ #
+ # Returns the created Note object
+ def noteable_moved(noteable_ref, direction)
+ unless [:to, :from].include?(direction)
+ raise ArgumentError, "Invalid direction `#{direction}`"
+ end
+
+ cross_reference = noteable_ref.to_reference(project)
+ body = "moved #{direction} #{cross_reference}"
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'moved'))
+ end
+
+ # Called when the confidentiality changes
+ #
+ # Example Note text:
+ #
+ # "made the issue confidential"
+ #
+ # Returns the created Note object
+ def change_issue_confidentiality
+ if noteable.confidential
+ body = 'made the issue confidential'
+ action = 'confidential'
+ else
+ body = 'made the issue visible to everyone'
+ action = 'visible'
+ end
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: action))
+ end
+
+ # Called when the status of a Noteable is changed
+ #
+ # status - String status
+ # source - Mentionable performing the change, or nil
+ #
+ # Example Note text:
+ #
+ # "merged"
+ #
+ # "closed via bc17db76"
+ #
+ # Returns the created Note object
+ def change_status(status, source = nil)
+ body = status.dup
+ body << " via #{source.gfm_reference(project)}" if source
+
+ action = status == 'reopened' ? 'opened' : status
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: action))
+ end
+
+ # Check if a cross reference to a noteable from a mentioner already exists
+ #
+ # This method is used to prevent multiple notes being created for a mention
+ # when a issue is updated, for example. The method also calls notes_for_mentioner
+ # to check if the mentioner is a commit, and return matches only on commit hash
+ # instead of project + commit, to avoid repeated mentions from forks.
+ #
+ # mentioner - Mentionable object
+ #
+ # Returns Boolean
+ def cross_reference_exists?(mentioner)
+ notes = noteable.notes.system
+ notes_for_mentioner(mentioner, noteable, notes).exists?
+ end
+
+ # Called when a Noteable has been marked as a duplicate of another Issue
+ #
+ # canonical_issue - Issue that this is a duplicate of
+ #
+ # Example Note text:
+ #
+ # "marked this issue as a duplicate of #1234"
+ #
+ # "marked this issue as a duplicate of other_project#5678"
+ #
+ # Returns the created Note object
+ def mark_duplicate_issue(canonical_issue)
+ body = "marked this issue as a duplicate of #{canonical_issue.to_reference(project)}"
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate'))
+ end
+
+ # Called when a Noteable has been marked as the canonical Issue of a duplicate
+ #
+ # duplicate_issue - Issue that was a duplicate of this
+ #
+ # Example Note text:
+ #
+ # "marked #1234 as a duplicate of this issue"
+ #
+ # "marked other_project#5678 as a duplicate of this issue"
+ #
+ # Returns the created Note object
+ def mark_canonical_issue_of_duplicate(duplicate_issue)
+ body = "marked #{duplicate_issue.to_reference(project)} as a duplicate of this issue"
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate'))
+ end
+
+ def discussion_lock
+ action = noteable.discussion_locked? ? 'locked' : 'unlocked'
+ body = "#{action} this #{noteable.class.to_s.titleize.downcase}"
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: action))
+ end
+
+ private
+
+ def cross_reference_note_content(gfm_reference)
+ "#{self.class.cross_reference_note_prefix}#{gfm_reference}"
+ end
+
+ def notes_for_mentioner(mentioner, noteable, notes)
+ if mentioner.is_a?(Commit)
+ text = "#{self.class.cross_reference_note_prefix}%#{mentioner.to_reference(nil)}"
+ notes.like_note_or_capitalized_note(text)
+ else
+ gfm_reference = mentioner.gfm_reference(noteable.project || noteable.group)
+ text = cross_reference_note_content(gfm_reference)
+ notes.for_note_or_capitalized_note(text)
+ end
+ end
+
+ def self.cross_reference_note_prefix
+ 'mentioned in '
+ end
+
+ def self.cross_reference?(note_text)
+ note_text =~ /\A#{cross_reference_note_prefix}/i
+ end
+ end
+end
+
+SystemNotes::IssuablesService.prepend_if_ee('::EE::SystemNotes::IssuablesService')
diff --git a/app/services/system_notes/zoom_service.rb b/app/services/system_notes/zoom_service.rb
new file mode 100644
index 00000000000..6cd166d6cb9
--- /dev/null
+++ b/app/services/system_notes/zoom_service.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module SystemNotes
+ class ZoomService < ::SystemNotes::BaseService
+ def zoom_link_added
+ create_note(NoteSummary.new(noteable, project, author, _('added a Zoom call to this issue'), action: 'pinned_embed'))
+ end
+
+ def zoom_link_removed
+ create_note(NoteSummary.new(noteable, project, author, _('removed a Zoom call from this issue'), action: 'pinned_embed'))
+ end
+ end
+end
diff --git a/app/services/update_deployment_service.rb b/app/services/update_deployment_service.rb
deleted file mode 100644
index 730210c611a..00000000000
--- a/app/services/update_deployment_service.rb
+++ /dev/null
@@ -1,57 +0,0 @@
-# frozen_string_literal: true
-
-class UpdateDeploymentService
- attr_reader :deployment
- attr_reader :deployable
-
- delegate :environment, to: :deployment
- delegate :variables, to: :deployable
-
- def initialize(deployment)
- @deployment = deployment
- @deployable = deployment.deployable
- end
-
- def execute
- deployment.create_ref
- deployment.invalidate_cache
-
- ActiveRecord::Base.transaction do
- environment.external_url = expanded_environment_url if
- expanded_environment_url
-
- environment.fire_state_event(action)
-
- break unless environment.save
- break if environment.stopped?
-
- deployment.tap(&:update_merge_request_metrics!)
- end
-
- deployment
- end
-
- private
-
- def environment_options
- @environment_options ||= deployable.options&.dig(:environment) || {}
- end
-
- def expanded_environment_url
- return @expanded_environment_url if defined?(@expanded_environment_url)
- return unless environment_url
-
- @expanded_environment_url =
- ExpandVariables.expand(environment_url, -> { variables })
- end
-
- def environment_url
- environment_options[:url]
- end
-
- def action
- environment_options[:action] || 'start'
- end
-end
-
-UpdateDeploymentService.prepend_if_ee('EE::UpdateDeploymentService')
diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb
index 9af59b0aceb..d42c9dbedf4 100644
--- a/app/uploaders/avatar_uploader.rb
+++ b/app/uploaders/avatar_uploader.rb
@@ -19,7 +19,7 @@ class AvatarUploader < GitlabUploader
end
def absolute_path
- self.class.absolute_path(model.avatar.upload)
+ self.class.absolute_path(upload)
end
private
diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb
index cefcd3d3f5a..7dc211b14e4 100644
--- a/app/uploaders/gitlab_uploader.rb
+++ b/app/uploaders/gitlab_uploader.rb
@@ -99,6 +99,17 @@ class GitlabUploader < CarrierWave::Uploader::Base
end
end
+ # Used to replace an existing upload with another +file+ without modifying stored metadata
+ # Use this method only to repair/replace an existing upload, or to upload to a Geo secondary node
+ #
+ # @param [CarrierWave::SanitizedFile] file that will replace existing upload
+ # @return CarrierWave::SanitizedFile
+ def replace_file_without_saving!(file)
+ raise ArgumentError, 'should be a CarrierWave::SanitizedFile' unless file.is_a? CarrierWave::SanitizedFile
+
+ storage.store!(file)
+ end
+
private
# Designed to be overridden by child uploaders that have a dynamic path
diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb
index f99ad987156..36bde629f9c 100644
--- a/app/uploaders/object_storage.rb
+++ b/app/uploaders/object_storage.rb
@@ -180,10 +180,11 @@ module ObjectStorage
end
def workhorse_authorize(has_length:, maximum_size: nil)
- {
- RemoteObject: workhorse_remote_upload_options(has_length: has_length, maximum_size: maximum_size),
- TempPath: workhorse_local_upload_path
- }.compact
+ if self.object_store_enabled? && self.direct_upload_enabled?
+ { RemoteObject: workhorse_remote_upload_options(has_length: has_length, maximum_size: maximum_size) }
+ else
+ { TempPath: workhorse_local_upload_path }
+ end
end
def workhorse_local_upload_path
diff --git a/app/validators/named_ecdsa_key_validator.rb b/app/validators/named_ecdsa_key_validator.rb
index 42ee02b6ad4..9053f375100 100644
--- a/app/validators/named_ecdsa_key_validator.rb
+++ b/app/validators/named_ecdsa_key_validator.rb
@@ -19,15 +19,13 @@ class NamedEcdsaKeyValidator < ActiveModel::EachValidator
private
- UNNAMED_CURVE = "UNDEF"
-
def explicit_ec?(value)
return false unless value
pkey = OpenSSL::PKey.read(value)
return false unless pkey.is_a?(OpenSSL::PKey::EC)
- pkey.group.curve_name == UNNAMED_CURVE
+ pkey.group.asn1_flag != OpenSSL::PKey::EC::NAMED_CURVE
rescue OpenSSL::PKey::PKeyError
false
end
diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml
index d1de4286ee7..1f5bce19bc6 100644
--- a/app/views/admin/application_settings/_ci_cd.html.haml
+++ b/app/views/admin/application_settings/_ci_cd.html.haml
@@ -34,7 +34,7 @@
= f.number_field :max_artifacts_size, class: 'form-control'
.form-text.text-muted
= _("Set the maximum file size for each job's artifacts")
- = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size')
+ = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size-core-only')
.form-group
= f.label :default_artifacts_expire_in, _('Default artifacts expiration'), class: 'label-bold'
= f.text_field :default_artifacts_expire_in, class: 'form-control'
diff --git a/app/views/admin/application_settings/_influx.html.haml b/app/views/admin/application_settings/_influx.html.haml
index 98c7a9659c3..300b01c6777 100644
--- a/app/views/admin/application_settings/_influx.html.haml
+++ b/app/views/admin/application_settings/_influx.html.haml
@@ -7,7 +7,7 @@
in running SQL queries. These settings require a
= link_to 'restart', help_page_path('administration/restart_gitlab')
to take effect.
- = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/introduction')
+ = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/index')
.form-group
.form-check
= f.check_box :metrics_enabled, class: 'form-check-input'
diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml
index 1282a032f52..b15afb3b806 100644
--- a/app/views/admin/application_settings/_pages.html.haml
+++ b/app/views/admin/application_settings/_pages.html.haml
@@ -30,6 +30,6 @@
= f.check_box :lets_encrypt_terms_of_service_accepted, class: 'form-check-input'
= f.label :lets_encrypt_terms_of_service_accepted, class: 'form-check-label' do
- terms_of_service_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: lets_encrypt_terms_of_service_admin_application_settings_path }
- = _("I have read and agree to the Let's Encrypt %{link_start}Terms of Service%{link_end}").html_safe % { link_start: terms_of_service_link_start, link_end: '</a>'.html_safe }
+ = _("I have read and agree to the Let's Encrypt %{link_start}Terms of Service%{link_end} (PDF)").html_safe % { link_start: terms_of_service_link_start, link_end: '</a>'.html_safe }
= f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_performance.html.haml b/app/views/admin/application_settings/_performance.html.haml
index b52171afc69..6b02521a0f0 100644
--- a/app/views/admin/application_settings/_performance.html.haml
+++ b/app/views/admin/application_settings/_performance.html.haml
@@ -20,5 +20,15 @@
= f.number_field :raw_blob_request_limit, class: 'form-control'
.form-text.text-muted
= _('Highest number of requests per minute for each raw path, default to 300. To disable throttling set to 0.')
+ .form-group
+ = f.label :push_event_hooks_limit, class: 'label-bold'
+ = f.number_field :push_event_hooks_limit, class: 'form-control'
+ .form-text.text-muted
+ = _("Number of changes (branches or tags) in a single push to determine whether webhooks and services will be fired or not. Webhooks and services won't be submitted if it surpasses that value.")
+ .form-group
+ = f.label :push_event_activities_limit, class: 'label-bold'
+ = f.number_field :push_event_activities_limit, class: 'form-control'
+ .form-text.text-muted
+ = _('Number of changes (branches or tags) in a single push to determine whether individual push events or bulk push event will be created. Bulk push event will be created if it surpasses that value.')
= f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_protected_paths.html.haml b/app/views/admin/application_settings/_protected_paths.html.haml
new file mode 100644
index 00000000000..f4d40e10f36
--- /dev/null
+++ b/app/views/admin/application_settings/_protected_paths.html.haml
@@ -0,0 +1,31 @@
+= form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-protected-paths-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ - if omnibus_protected_paths_throttle?
+ .bs-callout.bs-callout-danger
+ - relative_url_link = 'https://docs.gitlab.com/ee/user/admin_area/settings/protected_paths.html#migrate-settings-from-gitlab-123-and-earlier'
+ - relative_url_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: relative_url_link }
+ = _("Omnibus Protected Paths throttle is active. From 12.4, Omnibus throttle is deprecated and will be removed in a future release. Please read the %{relative_url_link_start}Migrating Protected Paths documentation%{relative_url_link_end}.").html_safe % { relative_url_link_start: relative_url_link_start, relative_url_link_end: '</a>'.html_safe }
+
+ .form-group
+ .form-check
+ = f.check_box :throttle_protected_paths_enabled, class: 'form-check-input'
+ = f.label :throttle_protected_paths_enabled, class: 'form-check-label' do
+ = _('Enable protected paths rate limit')
+ %span.form-text.text-muted
+ = _('Helps reduce request volume for protected paths')
+ .form-group
+ = f.label :throttle_protected_paths_requests_per_period, 'Max requests per period per user', class: 'label-bold'
+ = f.number_field :throttle_protected_paths_requests_per_period, class: 'form-control'
+ .form-group
+ = f.label :throttle_protected_paths_period_in_seconds, 'Rate limit period in seconds', class: 'label-bold'
+ = f.number_field :throttle_protected_paths_period_in_seconds, class: 'form-control'
+ .form-group
+ = f.label :protected_paths, class: 'label-bold' do
+ - relative_url_link = 'https://docs.gitlab.com/omnibus/settings/configuration.html#configuring-a-relative-url-for-gitlab'
+ - relative_url_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: relative_url_link }
+ = _('All paths are relative to the GitLab URL. Do not include %{relative_url_link_start}relative URL%{relative_url_link_end}.').html_safe % { relative_url_link_start: relative_url_link_start, relative_url_link_end: '</a>'.html_safe }
+ = f.text_area :protected_paths_raw, placeholder: '/users/sign_in,/users/password', class: 'form-control', rows: 10
+
+ = f.submit 'Save changes', class: 'btn btn-success'
diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml
index e5bcb180445..b97e9a194f3 100644
--- a/app/views/admin/application_settings/_repository_storage.html.haml
+++ b/app/views/admin/application_settings/_repository_storage.html.haml
@@ -3,21 +3,24 @@
%fieldset
.sub-section
+ %h4= _("Hashed repository storage paths")
.form-group
.form-check
= f.check_box :hashed_storage_enabled, class: 'form-check-input qa-hashed-storage-checkbox'
- = f.label :hashed_storage_enabled, class: 'form-check-label' do
- Use hashed storage paths for newly created and renamed projects
+ = f.label :hashed_storage_enabled, _("Use hashed storage"), class: 'label-bold form-check-label'
.form-text.text-muted
- Enable immutable, hash-based paths and repository names to store repositories on disk. This prevents
- repositories from having to be moved or renamed when the Project URL changes and may improve disk I/O performance.
+ = _("Use hashed storage paths for newly created and renamed projects. Enable immutable, hash-based paths and repository names to store repositories on disk. This prevents repositories from having to be moved or renamed when the Project URL changes and may improve disk I/O performance.")
+ .sub-section
+ %h4= _("Storage nodes for new projects")
.form-group
- = f.label :repository_storages, 'Storage paths for new projects', class: 'label-bold'
- = f.select :repository_storages, repository_storages_options_for_select(@application_setting.repository_storages),
- {include_hidden: false}, multiple: true, class: 'form-control'
- .form-text.text-muted
- Manage repository storage paths. Learn more in the
- = succeed "." do
- = link_to "repository storages documentation", help_page_path("administration/repository_storage_paths")
+ .form-text
+ %p.text-secondary
+ = _('Select the configured storaged available for new projects to be placed on.')
+ = link_to icon('question-circle'), help_page_path('administration/repository_storage_paths')
+ .form-check
+ = f.collection_check_boxes :repository_storages, Gitlab.config.repositories.storages, :first, :first, include_hidden: false do |b|
+ = b.check_box class: 'form-check-input'
+ = b.label class: 'label-bold form-check-label'
+ %br
- = f.submit 'Save changes', class: "btn btn-success qa-save-changes-button"
+ = f.submit _('Save changes'), class: "btn btn-success qa-save-changes-button"
diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml
index b60b5d55a1b..31fd12d191e 100644
--- a/app/views/admin/application_settings/_snowplow.html.haml
+++ b/app/views/admin/application_settings/_snowplow.html.haml
@@ -9,7 +9,7 @@
= _('Configure the %{link} integration.').html_safe % { link: link_to('Snowplow', 'https://snowplowanalytics.com/', target: '_blank') }
.settings-content
- = form_for @application_setting, url: integrations_admin_application_settings_path, html: { class: 'fieldset-form' } do |f|
+ = form_for @application_setting, url: integrations_admin_application_settings_path(anchor: 'js-snowplow-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml
index e57ef1ea18f..be5f1f4f9a8 100644
--- a/app/views/admin/application_settings/_visibility_and_access.html.haml
+++ b/app/views/admin/application_settings/_visibility_and_access.html.haml
@@ -53,6 +53,11 @@
= select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control')
%span.form-text.text-muted#clone-protocol-help
= _('Allow only the selected protocols to be used for Git access.')
+ .form-group
+ = f.label :custom_http_clone_url_root, _('Custom Git clone URL for HTTP(S)'), class: 'label-bold'
+ = f.text_field :custom_http_clone_url_root, class: 'form-control', placeholder: 'https://git.example.com', :'aria-describedby' => 'custom_http_clone_url_root_help_block'
+ %span.form-text.text-muted#custom_http_clone_url_root_help_block
+ = _('Replaces the clone URL root.')
- ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type|
- field_name = :"#{type}_key_restriction"
diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml
index 3a4d901ca1d..092834b993c 100644
--- a/app/views/admin/application_settings/network.html.haml
+++ b/app/views/admin/application_settings/network.html.haml
@@ -34,3 +34,16 @@
= _('Allow requests to the local network from hooks and services.')
.settings-content
= render 'outbound'
+
+%section.settings.as-protected-paths.no-animate#js-protected-paths-settings{ class: ('expanded' if expanded_by_default?) }
+ .settings-header
+ %h4
+ = _('Protected Paths')
+ %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded_by_default? ? _('Collapse') : _('Expand')
+ %p
+ = _('Configure paths to be protected by Rack Attack. A web server restart is required after changing these settings.')
+ .settings-content
+ = render 'protected_paths'
+
+= render_if_exists 'admin/application_settings/ee_network_settings'
diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml
index 180066723f1..aca9302aff7 100644
--- a/app/views/admin/applications/show.html.haml
+++ b/app/views/admin/applications/show.html.haml
@@ -13,7 +13,7 @@
.input-group
%input.label.label-monospace.monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true }
.input-group-append
- = clipboard_button(target: '#application_id', title: _("Copy ID to clipboard"), class: "btn btn btn-default")
+ = clipboard_button(target: '#application_id', title: _("Copy ID"), class: "btn btn btn-default")
%tr
%td
= _('Secret')
@@ -22,7 +22,7 @@
.input-group
%input.label.label-monospace.monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true }
.input-group-append
- = clipboard_button(target: '#secret', title: _("Copy secret to clipboard"), class: "btn btn btn-default")
+ = clipboard_button(target: '#secret', title: _("Copy secret"), class: "btn btn btn-default")
%tr
%td
= _('Callback URL')
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 8fad42436ca..41147950c40 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -1,6 +1,7 @@
- breadcrumb_title "Dashboard"
-= render_if_exists 'admin/licenses/breakdown', license: @license
+- if show_license_breakdown?
+ = render_if_exists 'admin/licenses/breakdown', license: @license
.admin-dashboard.prepend-top-default
.row
@@ -40,7 +41,7 @@
.info-well
.well-segment.admin-well.admin-well-features
%h4 Features
- = feature_entry(_('Sign up'), href: admin_application_settings_path(anchor: 'js-signup-settings'))
+ = feature_entry(_('Sign up'), href: admin_application_settings_path(anchor: 'js-signup-settings'), enabled: allow_signup?)
= feature_entry(_('LDAP'), enabled: Gitlab.config.ldap.enabled)
= feature_entry(_('Gravatar'), href: admin_application_settings_path(anchor: 'js-account-settings'), enabled: gravatar_enabled?)
= feature_entry(_('OmniAuth'), href: admin_application_settings_path(anchor: 'js-signin-settings'), enabled: Gitlab::Auth.omniauth_enabled?)
diff --git a/app/views/admin/impersonation_tokens/index.html.haml b/app/views/admin/impersonation_tokens/index.html.haml
index 8e869fb4b71..a7da14d16ff 100644
--- a/app/views/admin/impersonation_tokens/index.html.haml
+++ b/app/views/admin/impersonation_tokens/index.html.haml
@@ -8,7 +8,7 @@
- if @new_impersonation_token
= render "shared/personal_access_tokens_created_container", new_token_value: @new_impersonation_token,
container_title: 'Your New Impersonation Token',
- clipboard_button_title: 'Copy impersonation token to clipboard'
+ clipboard_button_title: _('Copy impersonation token')
= render "shared/personal_access_tokens_form", path: admin_user_impersonation_tokens_path, impersonation: true, token: @impersonation_token, scopes: @scopes
diff --git a/app/views/admin/labels/destroy.js.haml b/app/views/admin/labels/destroy.js.haml
index 7a0dcbdd1c6..394d3c11f31 100644
--- a/app/views/admin/labels/destroy.js.haml
+++ b/app/views/admin/labels/destroy.js.haml
@@ -1,2 +1,2 @@
-- if @labels.size == 0
- $('.labels').load(document.URL + ' .card.bg-light').hide().fadeIn(1000)
+- if @labels.size.zero?
+ $('.labels').load(document.URL + ' .nothing-here-block').hide().fadeIn(1000)
diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml
index 5a5b3d18c5f..38137f360fd 100644
--- a/app/views/admin/labels/index.html.haml
+++ b/app/views/admin/labels/index.html.haml
@@ -14,6 +14,4 @@
= paginate @labels, theme: 'gitlab'
- else
- .card.bg-light
- .nothing-here-block= _('There are no labels yet')
-
+ .nothing-here-block= _('There are no labels yet')
diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml
index bb0d62a70c0..08e668e8623 100644
--- a/app/views/admin/projects/index.html.haml
+++ b/app/views/admin/projects/index.html.haml
@@ -1,8 +1,20 @@
-- page_title "Projects"
+- page_title _('Projects')
- params[:visibility_level] ||= []
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
- .prepend-top-default
+ %ul.nav-links.nav.nav-tabs
+ - opts = params[:visibility_level].present? ? {} : { page: admin_projects_path }
+ = nav_link(opts) do
+ = link_to _('All'), admin_projects_path
+
+ = nav_link(html_options: { class: active_when(params[:visibility_level] == Gitlab::VisibilityLevel::PRIVATE.to_s) }) do
+ = link_to _('Private'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ = nav_link(html_options: { class: active_when(params[:visibility_level] == Gitlab::VisibilityLevel::INTERNAL.to_s) }) do
+ = link_to _('Internal'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
+ = nav_link(html_options: { class: active_when(params[:visibility_level] == Gitlab::VisibilityLevel::PUBLIC.to_s) }) do
+ = link_to _('Public'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
+
+ .nav-controls
.search-holder
= render 'shared/projects/search_form', autofocus: true, admin_view: true
.dropdown
@@ -22,20 +34,4 @@
New Project
= button_tag "Search", class: "btn btn-primary btn-search hide"
- %ul.nav-links.nav.nav-tabs
- - opts = params[:visibility_level].present? ? {} : { page: admin_projects_path }
- = nav_link(opts) do
- = link_to admin_projects_path do
- All
-
- = nav_link(html_options: { class: active_when(params[:visibility_level] == Gitlab::VisibilityLevel::PRIVATE.to_s) }) do
- = link_to admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PRIVATE) do
- Private
- = nav_link(html_options: { class: active_when(params[:visibility_level] == Gitlab::VisibilityLevel::INTERNAL.to_s) }) do
- = link_to admin_projects_path(visibility_level: Gitlab::VisibilityLevel::INTERNAL) do
- Internal
- = nav_link(html_options: { class: active_when(params[:visibility_level] == Gitlab::VisibilityLevel::PUBLIC.to_s) }) do
- = link_to admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PUBLIC) do
- Public
-
= render 'projects'
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index 76af4189b5b..2bf2b5fce8d 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -43,14 +43,13 @@
.row
.col-sm-9
= form_tag admin_runners_path, id: 'runners-search', method: :get, class: 'filter-form js-filter-form' do
- .filtered-search-wrapper
+ .filtered-search-wrapper.d-flex
.filtered-search-box
- = dropdown_tag(custom_icon('icon_history'),
+ = dropdown_tag(_('Recent searches'),
options: { wrapper_class: 'filtered-search-history-dropdown-wrapper',
toggle_class: 'filtered-search-history-dropdown-toggle-button',
dropdown_class: 'filtered-search-history-dropdown',
- content_class: 'filtered-search-history-dropdown-content',
- title: _('Recent searches') }) do
+ content_class: 'filtered-search-history-dropdown-content' }) do
.js-filtered-search-history-dropdown{ data: { full_path: admin_runners_path } }
.filtered-search-box-input-container.droplab-dropdown
.scroll-container
diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml
new file mode 100644
index 00000000000..55aea0296e7
--- /dev/null
+++ b/app/views/admin/sessions/_new_base.html.haml
@@ -0,0 +1,7 @@
+= form_tag(admin_session_path, method: :post, html: { class: 'new_user gl-show-field-errors', 'aria-live': 'assertive'}) do
+ .form-group
+ = label_tag :password, _('Password'), class: 'label-bold'
+ = password_field_tag :password, nil, class: 'form-control', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' }
+
+ .submit-container.move-submit-down
+ = submit_tag _('Enter admin mode'), class: 'btn btn-success', data: { qa_selector: 'sign_in_button' }
diff --git a/app/views/admin/sessions/_signin_box.html.haml b/app/views/admin/sessions/_signin_box.html.haml
new file mode 100644
index 00000000000..69baa76060e
--- /dev/null
+++ b/app/views/admin/sessions/_signin_box.html.haml
@@ -0,0 +1,11 @@
+- if form_based_providers.any?
+
+ - if password_authentication_enabled_for_web?
+ .login-box.tab-pane{ id: 'login-pane', role: 'tabpanel' }
+ .login-body
+ = render 'admin/sessions/new_base'
+
+- elsif password_authentication_enabled_for_web?
+ .login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' }
+ .login-body
+ = render 'admin/sessions/new_base'
diff --git a/app/views/admin/sessions/_tabs_normal.html.haml b/app/views/admin/sessions/_tabs_normal.html.haml
new file mode 100644
index 00000000000..f5dedb5ad76
--- /dev/null
+++ b/app/views/admin/sessions/_tabs_normal.html.haml
@@ -0,0 +1,3 @@
+%ul.nav-links.new-session-tabs.nav-tabs.nav{ role: 'tablist' }
+ %li.nav-item{ role: 'presentation' }
+ %a.nav-link.active{ href: '#login-pane', data: { toggle: 'tab', qa_selector: 'sign_in_tab' }, role: 'tab' }= _('Enter admin mode')
diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml
new file mode 100644
index 00000000000..ee06b4a1741
--- /dev/null
+++ b/app/views/admin/sessions/new.html.haml
@@ -0,0 +1,15 @@
+- @hide_breadcrumbs = true
+- page_title _('Enter admin mode')
+
+.row.justify-content-center
+ .col-6.new-session-forms-container
+ .login-page
+ #signin-container
+ = render 'admin/sessions/tabs_normal'
+ .tab-content
+ - if password_authentication_enabled_for_web?
+ = render 'admin/sessions/signin_box'
+ - else
+ -# Show a message if none of the mechanisms above are enabled
+ .prepend-top-default.center
+ = _('No authentication methods configured.')
diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml
index 948a11646f7..b7648979edd 100644
--- a/app/views/admin/system_info/show.html.haml
+++ b/app/views/admin/system_info/show.html.haml
@@ -1,35 +1,35 @@
-- page_title "System Info"
+- page_title _('System Info')
.prepend-top-default
.row
- .col-sm-4
- .card.bg-light.light-well
- %h4 CPU
+ .col-sm
+ .bg-light.light-well
+ %h4= _('CPU')
.data
- if @cpus
- %h1 #{@cpus.length} cores
+ %h2= _('%{cores} cores') % { cores: @cpus.length }
- else
= icon('warning', class: 'text-warning')
- Unable to collect CPU info
- .col-sm-4
- .card.bg-light.light-well
- %h4 Memory Usage
+ = _('Unable to collect CPU info')
+ .bg-light.light-well.prepend-top-default
+ %h4= _('Memory Usage')
.data
- if @memory
- %h1 #{number_to_human_size(@memory.active_bytes)} / #{number_to_human_size(@memory.total_bytes)}
+ %h2 #{number_to_human_size(@memory.active_bytes)} / #{number_to_human_size(@memory.total_bytes)}
- else
= icon('warning', class: 'text-warning')
- Unable to collect memory info
- .col-sm-4
- .card.bg-light.light-well
- %h4 Disk Usage
+ = _('Unable to collect memory info')
+ .bg-light.light-well.prepend-top-default
+ %h4= _('Uptime')
.data
- - @disks.each do |disk|
- %h1 #{number_to_human_size(disk[:bytes_used])} / #{number_to_human_size(disk[:bytes_total])}
- %p= disk[:disk_name]
- %p= disk[:mount_path]
- .col-sm-4
- .card.bg-light.light-well
- %h4 Uptime
+ %h2= distance_of_time_in_words_to_now(Rails.application.config.booted_at)
+ .col-sm
+ .bg-light.light-well
+ %h4= _('Disk Usage')
.data
- %h1= distance_of_time_in_words_to_now(Rails.application.config.booted_at)
+ %ul
+ - @disks.each do |disk|
+ %li
+ %h2 #{number_to_human_size(disk[:bytes_used])} / #{number_to_human_size(disk[:bytes_total])}
+ %p= disk[:disk_name]
+ %p= disk[:mount_path]
diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml
index e7dde7985fd..a218885a00e 100644
--- a/app/views/admin/users/_head.html.haml
+++ b/app/views/admin/users/_head.html.haml
@@ -6,11 +6,13 @@
%span.cred (Internal)
- if @user.admin
%span.cred (Admin)
+ - if @user.deactivated?
+ %span.cred (Deactivated)
= render_if_exists 'admin/users/audtior_user_badge'
.float-right
- if impersonation_enabled? && @user != current_user && @user.can?(:log_in)
- = link_to 'Impersonate', impersonate_admin_user_path(@user), method: :post, class: "btn btn-nr btn-grouped btn-info"
+ = link_to 'Impersonate', impersonate_admin_user_path(@user), method: :post, class: "btn btn-nr btn-grouped btn-info", data: { qa_selector: 'impersonate_user_link' }
= link_to edit_admin_user_path(@user), class: "btn btn-nr btn-grouped" do
%i.fa.fa-pencil-square-o
Edit
diff --git a/app/views/admin/users/_modals.html.haml b/app/views/admin/users/_modals.html.haml
new file mode 100644
index 00000000000..eaec6d69f5a
--- /dev/null
+++ b/app/views/admin/users/_modals.html.haml
@@ -0,0 +1,30 @@
+#user-modal
+#modal-texts.hidden{ "hidden": true, "aria-hidden": true }
+ %div{ data: { modal: "deactivate",
+ title: s_("AdminUsers|Deactivate User %{username}?"),
+ action: s_("AdminUsers|Deactivate") } }
+ = render partial: 'admin/users/user_deactivation_effects'
+
+ %div{ data: { modal: "block",
+ title: s_("AdminUsers|Block user %{username}?"),
+ action: s_("AdminUsers|Block") } }
+ = render partial: 'admin/users/user_block_effects'
+
+ %div{ data: { modal: "delete",
+ title: s_("AdminUsers|Delete User %{username}?"),
+ action: s_('AdminUsers|Delete user'),
+ 'secondary-action': s_('AdminUsers|Block user') } }
+ = s_('AdminUsers|You are about to permanently delete the user %{username}. Issues, merge requests,
+ and groups linked to them will be transferred to a system-wide "Ghost-user". To avoid data loss,
+ consider using the %{strong_start}block user%{strong_end} feature instead. Once you %{strong_start}Delete user%{strong_end},
+ it cannot be undone or recovered.')
+
+ %div{ data: { modal: "delete-with-contributions",
+ title: s_("AdminUsers|Delete User %{username} and contributions?"),
+ action: s_('AdminUsers|Delete user and contributions') ,
+ 'secondary-action': s_('AdminUsers|Block user') } }
+ = s_('AdminUsers|You are about to permanently delete the user %{username}. This will delete all of the issues,
+ merge requests, and groups linked to them. To avoid data loss,
+ consider using the %{strong_start}block user%{strong_end} feature instead. Once you %{strong_start}Delete user%{strong_end},
+ it cannot be undone or recovered.')
+
diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml
index be7bfa958b2..ca5109614fc 100644
--- a/app/views/admin/users/_user.html.haml
+++ b/app/views/admin/users/_user.html.haml
@@ -1,4 +1,4 @@
-.gl-responsive-table-row{ role: 'row' }
+.gl-responsive-table-row{ role: 'row', data: { qa_selector: 'user_row_content' } }
.table-section.section-40
.table-mobile-header{ role: 'rowheader' }
= _('Name')
@@ -31,7 +31,19 @@
- elsif user.blocked?
= link_to _('Unblock'), unblock_admin_user_path(user), method: :put
- else
- = link_to _('Block'), block_admin_user_path(user), data: { confirm: "#{s_('AdminUsers|User will be blocked').upcase}! #{_('Are you sure')}?" }, method: :put
+ %button.btn{ data: { 'gl-modal-action': 'block',
+ url: block_admin_user_path(user),
+ username: sanitize_name(user.name) } }
+ = s_('AdminUsers|Block')
+ - if user.can_be_deactivated?
+ %li
+ %button.btn{ data: { 'gl-modal-action': 'deactivate',
+ url: deactivate_admin_user_path(user),
+ username: sanitize_name(user.name) } }
+ = s_('AdminUsers|Deactivate')
+ - elsif user.deactivated?
+ %li
+ = link_to _('Activate'), activate_admin_user_path(user), method: :put
- if user.access_locked?
%li
= link_to _('Unlock'), unlock_admin_user_path(user), method: :put, data: { confirm: _('Are you sure?') }
@@ -39,19 +51,14 @@
%li.divider
- if user.can_be_removed?
%li
- %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal',
- target: '#delete-user-modal',
+ %button.delete-user-button.btn.text-danger{ data: { 'gl-modal-action': 'delete',
delete_user_url: admin_user_path(user),
block_user_url: block_admin_user_path(user),
- username: sanitize_name(user.name),
- delete_contributions: false }, type: 'button' }
+ username: sanitize_name(user.name) } }
= s_('AdminUsers|Delete user')
-
- %li
- %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal',
- target: '#delete-user-modal',
- delete_user_url: admin_user_path(user, hard_delete: true),
- block_user_url: block_admin_user_path(user),
- username: sanitize_name(user.name),
- delete_contributions: true }, type: 'button' }
- = s_('AdminUsers|Delete user and contributions')
+ %li
+ %button.delete-user-button.btn.text-danger{ data: { 'gl-modal-action': 'delete-with-contributions',
+ delete_user_url: admin_user_path(user, hard_delete: true),
+ block_user_url: block_admin_user_path(user),
+ username: sanitize_name(user.name) } }
+ = s_('AdminUsers|Delete user and contributions')
diff --git a/app/views/admin/users/_user_activation_effects.html.haml b/app/views/admin/users/_user_activation_effects.html.haml
new file mode 100644
index 00000000000..244836dac11
--- /dev/null
+++ b/app/views/admin/users/_user_activation_effects.html.haml
@@ -0,0 +1,6 @@
+%p
+ = s_('AdminUsers|Reactivating a user will:')
+%ul
+ %li
+ = s_('AdminUsers|Restore user access to the account, including web, Git and API.')
+ = render_if_exists 'admin/users/user_activation_effects_on_seats'
diff --git a/app/views/admin/users/_user_block_effects.html.haml b/app/views/admin/users/_user_block_effects.html.haml
new file mode 100644
index 00000000000..8ffbe145169
--- /dev/null
+++ b/app/views/admin/users/_user_block_effects.html.haml
@@ -0,0 +1,11 @@
+%p
+ = s_('AdminUsers|Blocking user has the following effects:')
+%ul
+ %li
+ = s_('AdminUsers|User will not be able to login')
+ %li
+ = s_('AdminUsers|User will not be able to access git repositories')
+ %li
+ = s_('AdminUsers|Personal projects will be left')
+ %li
+ = s_('AdminUsers|Owned groups will be left')
diff --git a/app/views/admin/users/_user_deactivation_effects.html.haml b/app/views/admin/users/_user_deactivation_effects.html.haml
new file mode 100644
index 00000000000..dc3896e18c0
--- /dev/null
+++ b/app/views/admin/users/_user_deactivation_effects.html.haml
@@ -0,0 +1,18 @@
+%p
+ = s_('AdminUsers|Deactivating a user has the following effects:')
+%ul
+ %li
+ = s_('AdminUsers|The user will be logged out')
+ %li
+ = s_('AdminUsers|The user will not be able to access git repositories')
+ %li
+ = s_('AdminUsers|The user will not be able to access the API')
+ %li
+ = s_('AdminUsers|The user will not receive any notifications')
+ %li
+ = s_('AdminUsers|The user will not be able to use slash commands')
+ %li
+ = s_('AdminUsers|When the user logs back in, their account will reactivate as a fully active account')
+ %li
+ = s_('AdminUsers|Personal projects, group and user history will be left intact')
+ = render_if_exists 'admin/users/user_deactivation_effects_on_seats'
diff --git a/app/views/admin/users/_user_detail.html.haml b/app/views/admin/users/_user_detail.html.haml
index 13d10dcd625..3cc3fc6fa92 100644
--- a/app/views/admin/users/_user_detail.html.haml
+++ b/app/views/admin/users/_user_detail.html.haml
@@ -4,7 +4,7 @@
.row-main-content
.row-title.str-truncated-100
= image_tag avatar_icon_for_user(user), class: 'avatar s16 d-xs-flex d-md-none mr-1 prepend-top-2', alt: _('Avatar for %{name}') % { name: sanitize_name(user.name) }
- = link_to user.name, admin_user_path(user), class: 'text-plain js-user-link', data: { user_id: user.id }
+ = link_to user.name, admin_user_path(user), class: 'text-plain js-user-link', data: { user_id: user.id, qa_selector: 'username_link' }
= render_if_exists 'admin/users/user_listing_note', user: user
diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml
index 36b62557fa6..3c6ad899d1e 100644
--- a/app/views/admin/users/index.html.haml
+++ b/app/views/admin/users/index.html.haml
@@ -30,6 +30,10 @@
= link_to admin_users_path(filter: "blocked") do
= s_('AdminUsers|Blocked')
%small.badge.badge-pill= limited_counter_with_delimiter(User.blocked)
+ = nav_link(html_options: { class: active_when(params[:filter] == 'deactivated') }) do
+ = link_to admin_users_path(filter: "deactivated") do
+ = s_('AdminUsers|Deactivated')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.deactivated)
= nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do
= link_to admin_users_path(filter: "wop") do
= s_('AdminUsers|Without projects')
@@ -44,12 +48,13 @@
= hidden_field_tag "filter", h(params[:filter])
.search-holder
.search-field-holder
- = search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email or username'), class: 'form-control search-text-input js-search-input', spellcheck: false
+ = search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email or username'), class: 'form-control search-text-input js-search-input', spellcheck: false, data: { qa_selector: 'user_search_field' }
- if @sort.present?
= hidden_field_tag :sort, @sort
= icon("search", class: "search-icon")
= button_tag s_('AdminUsers|Search users') if Rails.env.test?
.dropdown.user-sort-dropdown
+ = label_tag 'Sort by', nil, class: 'label-bold'
- toggle_text = @sort.present? ? users_sort_options_hash[@sort] : sort_title_name
= dropdown_toggle(toggle_text, { toggle: 'dropdown' })
%ul.dropdown-menu.dropdown-menu-right
@@ -74,4 +79,4 @@
= paginate @users, theme: "gitlab"
-#delete-user-modal
+= render partial: 'admin/users/modals'
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index a988f746ced..706fa033c51 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -156,6 +156,27 @@
= render_if_exists 'admin/users/user_detail_note'
+ - if @user.deactivated?
+ .card.border-info
+ .card-header.bg-info.text-white
+ Reactivate this user
+ .card-body
+ = render partial: 'admin/users/user_activation_effects'
+ %br
+ = link_to 'Activate user', activate_admin_user_path(@user), method: :put, class: "btn btn-info", data: { confirm: 'Are you sure?' }
+ - elsif @user.can_be_deactivated?
+ .card.border-warning
+ .card-header.bg-warning.text-white
+ Deactivate this user
+ .card-body
+ = render partial: 'admin/users/user_deactivation_effects'
+ %br
+ %button.btn.btn-warning{ data: { 'gl-modal-action': 'deactivate',
+ content: 'You can always re-activate their account, their data will remain intact.',
+ url: deactivate_admin_user_path(@user),
+ username: sanitize_name(@user.name) } }
+ = s_('AdminUsers|Deactivate user')
+
- if @user.blocked?
.card.border-info
.card-header.bg-info.text-white
@@ -172,14 +193,13 @@
.card-header.bg-warning.text-white
Block this user
.card-body
- %p Blocking user has the following effects:
- %ul
- %li User will not be able to login
- %li User will not be able to access git repositories
- %li Personal projects will be left
- %li Owned groups will be left
+ = render partial: 'admin/users/user_block_effects'
%br
- = link_to 'Block user', block_admin_user_path(@user), data: { confirm: 'USER WILL BE BLOCKED! Are you sure?' }, method: :put, class: "btn btn-warning"
+ %button.btn.btn-warning{ data: { 'gl-modal-action': 'block',
+ content: 'You can always unblock their account, their data will remain intact.',
+ url: block_admin_user_path(@user),
+ username: sanitize_name(@user.name) } }
+ = s_('AdminUsers|Block user')
- if @user.access_locked?
.card.border-info
.card-header.bg-info.text-white
@@ -197,12 +217,10 @@
%p Deleting a user has the following effects:
= render 'users/deletion_guidance', user: @user
%br
- %button.delete-user-button.btn.btn-danger{ data: { toggle: 'modal',
- target: '#delete-user-modal',
+ %button.delete-user-button.btn.btn-danger{ data: { 'gl-modal-action': 'delete',
delete_user_url: admin_user_path(@user),
block_user_url: block_admin_user_path(@user),
- username: @user.name,
- delete_contributions: false }, type: 'button' }
+ username: sanitize_name(@user.name) } }
= s_('AdminUsers|Delete user')
- else
- if @user.solo_owned_groups.present?
@@ -229,15 +247,13 @@
the user, and projects in them, will also be removed. Commits
to other projects are unaffected.
%br
- %button.delete-user-button.btn.btn-danger{ data: { toggle: 'modal',
- target: '#delete-user-modal',
+ %button.delete-user-button.btn.btn-danger{ data: { 'gl-modal-action': 'delete-with-contributions',
delete_user_url: admin_user_path(@user, hard_delete: true),
block_user_url: block_admin_user_path(@user),
- username: @user.name,
- delete_contributions: true }, type: 'button' }
+ username: @user.name } }
= s_('AdminUsers|Delete user and contributions')
- else
%p
You don't have access to delete this user.
- #delete-user-modal
+= render partial: 'admin/users/modals'
diff --git a/app/views/ci/runner/_how_to_setup_runner.html.haml b/app/views/ci/runner/_how_to_setup_runner.html.haml
index 4307060d748..aca8aa5d341 100644
--- a/app/views/ci/runner/_how_to_setup_runner.html.haml
+++ b/app/views/ci/runner/_how_to_setup_runner.html.haml
@@ -8,11 +8,11 @@
%li
= _("Specify the following URL during the Runner setup:")
%code#coordinator_address= root_url(only_path: false)
- = clipboard_button(target: '#coordinator_address', title: _("Copy URL to clipboard"), class: "btn-transparent btn-clipboard")
+ = clipboard_button(target: '#coordinator_address', title: _("Copy URL"), class: "btn-transparent btn-clipboard")
%li
= _("Use the following registration token during setup:")
%code#registration_token= registration_token
- = clipboard_button(target: '#registration_token', title: _("Copy token to clipboard"), class: "btn-transparent btn-clipboard")
+ = clipboard_button(target: '#registration_token', title: _("Copy token"), class: "btn-transparent btn-clipboard")
.prepend-top-10.append-bottom-10
= button_to _("Reset runners registration token"), reset_token_url,
method: :put, class: 'btn btn-default',
diff --git a/app/views/ci/runner/_how_to_setup_runner_automatically.html.haml b/app/views/ci/runner/_how_to_setup_runner_automatically.html.haml
new file mode 100644
index 00000000000..58d2ef5d5e6
--- /dev/null
+++ b/app/views/ci/runner/_how_to_setup_runner_automatically.html.haml
@@ -0,0 +1,22 @@
+.append-bottom-10
+ %h4= _('Set up a %{type} Runner automatically') % { type: type }
+
+%p
+ - link_to_help_page = link_to(_('Learn more about Kubernetes'),
+ help_page_path('user/project/clusters/index'),
+ target: '_blank',
+ rel: 'noopener noreferrer')
+
+ = _('You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}').html_safe % { link_to_help_page: link_to_help_page }
+
+%ol
+ %li
+ = _('Click the button below to begin the install process by navigating to the Kubernetes page')
+ %li
+ = _('Select an existing Kubernetes cluster or create a new one')
+ %li
+ = _('From the Kubernetes cluster details view, install Runner from the applications list')
+
+= link_to _('Install Runner on Kubernetes'),
+ clusters_path,
+ class: 'btn btn-info'
diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml
index ed4bd5ae19e..ed9b3ab1940 100644
--- a/app/views/ci/variables/_variable_row.html.haml
+++ b/app/views/ci/variables/_variable_row.html.haml
@@ -30,7 +30,7 @@
value: key,
placeholder: s_('CiVariables|Input variable key') }
.ci-variable-body-item.gl-show-field-errors.table-section.section-15.border-top-0.p-0
- .form-control.js-secret-value-placeholder.qa-ci-variable-input-value{ class: ('hide' unless id) }
+ .form-control.js-secret-value-placeholder.qa-ci-variable-input-value.overflow-hidden{ class: ('hide' unless id) }
= '*' * 17
%textarea.js-ci-variable-input-value.js-secret-value.qa-ci-variable-input-value.form-control{ class: ('hide' if id),
rows: 1,
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 f707c6585ec..d4999798c19 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
@@ -3,6 +3,6 @@
- label = local_assigns.fetch(:label)
= link_to clusterable.new_path(provider: provider), class: 'btn gl-button btn-outline flex-fill d-inline-flex flex-column mr-3 justify-content-center align-items-center' do
- = image_tag logo_path, alt: label, class: 'gl-w-13 gl-h-13'
+ .svg-content= image_tag logo_path, alt: label, class: 'gl-w-13 gl-h-13'
%span
= label
diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml
index 24506205243..7a93a7604f5 100644
--- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml
+++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml
@@ -6,6 +6,6 @@
= create_cluster_label
.d-flex
= render partial: 'clusters/clusters/cloud_providers/cloud_provider_button',
- locals: { provider: 'gke', label: gke_label, logo_path: '' }
+ locals: { provider: 'eks', label: eks_label, logo_path: 'illustrations/logos/amazon_eks.svg' }
= render partial: 'clusters/clusters/cloud_providers/cloud_provider_button',
- locals: { provider: 'eks', label: eks_label, logo_path: '' }
+ locals: { provider: 'gke', label: gke_label, logo_path: 'illustrations/logos/google_gke.svg' }
diff --git a/app/views/clusters/clusters/eks/_index.html.haml b/app/views/clusters/clusters/eks/_index.html.haml
index ca8e9ba527a..db64698a7f2 100644
--- a/app/views/clusters/clusters/eks/_index.html.haml
+++ b/app/views/clusters/clusters/eks/_index.html.haml
@@ -1 +1,2 @@
-.js-create-eks-cluster-form-container
+.js-create-eks-cluster-form-container{ data: { 'gitlab-managed-cluster-help-path' => help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'),
+'kubernetes-integration-help-path' => help_page_path('user/project/clusters/index') } }
diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml
index 4d3e3359ea0..cca16ce7eda 100644
--- a/app/views/clusters/clusters/gcp/_form.html.haml
+++ b/app/views/clusters/clusters/gcp/_form.html.haml
@@ -3,13 +3,12 @@
- zones_link_url = 'https://cloud.google.com/compute/docs/regions-zones/regions-zones'
- machine_type_link_url = 'https://cloud.google.com/compute/docs/machine-types'
- pricing_link_url = 'https://cloud.google.com/compute/pricing#machinetype'
+- kubernetes_integration_url = help_page_path('user/project/clusters/index')
- help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe
- help_link_end = ' %{external_link_icon}</a>'.html_safe % { external_link_icon: external_link_icon }
%p
- - link_to_help_page = link_to(s_('ClusterIntegration|help page'),
- help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
- = s_('ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration.').html_safe % { link_to_help_page: link_to_help_page }
+ = s_('ClusterIntegration|Read our %{link_start}help page%{link_end} on Kubernetes cluster integration.').html_safe % { link_start: help_link_start % { url: kubernetes_integration_url }, link_end: '</a>'.html_safe }
%p= link_to('Select a different Google account', @authorize_url)
@@ -65,6 +64,13 @@
%p.form-text.text-muted
= s_('ClusterIntegration|Learn more about %{help_link_start_machine_type}machine types%{help_link_end} and %{help_link_start_pricing}pricing%{help_link_end}.').html_safe % { help_link_start_machine_type: help_link_start % { url: machine_type_link_url }, help_link_start_pricing: help_link_start % { url: pricing_link_url }, help_link_end: help_link_end }
+ .form-group
+ = provider_gcp_field.check_box :cloud_run, { label: s_('ClusterIntegration|Enable Cloud Run on GKE (beta)'),
+ label_class: 'label-bold' }
+ .form-text.text-muted
+ = s_('ClusterIntegration|Uses the Cloud Run, Istio, and HTTP Load Balancing addons for this cluster.')
+ = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'cloud-run-on-gke'), target: '_blank'
+
.form-group
= field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'),
label_class: 'label-bold' }
diff --git a/app/views/clusters/clusters/gcp/_gcp_not_configured.html.haml b/app/views/clusters/clusters/gcp/_gcp_not_configured.html.haml
new file mode 100644
index 00000000000..b57e45e9812
--- /dev/null
+++ b/app/views/clusters/clusters/gcp/_gcp_not_configured.html.haml
@@ -0,0 +1,3 @@
+- documentation_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path("integration/google") }
+- link_end = '<a/>'.html_safe
+= s_('Google authentication is not %{link_start}property configured%{link_end}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_start: documentation_link_start, link_end: link_end }
diff --git a/app/views/clusters/clusters/gcp/_signin_with_google_button.html.haml b/app/views/clusters/clusters/gcp/_signin_with_google_button.html.haml
new file mode 100644
index 00000000000..65cfa6552b1
--- /dev/null
+++ b/app/views/clusters/clusters/gcp/_signin_with_google_button.html.haml
@@ -0,0 +1,4 @@
+.signin-with-google
+ - create_account_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: 'https://accounts.google.com/SignUpWithoutGmail?service=cloudconsole&continue=https%3A%2F%2Fconsole.cloud.google.com%2Ffreetrial%3Futm_campaign%3D2018_cpanel%26utm_source%3Dgitlab%26utm_medium%3Dreferral' }
+ = link_to(image_tag('auth_buttons/signin_with_google.png', width: '191px', alt: _('Sign in with Google')), @authorize_url)
+ = s_('or %{link_start}create a new Google account%{link_end}').html_safe % { link_start: create_account_link, link_end: '</a>'.html_safe }
diff --git a/app/views/clusters/clusters/new.html.haml b/app/views/clusters/clusters/new.html.haml
index fb182d99ff0..2c23426aaf9 100644
--- a/app/views/clusters/clusters/new.html.haml
+++ b/app/views/clusters/clusters/new.html.haml
@@ -2,7 +2,9 @@
- page_title _('Kubernetes Cluster')
- create_eks_enabled = Feature.enabled?(:create_eks_clusters)
- active_tab = local_assigns.fetch(:active_tab, 'create')
-- link_end = '<a/>'.html_safe
+- create_on_gke_tab_label = s_('ClusterIntegration|Create new Cluster on GKE')
+- create_on_eks_tab_label = s_('ClusterIntegration|Create new Cluster on EKS')
+- create_new_cluster_label = s_('ClusterIntegration|Create new Cluster')
= javascript_include_tag 'https://apis.google.com/js/api.js'
= render_gcp_signup_offer
@@ -14,7 +16,16 @@
%ul.nav-links.nav-tabs.gitlab-tabs.nav{ role: 'tablist' }
%li.nav-item{ role: 'presentation' }
%a.nav-link{ href: '#create-cluster-pane', id: 'create-cluster-tab', class: active_when(active_tab == 'create'), data: { toggle: 'tab' }, role: 'tab' }
- %span Create new Cluster on GKE
+ %span
+ - if create_eks_enabled
+ - if @gke_selected
+ = create_on_gke_tab_label
+ - elsif @eks_selected
+ = create_on_eks_tab_label
+ - else
+ = create_new_cluster_label
+ - else
+ = create_on_gke_tab_label
%li.nav-item{ role: 'presentation' }
%a.nav-link{ href: '#add-cluster-pane', id: 'add-cluster-tab', class: active_when(active_tab == 'add'), data: { toggle: 'tab' }, role: 'tab' }
%span Add existing cluster
@@ -22,9 +33,14 @@
.tab-content.gitlab-tab-content
- if create_eks_enabled
.tab-pane{ id: 'create-cluster-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' }
- - if @gke_selected && @valid_gcp_token
+ - if @gke_selected
= render 'clusters/clusters/gcp/header'
- = render 'clusters/clusters/gcp/form'
+ - if @valid_gcp_token
+ = render 'clusters/clusters/gcp/form'
+ - elsif @authorize_url
+ = render 'clusters/clusters/gcp/signin_with_google_button'
+ - else
+ = render 'clusters/clusters/gcp/gcp_not_configured'
- elsif @eks_selected
= render 'clusters/clusters/eks/index'
- else
@@ -35,13 +51,9 @@
- if @valid_gcp_token
= render 'clusters/clusters/gcp/form'
- elsif @authorize_url
- .signin-with-google
- - create_account_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: 'https://accounts.google.com/SignUpWithoutGmail?service=cloudconsole&continue=https%3A%2F%2Fconsole.cloud.google.com%2Ffreetrial%3Futm_campaign%3D2018_cpanel%26utm_source%3Dgitlab%26utm_medium%3Dreferral' }
- = link_to(image_tag('auth_buttons/signin_with_google.png', width: '191px', alt: _('Sign in with Google')), @authorize_url)
- = s_('or %{link_start}create a new Google account%{link_end}').html_safe % { link_start: create_account_link, link_end: link_end }
+ = render 'clusters/clusters/gcp/signin_with_google_button'
- else
- - documentation_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path("integration/google") }
- = s_('Google authentication is not %{link_start}property configured%{link_end}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_start: documentation_link_start, link_end: link_end }
+ = render 'clusters/clusters/gcp/gcp_not_configured'
.tab-pane{ id: 'add-cluster-pane', class: active_when(active_tab == 'add'), role: 'tabpanel' }
= render 'clusters/clusters/user/header'
diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml
index cccba48624b..31d5f592d75 100644
--- a/app/views/clusters/clusters/show.html.haml
+++ b/app/views/clusters/clusters/show.html.haml
@@ -23,12 +23,15 @@
cluster_type: @cluster.cluster_type,
cluster_status: @cluster.status_name,
cluster_status_reason: @cluster.status_reason,
+ provider_type: @cluster.provider_type,
+ pre_installed_knative: @cluster.knative_pre_installed? ? 'true': 'false',
help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'),
ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-endpoint'),
ingress_dns_help_path: help_page_path('user/project/clusters/index.md', anchor: 'manually-determining-the-external-endpoint'),
environments_help_path: help_page_path('ci/environments', anchor: 'defining-environments'),
clusters_help_path: help_page_path('user/project/clusters/index.md', anchor: 'deploying-to-a-kubernetes-cluster'),
deploy_boards_help_path: help_page_path('user/project/deploy_boards.html', anchor: 'enabling-deploy-boards'),
+ cloud_run_help_path: help_page_path('user/project/clusters/index.md', anchor: 'cloud-run-on-gke'),
manage_prometheus_path: manage_prometheus_path,
cluster_id: @cluster.id } }
@@ -39,6 +42,6 @@
= render 'banner'
- if cluster_environments_path.present?
- = render_if_exists 'clusters/clusters/group_cluster_environments', expanded: expanded
+ = render_if_exists 'clusters/clusters/cluster_environments', expanded: expanded
- else
= render 'configure', expanded: expanded
diff --git a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml
index c50b20a83dc..6e7ec1264ea 100644
--- a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml
+++ b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml
@@ -1,41 +1,40 @@
.blank-state-row
- = link_to new_project_path, class: "blank-state-link" do
- .blank-state
+ - if has_start_trial?
+ = render_if_exists "dashboard/projects/blank_state_ee_trial"
+
+ = link_to new_project_path, class: "blank-state blank-state-link" do
+ .blank-state-icon
+ = image_tag("illustrations/welcome/add_new_project")
+ .blank-state-body
+ %h3.blank-state-title
+ Create a project
+ %p.blank-state-text
+ Projects are where you store your code, access issues, wiki and other features of GitLab.
+
+ - if current_user.can_create_group?
+ = link_to new_group_path, class: "blank-state blank-state-link" do
.blank-state-icon
- = custom_icon("add_new_project", size: 50)
+ = image_tag("illustrations/welcome/add_new_group")
.blank-state-body
%h3.blank-state-title
- Create a project
+ Create a group
%p.blank-state-text
- Projects are where you store your code, access issues, wiki and other features of GitLab.
+ Groups are a great way to organize projects and people.
- - if current_user.can_create_group?
- = link_to new_group_path, class: "blank-state-link" do
- .blank-state
- .blank-state-icon
- = custom_icon("add_new_group", size: 50)
- .blank-state-body
- %h3.blank-state-title
- Create a group
- %p.blank-state-text
- Groups are a great way to organize projects and people.
-
- = link_to new_admin_user_path, class: "blank-state-link" do
- .blank-state
- .blank-state-icon
- = custom_icon("add_new_user", size: 50)
- .blank-state-body
- %h3.blank-state-title
- Add people
- %p.blank-state-text
- Add your team members and others to GitLab.
-
- = link_to admin_root_path, class: "blank-state-link" do
- .blank-state
+ = link_to new_admin_user_path, class: "blank-state blank-state-link" do
.blank-state-icon
- = custom_icon("configure_server", size: 50)
+ = image_tag("illustrations/welcome/add_new_user")
.blank-state-body
%h3.blank-state-title
- Configure GitLab
+ Add people
%p.blank-state-text
- Make adjustments to how your GitLab instance is set up.
+ Add your team members and others to GitLab.
+
+ = link_to admin_root_path, class: "blank-state blank-state-link" do
+ .blank-state-icon
+ = image_tag("illustrations/welcome/configure_server")
+ .blank-state-body
+ %h3.blank-state-title
+ Configure GitLab
+ %p.blank-state-text
+ Make adjustments to how your GitLab instance is set up.
diff --git a/app/views/dashboard/projects/_blank_state_welcome.html.haml b/app/views/dashboard/projects/_blank_state_welcome.html.haml
index 8d5bddbb288..e3af3405b76 100644
--- a/app/views/dashboard/projects/_blank_state_welcome.html.haml
+++ b/app/views/dashboard/projects/_blank_state_welcome.html.haml
@@ -2,19 +2,18 @@
.blank-state-row
- if current_user.can_create_project?
- = link_to new_project_path, class: "blank-state-link" do
- .blank-state
- .blank-state-icon
- = custom_icon("add_new_project", size: 50)
- .blank-state-body
- %h3.blank-state-title
- Create a project
- %p.blank-state-text
- Projects are where you store your code, access issues, wiki and other features of GitLab.
+ = link_to new_project_path, class: "blank-state blank-state-link" do
+ .blank-state-icon
+ = image_tag("illustrations/welcome/add_new_project")
+ .blank-state-body
+ %h3.blank-state-title
+ Create a project
+ %p.blank-state-text
+ Projects are where you store your code, access issues, wiki and other features of GitLab.
- else
.blank-state
.blank-state-icon
- = custom_icon("add_new_project", size: 50)
+ = image_tag("illustrations/welcome/add_new_project")
.blank-state-body
%h3.blank-state-title
Create a project
@@ -22,37 +21,34 @@
If you are added to a project, it will be displayed here.
- if current_user.can_create_group?
- = link_to new_group_path, class: "blank-state-link" do
- .blank-state
- .blank-state-icon
- = custom_icon("add_new_group", size: 50)
- .blank-state-body
- %h3.blank-state-title
- Create a group
- %p.blank-state-text
- Groups are the best way to manage projects and members.
+ = link_to new_group_path, class: "blank-state blank-state-link" do
+ .blank-state-icon
+ = image_tag("illustrations/welcome/add_new_group")
+ .blank-state-body
+ %h3.blank-state-title
+ Create a group
+ %p.blank-state-text
+ Groups are the best way to manage projects and members.
- if public_project_count > 0
- = link_to trending_explore_projects_path, class: "blank-state-link" do
- .blank-state
- .blank-state-icon
- = custom_icon("globe", size: 50)
- .blank-state-body
- %h3.blank-state-title
- Explore public projects
- %p.blank-state-text
- There are
- = number_with_delimiter(public_project_count)
- public projects on this server.
- Public projects are an easy way to allow
- everyone to have read-only access.
-
- = link_to "https://docs.gitlab.com/", class: "blank-state-link" do
- .blank-state
+ = link_to trending_explore_projects_path, class: "blank-state blank-state-link" do
.blank-state-icon
- = custom_icon("lightbulb", size: 50)
+ = image_tag("illustrations/welcome/globe")
.blank-state-body
%h3.blank-state-title
- Learn more about GitLab
+ Explore public projects
%p.blank-state-text
- Take a look at the documentation to discover all of GitLab's capabilities.
+ There are
+ = number_with_delimiter(public_project_count)
+ public projects on this server.
+ Public projects are an easy way to allow
+ everyone to have read-only access.
+
+ = link_to "https://docs.gitlab.com/", class: "blank-state blank-state-link" do
+ .blank-state-icon
+ = image_tag("illustrations/welcome/lightbulb")
+ .blank-state-body
+ %h3.blank-state-title
+ Learn more about GitLab
+ %p.blank-state-text
+ Take a look at the documentation to discover all of GitLab's capabilities.
diff --git a/app/views/dashboard/projects/_zero_authorized_projects.html.haml b/app/views/dashboard/projects/_zero_authorized_projects.html.haml
index 8933c5d7227..a2b1f0d9298 100644
--- a/app/views/dashboard/projects/_zero_authorized_projects.html.haml
+++ b/app/views/dashboard/projects/_zero_authorized_projects.html.haml
@@ -1,18 +1,13 @@
-.blank-state-parent-container{ class: ('has-start-trial-container' if has_start_trial?) }
+.blank-state-parent-container
.section-container.section-welcome{ class: "#{ 'section-admin-welcome' if current_user.admin? }" }
.container.section-body
.row
- .blank-state-welcome
+ .blank-state-welcome.w-100
%h2.blank-state-welcome-title
- Welcome to GitLab
+ = _('Welcome to GitLab')
%p.blank-state-text
- Code, test, and deploy together
- .blank-state-row
- %div{ class: ('column-large' if has_start_trial?) }
- - if current_user.admin?
- = render "blank_state_admin_welcome"
- - else
- = render "blank_state_welcome"
- - if has_start_trial?
- .column-small
- = render_if_exists "blank_state_ee_trial"
+ = _('Faster releases. Better code. Less pain.')
+ - if current_user.admin?
+ = render "blank_state_admin_welcome"
+ - else
+ = render "blank_state_welcome"
diff --git a/app/views/dashboard/snippets/index.html.haml b/app/views/dashboard/snippets/index.html.haml
index b649fe91c24..2caa8e0cac4 100644
--- a/app/views/dashboard/snippets/index.html.haml
+++ b/app/views/dashboard/snippets/index.html.haml
@@ -6,10 +6,6 @@
- if current_user.snippets.exists?
= render partial: 'snippets/snippets_scope_menu', locals: { include_private: true }
-.d-block.d-sm-none
- &nbsp;
- = link_to _("New snippet"), new_snippet_path, class: "btn btn-success btn-block", title: _("New snippet")
-
- if current_user.snippets.exists?
= render partial: 'shared/snippets/list', locals: { link_project: true }
- else
diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml
index 42cfbbf84f2..5f85235e8fa 100644
--- a/app/views/devise/registrations/new.html.haml
+++ b/app/views/devise/registrations/new.html.haml
@@ -1,4 +1,7 @@
- page_title "Sign up"
-= render 'devise/shared/signup_box'
+- if experiment_enabled?(:signup_flow)
+ = render 'devise/shared/experimental_separate_sign_up_flow_box'
+- else
+ = render 'devise/shared/signup_box'
= render 'devise/shared/sign_in_link'
diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml
index 30ed7ed6b29..8f6c3ecbe58 100644
--- a/app/views/devise/sessions/new.html.haml
+++ b/app/views/devise/sessions/new.html.haml
@@ -4,7 +4,8 @@
- if form_based_providers.any?
= render 'devise/shared/tabs_ldap'
- else
- = render 'devise/shared/tabs_normal'
+ - unless experiment_enabled?(:signup_flow)
+ = render 'devise/shared/tabs_normal'
.tab-content
- if password_authentication_enabled_for_web? || ldap_enabled? || crowd_enabled?
= render 'devise/shared/signin_box'
diff --git a/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml b/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml
new file mode 100644
index 00000000000..5d163d03c73
--- /dev/null
+++ b/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml
@@ -0,0 +1,31 @@
+- content_for(:page_title, _('Register for GitLab'))
+- max_username_length = 255
+.signup-box.p-3.mb-2
+ .signup-body
+ = form_for(resource, as: "new_#{resource_name}", url: registration_path(resource_name), html: { class: "new_new_user gl-show-field-errors", "aria-live" => "assertive" }) do |f|
+ .devise-errors.mt-0
+ = render "devise/shared/error_messages", resource: resource
+ = invisible_captcha
+ .username.form-group
+ = f.label :username, class: 'label-bold'
+ = f.text_field :username, class: "form-control middle js-block-emoji js-validate-length js-validate-username", :data => { :max_length => max_username_length, :max_length_message => s_("SignUp|Username is too long (maximum is %{max_length} characters).") % { max_length: max_username_length }, :qa_selector => 'new_user_username_field' }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.")
+ %p.validation-error.gl-field-error-ignore.field-validation.mt-1.hide.cred= _('Username is already taken.')
+ %p.validation-success.gl-field-error-ignore.field-validation.mt-1.hide.cgreen= _('Username is available.')
+ %p.validation-pending.gl-field-error-ignore.field-validation.mt-1.hide= _('Checking username availability...')
+ .form-group
+ = f.label :email, class: 'label-bold'
+ = f.email_field :email, class: "form-control middle", data: { qa_selector: 'new_user_email_field' }, required: true, title: _("Please provide a valid email address.")
+ .form-group.append-bottom-20#password-strength
+ = f.label :password, class: 'label-bold'
+ = f.password_field :password, class: "form-control bottom", data: { qa_selector: 'new_user_password_field' }, required: true, pattern: ".{#{@minimum_password_length},}", title: _("Minimum length is %{minimum_password_length} characters.") % { minimum_password_length: @minimum_password_length }
+ %p.gl-field-hint.text-secondary= _('Minimum length is %{minimum_password_length} characters') % { minimum_password_length: @minimum_password_length }
+ - if Gitlab::CurrentSettings.current_application_settings.enforce_terms?
+ .form-group
+ = check_box_tag :terms_opt_in, '1', false, required: true, data: { qa_selector: 'new_user_accept_terms_checkbox' }
+ = label_tag :terms_opt_in do
+ - terms_link = link_to s_("I accept the|Terms of Service and Privacy Policy"), terms_path, target: "_blank"
+ - accept_terms_label = _("I accept the %{terms_link}") % { terms_link: terms_link }
+ = accept_terms_label.html_safe
+ = render_if_exists 'devise/shared/email_opted_in', f: f
+ .submit-container.mt-3
+ = f.submit _("Register"), class: "btn-register btn btn-block btn-success mb-0 p-2", data: { qa_selector: 'new_user_register_button' }
diff --git a/app/views/devise/shared/_sign_in_link.html.haml b/app/views/devise/shared/_sign_in_link.html.haml
index 77ef103cc47..9a7d8a0a160 100644
--- a/app/views/devise/shared/_sign_in_link.html.haml
+++ b/app/views/devise/shared/_sign_in_link.html.haml
@@ -1,4 +1,4 @@
-%p
+%p.text-center
%span.light
Already have login and password?
= link_to "Sign in", new_session_path(:user, redirect_to_referer: 'yes')
diff --git a/app/views/devise/shared/_signin_box.html.haml b/app/views/devise/shared/_signin_box.html.haml
index f8f36a8bfff..746d43edbad 100644
--- a/app/views/devise/shared/_signin_box.html.haml
+++ b/app/views/devise/shared/_signin_box.html.haml
@@ -22,3 +22,8 @@
.login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' }
.login-body
= render 'devise/sessions/new_base'
+
+- if experiment_enabled?(:signup_flow)
+ %p.light.mt-2
+ = _("Don't have an account yet?")
+ = link_to _("Register now"), new_registration_path(:user)
diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml
index 6750732ab67..8a1b7500abf 100644
--- a/app/views/doorkeeper/applications/show.html.haml
+++ b/app/views/doorkeeper/applications/show.html.haml
@@ -16,7 +16,7 @@
.input-group
%input.label.label-monospace.monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true }
.input-group-append
- = clipboard_button(target: '#application_id', title: _("Copy ID to clipboard"), class: "btn btn btn-default")
+ = clipboard_button(target: '#application_id', title: _("Copy ID"), class: "btn btn btn-default")
%tr
%td
= _('Secret')
@@ -25,7 +25,7 @@
.input-group
%input.label.label-monospace.monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true }
.input-group-append
- = clipboard_button(target: '#secret', title: _("Copy secret to clipboard"), class: "btn btn btn-default")
+ = clipboard_button(target: '#secret', title: _("Copy secret"), class: "btn btn btn-default")
%tr
%td
= _('Callback URL')
diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml
index 21c418cb0e4..b9e88f3fc47 100644
--- a/app/views/events/event/_push.html.haml
+++ b/app/views/events/event/_push.html.haml
@@ -6,11 +6,13 @@
.event-title.d-flex.flex-wrap
= inline_event_icon(event)
- %span.event-type.d-inline-block.append-right-4.pushed #{event.action_name} #{event.ref_type}
- %span.append-right-4
- - commits_link = project_commits_path(project, event.ref_name)
- - should_link = event.tag? ? project.repository.tag_exists?(event.ref_name) : project.repository.branch_exists?(event.ref_name)
- = link_to_if should_link, event.ref_name, commits_link, class: 'ref-name'
+ - many_refs = event.ref_count.to_i > 1
+ %span.event-type.d-inline-block.append-right-4.pushed= many_refs ? "#{event.action_name} #{event.ref_count} #{event.ref_type.pluralize}" : "#{event.action_name} #{event.ref_type}"
+ - unless many_refs
+ %span.append-right-4
+ - commits_link = project_commits_path(project, event.ref_name)
+ - should_link = event.tag? ? project.repository.tag_exists?(event.ref_name) : project.repository.branch_exists?(event.ref_name)
+ = link_to_if should_link, event.ref_name, commits_link, class: 'ref-name'
= render "events/event_scope", event: event
diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml
index 13df1e57125..44554cab1e9 100644
--- a/app/views/groups/_activities.html.haml
+++ b/app/views/groups/_activities.html.haml
@@ -1,5 +1,5 @@
.nav-block.activities
- = render 'shared/event_filter'
+ = render 'shared/event_filter', show_group_events: @group.supports_events?
.controls
= link_to group_path(@group, rss_url_options), class: 'btn d-none d-sm-inline-block has-tooltip' , title: 'Subscribe' do
%i.fa.fa-rss
diff --git a/app/views/groups/_create_chat_team.html.haml b/app/views/groups/_create_chat_team.html.haml
index 561e68a9155..2531993a095 100644
--- a/app/views/groups/_create_chat_team.html.haml
+++ b/app/views/groups/_create_chat_team.html.haml
@@ -8,7 +8,7 @@
.form-check.js-toggle-container
.js-toggle-button.form-check-input= f.check_box(:create_chat_team, { checked: true }, true, false)
= f.label :create_chat_team, class: 'form-check-label' do
- Create a Mattermost team for this group
+ = _('Create a Mattermost team for this group')
%br
%small.light.js-toggle-content
Mattermost URL:
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index 6d06bb246cb..0e78ce9f656 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -1,4 +1,4 @@
-- breadcrumb_title "General Settings"
+- breadcrumb_title _("General Settings")
- @content_class = "limit-container-width" unless fluid_layout
- expanded = expanded_by_default?
@@ -32,7 +32,7 @@
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' }
= s_('GroupSettings|Badges')
%button.btn.js-settings-toggle{ type: 'button' }
- = expanded ? 'Collapse' : 'Expand'
+ = expanded ? _('Collapse') : _('Expand')
%p
= s_('GroupSettings|Customize your group badges.')
= link_to s_('GroupSettings|Learn more about badges.'), help_page_path('user/project/badges')
diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml
index 06e05d898d6..376624f4786 100644
--- a/app/views/groups/new.html.haml
+++ b/app/views/groups/new.html.haml
@@ -40,5 +40,5 @@
= render 'create_chat_team', f: f if Gitlab.config.mattermost.enabled
.form-actions
- = f.submit 'Create group', class: "btn btn-success"
- = link_to 'Cancel', dashboard_groups_path, class: 'btn btn-cancel'
+ = f.submit _('Create group'), class: "btn btn-success"
+ = link_to _('Cancel'), dashboard_groups_path, class: 'btn btn-cancel'
diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml
index ba186875a86..8b01e54474a 100644
--- a/app/views/groups/projects.html.haml
+++ b/app/views/groups/projects.html.haml
@@ -8,21 +8,38 @@
.controls
= link_to new_project_path(namespace_id: @group.id), class: "btn btn-sm btn-success" do
New project
- %ul.content-list
+ %ul.projects-list.content-list.group-settings-projects
- @projects.each do |project|
- %li
- .list-item-name
- %span{ class: visibility_level_color(project.visibility_level) }
- = visibility_level_icon(project.visibility_level)
- %strong= link_to project.full_name, project
- .float-right
+ %li.project-row{ class: ('no-description' if project.description.blank?) }
+ .controls
+ = link_to _('Members'), project_project_members_path(project), id: "edit_#{dom_id(project)}", class: "btn"
+ = link_to _('Edit'), edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn"
+ = link_to _('Remove'), project, data: { confirm: remove_project_message(project)}, method: :delete, class: "btn btn-remove"
+
+ .stats
+ %span.badge.badge-pill
+ = storage_counter(project.statistics&.storage_size)
- if project.archived
%span.badge.badge-warning archived
- %span.badge.badge-pill
- = storage_counter(project.statistics.storage_size)
- = link_to 'Members', project_project_members_path(project), id: "edit_#{dom_id(project)}", class: "btn btn-sm"
- = link_to 'Edit', edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn btn-sm"
- = link_to 'Remove', project, data: { confirm: remove_project_message(project)}, method: :delete, class: "btn btn-sm btn-remove"
+
+ .title
+ = link_to(project_path(project)) do
+ .dash-project-avatar
+ .avatar-container.rect-avatar.s40
+ = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40)
+ %span.project-full-name
+ %span.namespace-name
+ - if project.namespace
+ = project.namespace.human_name
+ \/
+ %span.project-name
+ = project.name
+ %span{ class: visibility_level_color(project.visibility_level) }
+ = visibility_level_icon(project.visibility_level)
+
+ - if project.description.present?
+ .description
+ = markdown_field(project, :description)
- if @projects.blank?
.nothing-here-block This group has no projects yet
diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml
new file mode 100644
index 00000000000..e85b0713230
--- /dev/null
+++ b/app/views/groups/registry/repositories/index.html.haml
@@ -0,0 +1,12 @@
+- page_title _("Container Registry")
+
+%section
+ .row.registry-placeholder.prepend-bottom-10
+ .col-12
+ #js-vue-registry-images{ data: { endpoint: group_container_registries_path(@group, format: :json),
+ "help_page_path" => help_page_path('user/packages/container_registry/index'),
+ "no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
+ "containers_error_image" => image_path('illustrations/docker-error-state.svg'),
+ "repository_url" => "",
+ is_group_page: true,
+ character_error: @character_error.to_s } }
diff --git a/app/views/groups/runners/_group_runners.html.haml b/app/views/groups/runners/_group_runners.html.haml
index fd40ec5a984..f752bc0a702 100644
--- a/app/views/groups/runners/_group_runners.html.haml
+++ b/app/views/groups/runners/_group_runners.html.haml
@@ -10,6 +10,10 @@
-# Proper policies should be implemented per
-# https://gitlab.com/gitlab-org/gitlab-foss/issues/45894
- if can?(current_user, :admin_pipeline, @group)
+ = render partial: 'ci/runner/how_to_setup_runner_automatically',
+ locals: { type: 'group',
+ clusters_path: group_clusters_path(@group) }
+ %hr
= render partial: 'ci/runner/how_to_setup_runner',
locals: { registration_token: @group.runners_token,
type: 'group',
diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml
index 64fec260f3b..307309c6ca3 100644
--- a/app/views/groups/settings/_advanced.html.haml
+++ b/app/views/groups/settings/_advanced.html.haml
@@ -1,12 +1,12 @@
.sub-section
- %h4.warning-title Change group path
+ %h4.warning-title= s_('GroupSettings|Change group path')
= form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f|
= form_errors(@group)
.form-group
%p
- Changing group path can have unintended side effects.
+ = s_('GroupSettings|Changing group path can have unintended side effects.')
= succeed '.' do
- = link_to 'Learn more', help_page_path('user/group/index', anchor: 'changing-a-groups-path'), target: '_blank'
+ = link_to _('Learn more'), help_page_path('user/group/index', anchor: 'changing-a-groups-path'), target: '_blank'
.input-group.gl-field-error-anchor
.group-root-path.input-group-prepend.has-tooltip{ title: group_path(@group), :'data-placement' => 'bottom' }
@@ -18,24 +18,26 @@
= f.text_field :path, placeholder: 'open-source', class: 'form-control',
autofocus: local_assigns[:autofocus] || false, required: true,
pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS,
- title: 'Please choose a group path with no special characters.',
+ title: s_('GroupSettings|Please choose a group path with no special characters.'),
"data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}"
- = f.submit 'Change group path', class: 'btn btn-warning'
+ = f.submit s_('GroupSettings|Change group path'), class: 'btn btn-warning'
.sub-section
- %h4.warning-title Transfer group
+ %h4.warning-title= s_('GroupSettings|Transfer group')
= form_for @group, url: transfer_group_path(@group), method: :put, html: { class: 'js-group-transfer-form' } do |f|
.form-group
= dropdown_tag('Select parent group', options: { toggle_class: 'js-groups-dropdown', title: 'Parent Group', filter: true, dropdown_class: 'dropdown-open-top dropdown-group-transfer', placeholder: 'Search groups', data: { data: parent_group_options(@group) } })
= hidden_field_tag 'new_parent_group_id'
%ul
- %li Be careful. Changing a group's parent can have unintended #{link_to 'side effects', 'https://docs.gitlab.com/ce/user/project/index.html#redirects-when-changing-repository-paths', target: 'blank'}.
- %li You can only transfer the group to a group you manage.
- %li You will need to update your local repositories to point to the new location.
- %li If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.
- = f.submit 'Transfer group', class: 'btn btn-warning'
+ - side_effects_link_start = '<a href="https://docs.gitlab.com/ce/user/project/index.html#redirects-when-changing-repository-paths" target="_blank">'
+ - warning_text = s_("GroupSettings|Be careful. Changing a group's parent can have unintended %{side_effects_link_start}side effects%{side_effects_link_end}.") % { side_effects_link_start: side_effects_link_start, side_effects_link_end:'</a>' }
+ %li= warning_text.html_safe
+ %li= s_('GroupSettings|You can only transfer the group to a group you manage.')
+ %li= s_('GroupSettings|You will need to update your local repositories to point to the new location.')
+ %li= s_("GroupSettings|If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.")
+ = f.submit s_('GroupSettings|Transfer group'), class: 'btn btn-warning'
.sub-section
%h4.danger-title= _('Remove group')
diff --git a/app/views/groups/settings/ci_cd/_form.html.haml b/app/views/groups/settings/ci_cd/_form.html.haml
new file mode 100644
index 00000000000..54e88d11827
--- /dev/null
+++ b/app/views/groups/settings/ci_cd/_form.html.haml
@@ -0,0 +1,13 @@
+.row.prepend-top-default
+ .col-lg-12
+ = form_for group, url: group_settings_ci_cd_path(group, anchor: 'js-general-pipeline-settings') do |f|
+ = form_errors(group)
+ %fieldset.builds-feature
+ .form-group
+ = f.label :max_artifacts_size, _('Maximum artifacts size (MB)'), class: 'label-bold'
+ = f.number_field :max_artifacts_size, class: 'form-control'
+ %p.form-text.text-muted
+ = _("Set the maximum file size for each job's artifacts")
+ = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size-core-only'), target: '_blank'
+
+ = f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml
index d21496ee0aa..a3f35b72cc6 100644
--- a/app/views/groups/settings/ci_cd/show.html.haml
+++ b/app/views/groups/settings/ci_cd/show.html.haml
@@ -2,6 +2,21 @@
- page_title "CI / CD"
- expanded = expanded_by_default?
+- general_expanded = @group.errors.empty? ? expanded : true
+
+-# Given we only have one field in this form which is also admin-only,
+-# we don't want to show an empty section to non-admin users,
+- if can?(current_user, :update_max_artifacts_size, @group)
+ %section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if general_expanded) }
+ .settings-header
+ %h4
+ = _("General pipelines")
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? _('Collapse') : _('Expand')
+ %p
+ = _("Customize your pipeline configuration.")
+ .settings-content
+ = render 'groups/settings/ci_cd/form', group: @group
%section.settings#ci-variables.no-animate{ class: ('expanded' if expanded) }
.settings-header
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index 0e6c16f0f06..457d05b4a97 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -5,6 +5,8 @@
= auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity")
%div{ class: [("limit-container-width" unless fluid_layout)] }
+ = render_if_exists 'trials/banner', namespace: @group
+
= render 'groups/home_panel'
.groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } }
diff --git a/app/views/groups/sidebar/_packages.html.haml b/app/views/groups/sidebar/_packages.html.haml
new file mode 100644
index 00000000000..16b902a18b9
--- /dev/null
+++ b/app/views/groups/sidebar/_packages.html.haml
@@ -0,0 +1,16 @@
+- if group_container_registry_nav?
+ = nav_link(path: group_packages_nav_link_paths) do
+ = link_to group_container_registries_path(@group), title: _('Container Registry') do
+ .nav-icon-container
+ = sprite_icon('package')
+ %span.nav-item-name
+ = _('Packages')
+ %ul.sidebar-sub-level-items
+ = nav_link(controller: [:packages, :repositories], html_options: { class: "fly-out-top-item" } ) do
+ = link_to group_container_registries_path(@group), title: _('Container Registry') do
+ %strong.fly-out-top-item-name
+ = _('Packages')
+ %li.divider.fly-out-top-item
+ = nav_link(controller: 'groups/container_registries') do
+ = link_to group_container_registries_path(@group), title: _('Container Registry') do
+ %span= _('Container Registry')
diff --git a/app/views/help/instance_configuration/_ssh_info.html.haml b/app/views/help/instance_configuration/_ssh_info.html.haml
index 987cc61b3f6..a7ee37b2784 100644
--- a/app/views/help/instance_configuration/_ssh_info.html.haml
+++ b/app/views/help/instance_configuration/_ssh_info.html.haml
@@ -1,22 +1,29 @@
- ssh_info = @instance_configuration.settings[:ssh_algorithms_hashes]
-- if ssh_info.any?
- - content_for :table_content do
- %li= link_to 'SSH host keys fingerprints', '#ssh-host-keys-fingerprints'
+- content_for :table_content do
+ %li
+ = link_to _('SSH host key fingerprints'), '#ssh-host-keys-fingerprints'
- - content_for :settings_content do
- %h2#ssh-host-keys-fingerprints
- SSH host keys fingerprints
+- content_for :settings_content do
+ %h2#ssh-host-keys-fingerprints
+ = _('SSH host key fingerprints')
+ - if ssh_info.blank?
%p
- Below are the fingerprints for the current instance SSH host keys.
+ = _('SSH host keys are not available on this system. Please use <code>ssh-keyscan</code> command or contact your GitLab administrator for more information.').html_safe
+ - else
+ %p
+ = _('Below are the fingerprints for the current instance SSH host keys.')
.table-responsive
%table
%thead
%tr
- %th Algorithm
- %th MD5
- %th SHA256
+ %th
+ = _('Algorithm')
+ %th
+ = _('MD5')
+ %th
+ = _('SHA256')
%tbody
- ssh_info.each do |algorithm|
%tr
diff --git a/app/views/help/show.html.haml b/app/views/help/show.html.haml
index dce27dee9be..dace8a77736 100644
--- a/app/views/help/show.html.haml
+++ b/app/views/help/show.html.haml
@@ -1,3 +1,5 @@
- page_title @path.split("/").reverse.map(&:humanize)
+- @content_class = "limit-container-width" unless fluid_layout
+
.documentation.md.prepend-top-default
= markdown @markdown
diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml
index cdc894ee5a0..b8a421ac9d3 100644
--- a/app/views/help/ui.html.haml
+++ b/app/views/help/ui.html.haml
@@ -508,8 +508,7 @@
.btn-group
%a.btn Edit
%a.btn.btn-danger Remove
- .file-contenta.code
- = render 'shared/file_highlight', blob: blob
+ = render 'shared/file_highlight', blob: blob
%h2#markdown Markdown
%h4
diff --git a/app/views/import/bitbucket/status.html.haml b/app/views/import/bitbucket/status.html.haml
index 2336e1e83f9..7399ff937ce 100644
--- a/app/views/import/bitbucket/status.html.haml
+++ b/app/views/import/bitbucket/status.html.haml
@@ -8,7 +8,6 @@
- if @repos.any?
%p.light
= _('Select projects you want to import.')
- %hr
%p
- if @incompatible_repos.any?
= button_tag class: 'btn btn-import btn-success js-import-all' do
@@ -19,6 +18,14 @@
= _('Import all projects')
= icon('spinner spin', class: 'loading-icon')
+.position-relative.ms-no-clear.d-flex.flex-fill.float-right.append-bottom-10
+ = form_tag status_import_bitbucket_path, method: 'get' do
+ = text_field_tag :filter, @filter, class: 'form-control pr-5', placeholder: _('Filter projects'), size: 40, autofocus: true, 'aria-label': _('Search')
+ .position-absolute.position-top-0.d-flex.align-items-center.text-muted.position-right-0.h-100
+ .border-left
+ %button{ class: 'btn btn-transparent btn-secondary', 'aria-label': _('Search Button'), type: 'submit' }
+ %i{ class: 'fa fa-search', 'aria-hidden': true }
+
.table-responsive
%table.table.import-jobs
%colgroup.import-jobs-from-col
@@ -59,7 +66,7 @@
- if current_user.can_select_namespace?
- selected = params[:namespace_id] || :current_user
- opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.owner, path: repo.owner) } : {}
- = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace', tabindex: 1 }
+ = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'select2 js-select-namespace', tabindex: 1 }
- else
= text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true
%span.input-group-prepend
diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml
index aac09801d91..1aaf5883bf4 100644
--- a/app/views/import/bitbucket_server/status.html.haml
+++ b/app/views/import/bitbucket_server/status.html.haml
@@ -62,7 +62,7 @@
- if current_user.can_select_namespace?
- selected = params[:namespace_id] || :extra_group
- opts = current_user.can_create_group? ? { extra_group: Group.new(name: sanitize_project_name(repo.project_key), path: sanitize_project_name(repo.project_key)) } : {}
- = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace', tabindex: 1 }
+ = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'select2 js-select-namespace', tabindex: 1 }
- else
= text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true
%span.input-group-prepend
diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml
index d673d7164b3..92572f0308c 100644
--- a/app/views/layouts/_flash.html.haml
+++ b/app/views/layouts/_flash.html.haml
@@ -5,4 +5,5 @@
- if value
%div{ class: "flash-content flash-#{key} rounded" }
%span= value
- = sprite_icon('close', size: 16, css_class: 'close-icon')
+ %div{ class: "close-icon-wrapper js-close-icon" }
+ = sprite_icon('close', size: 16, css_class: 'close-icon')
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 68abfd3f61f..b8c9f0ae1e8 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -1,5 +1,17 @@
- page_description brand_title unless page_description
+-# Needs a redirect on the client side since it's using an anchor to distuingish
+-# between sign in and registration. We need to inline the JS to not render
+-# anything from this page beforehand.
+-# Part of an experiment to build a new sign up flow. Will be removed again with
+-# https://gitlab.com/gitlab-org/growth/engineering/issues/64
+- if experiment_enabled?(:signup_flow) && current_path?("sessions#new")
+ = javascript_tag nonce: true do
+ :plain
+ if (window.location.hash === '#register-pane') {
+ window.location.replace("/users/sign_up")
+ }
+
- site_name = "GitLab"
%head{ prefix: "og: http://ogp.me/ns#" }
%meta{ charset: "utf-8" }
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 443a73f5cce..6cdb85456c3 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -17,4 +17,6 @@
%div{ class: "#{(container_class unless @no_container)} #{@content_class}" }
.content{ id: "content-body" }
= render "layouts/flash", extra_flash_class: 'limit-container-width'
+ - if Gitlab.com?
+ = render_if_exists "layouts/privacy_policy_update_callout"
= yield
diff --git a/app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml b/app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml
new file mode 100644
index 00000000000..2f05717fc0e
--- /dev/null
+++ b/app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml
@@ -0,0 +1,26 @@
+!!! 5
+%html.devise-layout-html.navless{ class: system_message_class }
+ = render "layouts/head"
+ %body.ui-indigo.signup-page.application.navless{ class: "#{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } }
+ = header_message
+ = render "layouts/init_client_detection_flags"
+ .page-wrap
+ .container.signup-box-container.navless-container.mt-0
+ = render "layouts/broadcast"
+ .content
+ = render "layouts/flash"
+ .row.mb-3
+ .col-sm-8.offset-sm-2.col-md-6.offset-md-3.new-session-forms-container
+ = render_if_exists 'layouts/devise_help_text'
+ .text-center.signup-heading.mt-3.mb-3
+ = image_tag(image_url('logo.svg'), class: 'gitlab-logo', alt: 'GitLab Logo')
+ - if content_for?(:page_title)
+ %h2= yield :page_title
+ = yield
+ %hr.footer-fixed
+ .footer-container
+ .container
+ .footer-links
+ = link_to _("Help"), help_path
+ = link_to _("About GitLab"), "https://about.gitlab.com/"
+ = footer_message
diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml
index 808290afcad..efe74ddd902 100644
--- a/app/views/layouts/header/_current_user_dropdown.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown.html.haml
@@ -21,6 +21,24 @@
- if current_user_menu?(:settings)
%li
= link_to s_("CurrentUser|Settings"), profile_path, data: { qa_selector: 'settings_link' }
+
+ - if current_user_menu?(:help)
+ %li.divider.d-md-none
+ %li.d-md-none
+ = link_to _("Help"), help_path
+ %li.d-md-none
+ = link_to _("Support"), support_url
+ = render_if_exists "shared/learn_gitlab_menu_item"
+ %li.d-md-none
+ = link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback"
+ - if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile)
+ %li.d-md-none
+ = render 'shared/user_dropdown_contributing_link'
+ = render_if_exists 'shared/user_dropdown_instance_review'
+ - if Gitlab.com?
+ %li.js-canary-link.d-md-none
+ = link_to _("Switch to GitLab Next"), "https://next.gitlab.com/"
+
- if current_user_menu?(:sign_out)
%li.divider
%li
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 14c7b2428b2..d8697be7f7a 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -33,9 +33,9 @@
- if current_user
= render 'layouts/header/new_dropdown'
- if header_link?(:search)
- %li.nav-item.d-none.d-sm-none.d-md-block.m-auto
+ %li.nav-item.d-none.d-lg-block.m-auto
= render 'layouts/search' unless current_controller?(:search)
- %li.nav-item.d-inline-block.d-sm-none.d-md-none
+ %li.nav-item.d-inline-block.d-lg-none
= link_to search_path_url, title: _('Search'), aria: { label: _('Search') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('search', size: 16)
- if header_link?(:issues)
@@ -58,7 +58,7 @@
= sprite_icon('todo-done', size: 16)
%span.badge.badge-pill.todos-count{ class: ('hidden' if todos_pending_count.zero?) }
= todos_count_format(todos_pending_count)
- %li.nav-item.header-help.dropdown
+ %li.nav-item.header-help.dropdown.d-none.d-md-block
= link_to help_path, class: 'header-help-dropdown-toggle', data: { toggle: "dropdown" } do
= sprite_icon('question', size: 16)
= sprite_icon('angle-down', css_class: 'caret-down')
@@ -73,7 +73,7 @@
= render 'layouts/header/current_user_dropdown'
- if has_impersonation_link
%li.nav-item.impersonation.ml-0
- = link_to admin_impersonation_path, class: 'nav-link impersonation-btn', method: :delete, title: _('Stop impersonation'), aria: { label: _('Stop impersonation') }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
+ = link_to admin_impersonation_path, class: 'nav-link impersonation-btn', method: :delete, title: _('Stop impersonation'), aria: { label: _('Stop impersonation') }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body', qa_selector: 'stop_impersonation_link' } do
= icon('user-secret')
- if header_link?(:sign_in)
%li.nav-item
diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml
index 41d7aa3741a..71977b23481 100644
--- a/app/views/layouts/header/_help_dropdown.html.haml
+++ b/app/views/layouts/header/_help_dropdown.html.haml
@@ -9,7 +9,8 @@
%li
= link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback"
- if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile)
- = render 'shared/user_dropdown_contributing_link'
+ %li
+ = render 'shared/user_dropdown_contributing_link'
= render_if_exists 'shared/user_dropdown_instance_review'
- if Gitlab.com?
%li.js-canary-link
diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml
index 7b0824ae2af..5122c2517aa 100644
--- a/app/views/layouts/nav/_dashboard.html.haml
+++ b/app/views/layouts/nav/_dashboard.html.haml
@@ -10,37 +10,24 @@
= render "layouts/nav/projects_dropdown/show"
- if dashboard_nav_link?(:groups)
- = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "home dropdown header-groups qa-groups-dropdown", data: { track_label: "groups_dropdown", track_event: "click_dropdown", track_value: "" } }) do
+ = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "d-none d-md-block home dropdown header-groups qa-groups-dropdown", data: { track_label: "groups_dropdown", track_event: "click_dropdown", track_value: "" } }) do
%button.btn{ type: 'button', data: { toggle: "dropdown" } }
= _('Groups')
= sprite_icon('angle-down', css_class: 'caret-down')
.dropdown-menu.frequent-items-dropdown-menu
= render "layouts/nav/groups_dropdown/show"
- - if dashboard_nav_link?(:activity)
- = nav_link(path: 'dashboard#activity', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do
- = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity' do
- = _('Activity')
-
- - if dashboard_nav_link?(:milestones)
- = nav_link(controller: 'dashboard/milestones', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do
- = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones' do
- = _('Milestones')
-
- - if dashboard_nav_link?(:snippets)
- = nav_link(controller: 'dashboard/snippets', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do
- = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets qa-snippets-link' do
- = _('Snippets')
-
- = render_if_exists 'layouts/nav/sidebar/analytics_link'
-
- if any_dashboard_nav_link?([:groups, :milestones, :activity, :snippets])
- %li.header-more.dropdown.d-xl-none{ class: ('d-lg-none' unless has_extra_nav_icons?) }
- %a{ href: "#", data: { toggle: "dropdown" } }
+ %li.header-more.dropdown
+ %a{ href: "#", data: { toggle: "dropdown", qa_selector: 'more_dropdown' } }
= _('More')
= sprite_icon('angle-down', css_class: 'caret-down')
.dropdown-menu
%ul
+ - if dashboard_nav_link?(:groups)
+ %li.d-md-none
+ = link_to dashboard_groups_path do
+ = _('Groups')
- if dashboard_nav_link?(:activity)
= nav_link(path: 'dashboard#activity') do
= link_to activity_dashboard_path do
@@ -53,51 +40,45 @@
- if dashboard_nav_link?(:snippets)
= nav_link(controller: 'dashboard/snippets') do
- = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets' do
+ = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', data: { qa_selector: 'snippets_link' } do
= _('Snippets')
- = render_if_exists 'layouts/nav/sidebar/analytics_more_link'
+ = render_if_exists 'layouts/nav/sidebar/analytics_link'
- %li.dropdown.d-lg-none
- = render_if_exists 'dashboard/operations/nav_link_list'
+ %li.dropdown
+ = render_if_exists 'dashboard/nav_link_list'
- if can?(current_user, :read_instance_statistics)
- = nav_link(controller: [:conversational_development_index, :cohorts], html_options: { class: 'd-lg-none' }) do
+ = nav_link(controller: [:conversational_development_index, :cohorts]) do
= link_to instance_statistics_root_path do
= _('Instance Statistics')
- if current_user.admin?
= nav_link(controller: 'admin/dashboard') do
- = link_to admin_root_path, class: 'd-lg-none admin-icon qa-admin-area-link' do
+ = link_to admin_root_path, class: 'admin-icon qa-admin-area-link d-xl-none' do
= _('Admin Area')
+ - if Feature.enabled?(:user_mode_in_session)
+ - if header_link?(:admin_mode)
+ = nav_link(controller: 'admin/sessions') do
+ = link_to destroy_admin_session_path, class: 'd-lg-none lock-open-icon' do
+ = _('Leave admin mode')
+ - elsif current_user.admin?
+ = nav_link(controller: 'admin/sessions') do
+ = link_to new_admin_session_path, class: 'd-lg-none lock-icon' do
+ = _('Enter admin mode')
- if Gitlab::Sherlock.enabled?
%li
- = link_to sherlock_transactions_path, class: 'd-lg-none admin-icon' do
+ = link_to sherlock_transactions_path, class: 'admin-icon' do
= _('Sherlock Transactions')
+ - if current_user.admin?
+ = nav_link(controller: 'admin/dashboard', html_options: { class: "d-none d-xl-block"}) do
+ = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = sprite_icon('admin', size: 18)
+
+
-# Shortcut to Dashboard > Projects
- if dashboard_nav_link?(:projects)
%li.hidden
= link_to dashboard_projects_path, class: 'dashboard-shortcuts-projects' do
= _('Projects')
- - if current_controller?('ide')
- %li.line-separator.d-none.d-sm-block
- = nav_link(controller: 'ide') do
- = link_to '#', class: 'dashboard-shortcuts-web-ide' do
- = _('Web IDE')
-
- %li.dropdown{ class: 'd-none d-lg-block' }
- = render_if_exists 'dashboard/operations/nav_link'
- - if can?(current_user, :read_instance_statistics)
- = nav_link(controller: [:conversational_development_index, :cohorts], html_options: { class: "d-none d-lg-block d-xl-block"}) do
- = link_to instance_statistics_root_path, title: _('Instance Statistics'), aria: { label: _('Instance Statistics') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = sprite_icon('chart', size: 18)
- - if current_user.admin?
- = nav_link(controller: 'admin/dashboard', html_options: { class: "d-none d-lg-block d-xl-block"}) do
- = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = sprite_icon('admin', size: 18)
- - if Gitlab::Sherlock.enabled?
- %li
- = link_to sherlock_transactions_path, class: 'admin-icon d-none d-lg-block d-xl-block', title: _('Sherlock Transactions'),
- data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
- = icon('tachometer fw')
= render_if_exists 'layouts/nav/geo_primary_node_url'
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index 784fe556123..b33ef26f87d 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -6,7 +6,7 @@
= sprite_icon('admin', size: 24)
.sidebar-context-title
= _('Admin Area')
- %ul.sidebar-top-level-items
+ %ul.sidebar-top-level-items{ data: { qa_selector: 'admin_sidebar_overview_submenu_content' } }
= nav_link(controller: %w(dashboard admin admin/projects users groups jobs runners gitaly_servers), html_options: {class: 'home'}) do
= link_to admin_root_path, class: 'shortcuts-tree' do
.nav-icon-container
@@ -28,7 +28,7 @@
%span
= _('Projects')
= nav_link(controller: :users) do
- = link_to admin_users_path, title: _('Users') do
+ = link_to admin_users_path, title: _('Users') , data: { qa_selector: 'users_overview_link' } do
%span
= _('Users')
= nav_link(controller: :groups) do
@@ -49,13 +49,13 @@
= _('Gitaly Servers')
= nav_link(controller: admin_monitoring_nav_links) do
- = link_to admin_system_info_path do
+ = link_to admin_system_info_path, data: { qa_selector: 'admin_monitoring_link' } do
.nav-icon-container
= sprite_icon('monitor')
%span.nav-item-name
= _('Monitoring')
- %ul.sidebar-sub-level-items
+ %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_sidebar_monitoring_submenu_content' } }
= nav_link(controller: %w(system_info background_jobs logs health_check requests_profiles), html_options: { class: "fly-out-top-item" } ) do
= link_to admin_system_info_path do
%strong.fly-out-top-item-name
@@ -225,7 +225,7 @@
%span.nav-item-name.qa-admin-settings-item
= _('Settings')
- %ul.sidebar-sub-level-items.qa-admin-sidebar-submenu
+ %ul.sidebar-sub-level-items.qa-admin-sidebar-settings-submenu
= nav_link(controller: :application_settings, html_options: { class: "fly-out-top-item" } ) do
= link_to admin_application_settings_path do
%strong.fly-out-top-item-name
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index 7cc7d1783c4..4930c6cf5f7 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -118,7 +118,7 @@
%strong.fly-out-top-item-name
= _('Kubernetes')
- = render_if_exists 'groups/sidebar/packages' # EE-specific
+ = render_if_exists 'groups/sidebar/packages'
- if group_sidebar_link?(:group_members)
= nav_link(path: 'group_members#index') do
diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml
index 22ac9ee234d..9be39d14169 100644
--- a/app/views/layouts/nav/sidebar/_profile.html.haml
+++ b/app/views/layouts/nav/sidebar/_profile.html.haml
@@ -64,7 +64,7 @@
%strong.fly-out-top-item-name
= _('Access Tokens')
= nav_link(controller: :emails) do
- = link_to profile_emails_path do
+ = link_to profile_emails_path, data: { qa_selector: 'profile_emails_link' } do
.nav-icon-container
= sprite_icon('mail')
%span.nav-item-name
@@ -76,7 +76,7 @@
= _('Emails')
- if current_user.allow_password_authentication?
= nav_link(controller: :passwords) do
- = link_to edit_profile_password_path do
+ = link_to edit_profile_password_path , data: { qa_selector: 'profile_password_link' } do
.nav-icon-container
= sprite_icon('lock')
%span.nav-item-name
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 48fea2bbecf..c84bc0b5cd4 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -182,11 +182,17 @@
= _('Pipelines')
- if project_nav_tab? :builds
- = nav_link(controller: [:jobs, :artifacts]) do
+ = nav_link(controller: :jobs) do
= link_to project_jobs_path(@project), title: _('Jobs'), class: 'shortcuts-builds' do
%span
= _('Jobs')
+ - if Feature.enabled?(:artifacts_management_page, @project)
+ = nav_link(controller: :artifacts, action: :index) do
+ = link_to project_artifacts_path(@project), title: _('Artifacts'), class: 'shortcuts-builds' do
+ %span
+ = _('Artifacts')
+
- if project_nav_tab? :pipelines
= nav_link(controller: :pipeline_schedules) do
= link_to pipeline_schedules_path(@project), title: _('Schedules'), class: 'shortcuts-builds' do
diff --git a/app/views/notify/new_release_email.html.haml b/app/views/notify/new_release_email.html.haml
new file mode 100644
index 00000000000..45e99f3c07a
--- /dev/null
+++ b/app/views/notify/new_release_email.html.haml
@@ -0,0 +1,18 @@
+- release_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url }
+- description_details = { tag: @release.tag, name: @project.name, release_link_start: release_link_start, release_link_end: '</a>'.html_safe }
+
+%div{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;" }
+ %p
+ = _("A new Release %{tag} for %{name} was published. Visit the %{release_link_start}Releases page%{release_link_end} to read more about it.").html_safe % description_details
+
+ %p
+ %h4= _("Assets:")
+ %ul
+ - @release.links.each do |link|
+ %li= link_to(link.name, link.url)
+ - @release.sources.each do |source|
+ %li= link_to(_("Download %{format}") % { format: source.format }, source.url)
+
+ %p
+ %h4= _("Release notes:")
+ = markdown_field(@release, :description)
diff --git a/app/views/notify/new_release_email.text.erb b/app/views/notify/new_release_email.text.erb
new file mode 100644
index 00000000000..e03cf2d5fd1
--- /dev/null
+++ b/app/views/notify/new_release_email.text.erb
@@ -0,0 +1,12 @@
+<%= _("A new Release %{tag} for %{name} was published. Visit the Releases page to read more about it:").html_safe % { tag: @release.tag, name: @project.name } %> <%= @target_url %>
+
+<%= _("Assets:") %>
+<% @release.links.each do |link| -%>
+ - <%= link.name %>: <%= link.url %>
+<% end -%>
+<% @release.sources.each do |source| -%>
+ - <%= _("Download %{format}:") % { format: source.format } %> <%= source.url %>
+<% end -%>
+
+<%= _("Release notes:") %>
+<%= @release.description %>
diff --git a/app/views/notify/pipeline_failed_email.html.haml b/app/views/notify/pipeline_failed_email.html.haml
index 86dcca4a447..f01181857ce 100644
--- a/app/views/notify/pipeline_failed_email.html.haml
+++ b/app/views/notify/pipeline_failed_email.html.haml
@@ -34,7 +34,7 @@
%img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-branch-gray.gif'), style: "display:block;", width: "13", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
%a.muted{ href: commits_url(@pipeline), style: "color:#333333;text-decoration:none;" }
- = @pipeline.ref
+ = @pipeline.source_ref
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Commit
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:400;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
diff --git a/app/views/notify/pipeline_failed_email.text.erb b/app/views/notify/pipeline_failed_email.text.erb
index 722eedf90be..9cd479ef1e6 100644
--- a/app/views/notify/pipeline_failed_email.text.erb
+++ b/app/views/notify/pipeline_failed_email.text.erb
@@ -1,7 +1,7 @@
Your pipeline has failed.
Project: <%= @project.name %> ( <%= project_url(@project) %> )
-Branch: <%= @pipeline.ref %> ( <%= commits_url(@pipeline) %> )
+Branch: <%= @pipeline.source_ref %> ( <%= commits_url(@pipeline) %> )
<% if @merge_request -%>
Merge Request: <%= @merge_request.to_reference %> ( <%= merge_request_url(@merge_request) %> )
<% end -%>
diff --git a/app/views/notify/pipeline_success_email.html.haml b/app/views/notify/pipeline_success_email.html.haml
index 4fe3c4c8269..e575a5569fa 100644
--- a/app/views/notify/pipeline_success_email.html.haml
+++ b/app/views/notify/pipeline_success_email.html.haml
@@ -34,7 +34,7 @@
%img{ height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-branch-gray.gif'), style: "display:block;", width: "13", alt: "" }/
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;vertical-align:middle;" }
%a.muted{ href: commits_url(@pipeline), style: "color:#333333;text-decoration:none;" }
- = @pipeline.ref
+ = @pipeline.source_ref
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Commit
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:400;padding:14px 0;margin:0;color:#333333;width:75%;padding-left:5px;border-top:1px solid #ededed;" }
diff --git a/app/views/notify/pipeline_success_email.text.erb b/app/views/notify/pipeline_success_email.text.erb
index 9aadf380f79..4005158dc9e 100644
--- a/app/views/notify/pipeline_success_email.text.erb
+++ b/app/views/notify/pipeline_success_email.text.erb
@@ -1,7 +1,7 @@
Your pipeline has passed.
Project: <%= @project.name %> ( <%= project_url(@project) %> )
-Branch: <%= @pipeline.ref %> ( <%= commits_url(@pipeline) %> )
+Branch: <%= @pipeline.source_ref %> ( <%= commits_url(@pipeline) %> )
<% if @merge_request -%>
Merge Request: <%= @merge_request.to_reference %> ( <%= merge_request_url(@merge_request) %> )
<% end -%>
diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml
index 3c20518c038..6ea4eeb66c5 100644
--- a/app/views/profiles/emails/index.html.haml
+++ b/app/views/profiles/emails/index.html.haml
@@ -13,9 +13,9 @@
= form_for 'email', url: profile_emails_path do |f|
.form-group
= f.label :email, _('Email'), class: 'label-bold'
- = f.text_field :email, class: 'form-control'
+ = f.text_field :email, class: 'form-control', data: { qa_selector: 'email_address_field' }
.prepend-top-default
- = f.submit _('Add email address'), class: 'btn btn-success'
+ = f.submit _('Add email address'), class: 'btn btn-success', data: { qa_selector: 'add_email_address_button' }
%hr
%h4.prepend-top-0
= _('Linked emails (%{email_count})') % { email_count: @emails.count + 1 }
@@ -45,7 +45,7 @@
- if @primary_email === current_user.notification_email
%span.badge.badge-info= s_('Profiles|Default notification email')
- @emails.each do |email|
- %li
+ %li{ data: { qa_selector: 'email_row_content' } }
= render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? }
%span.float-right
- if email.email === current_user.commit_email
@@ -58,6 +58,6 @@
- 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: 'btn btn-sm btn-warning prepend-left-10'
- = link_to profile_email_path(email), data: { confirm: _('Are you sure?')}, method: :delete, class: 'btn btn-sm btn-danger prepend-left-10' do
+ = link_to profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, class: 'btn btn-sm btn-danger prepend-left-10' do
%span.sr-only= _('Remove')
= icon('trash')
diff --git a/app/views/profiles/notifications/_group_settings.html.haml b/app/views/profiles/notifications/_group_settings.html.haml
index 1776d260e19..33b0aa93d84 100644
--- a/app/views/profiles/notifications/_group_settings.html.haml
+++ b/app/views/profiles/notifications/_group_settings.html.haml
@@ -12,5 +12,5 @@
= render 'shared/notifications/button', notification_setting: setting, emails_disabled: emails_disabled
.table-section.section-30
- = form_for @user.notification_settings.find { |ns| ns.source == group }, url: profile_notifications_group_path(group), method: :put, html: { class: 'update-notifications' } do |f|
+ = form_for setting, url: profile_notifications_group_path(group), method: :put, html: { class: 'update-notifications' } do |f|
= f.select :notification_email, @user.all_emails, { include_blank: 'Global notification email' }, class: 'select2 js-group-notification-email'
diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml
index ac8c31189d0..0e2b0430fec 100644
--- a/app/views/profiles/passwords/edit.html.haml
+++ b/app/views/profiles/passwords/edit.html.haml
@@ -20,16 +20,16 @@
- unless @user.password_automatically_set?
.form-group
= f.label :current_password, _('Current password'), class: 'label-bold'
- = f.password_field :current_password, required: true, class: 'form-control'
+ = f.password_field :current_password, required: true, class: 'form-control', data: { qa_selector: 'current_password_field' }
%p.form-text.text-muted
= _('You must provide your current password in order to change it.')
.form-group
= f.label :password, _('New password'), class: 'label-bold'
- = f.password_field :password, required: true, class: 'form-control'
+ = f.password_field :password, required: true, class: 'form-control', data: { qa_selector: 'new_password_field' }
.form-group
= f.label :password_confirmation, _('Password confirmation'), class: 'label-bold'
- = f.password_field :password_confirmation, required: true, class: 'form-control'
+ = f.password_field :password_confirmation, required: true, class: 'form-control', data: { qa_selector: 'confirm_password_field' }
.prepend-top-default.append-bottom-default
- = f.submit _('Save password'), class: "btn btn-success append-right-10"
+ = f.submit _('Save password'), class: "btn btn-success append-right-10", data: { qa_selector: 'save_password_button' }
- unless @user.password_automatically_set?
= link_to _('I forgot my password'), reset_profile_password_path, method: :put, class: "account-btn-link"
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 0576f51fa83..68b7efc6fb4 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -94,6 +94,7 @@
- else
= f.text_field :name, label: s_('Profiles|Full name'), required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead"), wrapper: { class: 'col-md-9 qa-full-name rspec-full-name' }, help: s_("Profiles|Enter your name, so people you know can recognize you")
= f.text_field :id, readonly: true, label: s_('Profiles|User ID'), wrapper: { class: 'col-md-3' }
+ = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, {}, class: 'input-md'
= render_if_exists 'profiles/email_settings', form: f
= f.text_field :skype, class: 'input-md', placeholder: s_("Profiles|username")
diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml
index f564ed41760..e4129a91daf 100644
--- a/app/views/projects/_export.html.haml
+++ b/app/views/projects/_export.html.haml
@@ -10,12 +10,8 @@
%p.append-bottom-0
%p= _('The following items will be exported:')
%ul
- %li= _('Project and wiki repositories')
- %li= _('Project uploads')
- %li= _('Project configuration, including services')
- %li= _('Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities')
- %li= _('LFS objects')
- %li= _('Issue Boards')
+ - project_export_descriptions.each do |desc|
+ %li= desc
%p= _('The following items will NOT be exported:')
%ul
%li= _('Job traces and artifacts')
diff --git a/app/views/projects/_new_project_push_tip.html.haml b/app/views/projects/_new_project_push_tip.html.haml
index 22e9522c0e7..e008130436c 100644
--- a/app/views/projects/_new_project_push_tip.html.haml
+++ b/app/views/projects/_new_project_push_tip.html.haml
@@ -6,6 +6,6 @@
%span
= text_field_tag :push_to_create_tip, push_to_create_project_command, class: "js-select-on-focus form-control monospace", readonly: true, aria: { label: _("Push project from command line") }
%span.input-group-append
- = clipboard_button(text: push_to_create_project_command, title: _("Copy command to clipboard"), class: 'input-group-text', placement: "right")
+ = clipboard_button(text: push_to_create_project_command, title: _("Copy command"), class: 'input-group-text', placement: "right")
%p
= link_to("What does this command do?", help_page_path("gitlab-basics/create-project", anchor: "push-to-create-a-new-project"), target: "_blank")
diff --git a/app/views/projects/artifacts/_artifact.html.haml b/app/views/projects/artifacts/_artifact.html.haml
new file mode 100644
index 00000000000..36e149556e0
--- /dev/null
+++ b/app/views/projects/artifacts/_artifact.html.haml
@@ -0,0 +1,61 @@
+.gl-responsive-table-row.px-md-3
+ .table-section.section-25.section-wrap.commit
+ .table-mobile-header{ role: 'rowheader' }= _('Job')
+ .table-mobile-content
+ .branch-commit.cgray
+ - if can?(current_user, :read_build, @project)
+ = link_to project_job_path(@project, artifact.job) do
+ %span.build-link ##{artifact.job_id}
+ - else
+ %span.build-link ##{artifact.job_id}
+
+ - if artifact.job.ref
+ .icon-container{ "aria-label" => artifact.job.tag? ? _('Tag') : _('Branch') }
+ = artifact.job.tag? ? sprite_icon('tag', css_class: 'sprite') : sprite_icon('branch', css_class: 'sprite')
+ = link_to artifact.job.ref, project_ref_path(@project, artifact.job.ref), class: 'ref-name'
+ - else
+ .light= _('none')
+ .icon-container.commit-icon{ "aria-label" => _('Commit') }
+ = sprite_icon('commit')
+
+ - if artifact.job.sha
+ = link_to artifact.job.short_sha, project_commit_path(@project, artifact.job.sha), class: 'commit-sha mr-0'
+
+ .table-section.section-15.section-wrap
+ .table-mobile-header{ role: 'rowheader' }= _('Name')
+ .table-mobile-content
+ = artifact.job.name
+
+ .table-section.section-20
+ .table-mobile-header{ role: 'rowheader' }= _('Creation date')
+ .table-mobile-content
+ %p.finished-at
+ = icon("calendar")
+ %span= time_ago_with_tooltip(artifact.created_at)
+
+ .table-section.section-20
+ .table-mobile-header{ role: 'rowheader' }= _('Expiration date')
+ .table-mobile-content
+ - if artifact.expire_at
+ %p.finished-at
+ = icon("calendar")
+ %span= time_ago_with_tooltip(artifact.expire_at)
+
+ .table-section.section-10
+ .table-mobile-header{ role: 'rowheader' }= _('Size')
+ .table-mobile-content
+ = number_to_human_size(artifact.size, precision: 2)
+
+ .table-section.table-button-footer.section-10
+ .table-action-buttons
+ .btn-group
+ - if can?(current_user, :read_build, @project)
+ = link_to download_project_job_artifacts_path(@project, artifact.job), rel: 'nofollow', download: '', title: _('Download artifacts'), data: { placement: 'top', container: 'body' }, ref: 'tooltip', aria: { label: _('Download artifacts') }, class: 'btn btn-build has-tooltip ml-0' do
+ = sprite_icon('download')
+
+ = link_to browse_project_job_artifacts_path(@project, artifact.job), rel: 'nofollow', title: _('Browse artifacts'), data: { placement: 'top', container: 'body' }, ref: 'tooltip', aria: { label: _('Browse artifacts') }, class: 'btn btn-build has-tooltip' do
+ = sprite_icon('folder-open')
+
+ - if can?(current_user, :destroy_artifacts, @project)
+ = link_to project_artifact_path(@project, artifact), data: { placement: 'top', container: 'body', confirm: _('Are you sure you want to delete these artifacts?') }, method: :delete, title: _('Delete artifacts'), ref: 'tooltip', aria: { label: _('Delete artifacts') }, class: 'btn btn-remove has-tooltip' do
+ = sprite_icon('remove')
diff --git a/app/views/projects/artifacts/_table.html.haml b/app/views/projects/artifacts/_table.html.haml
new file mode 100644
index 00000000000..1963449d704
--- /dev/null
+++ b/app/views/projects/artifacts/_table.html.haml
@@ -0,0 +1,16 @@
+- if artifacts.blank?
+ .nothing-here-block= _('No jobs to show')
+- else
+ .table-holder
+ .ci-table
+ .gl-responsive-table-row.table-row-header.px-md-3{ role: 'row' }
+ .table-section.section-25{ role: 'rowheader' }= _('Job')
+ .table-section.section-15{ role: 'rowheader' }= _('Name')
+ .table-section.section-20{ role: 'rowheader' }= _('Creation date')
+ .table-section.section-20{ role: 'rowheader' }= _('Expiration date')
+ .table-section.section-10{ role: 'rowheader' }= _('Size')
+ .table-section.section-10{ role: 'rowheader' }
+
+ = render partial: 'artifact', collection: artifacts, as: :artifact
+
+ = paginate artifacts, theme: "gitlab", total_pages: @total_pages
diff --git a/app/views/projects/artifacts/index.html.haml b/app/views/projects/artifacts/index.html.haml
new file mode 100644
index 00000000000..1ab3e8e67d8
--- /dev/null
+++ b/app/views/projects/artifacts/index.html.haml
@@ -0,0 +1,10 @@
+- @no_container = true
+- page_title _('Artifacts')
+
+%div{ class: container_class }
+ .top-area.py-3
+ .align-self-center
+ = _('Total artifacts size: %{total_size}') % { total_size: number_to_human_size(@total_size, precicion: 2) }
+
+ .content-list.builds-content-list
+ = render "table", artifacts: @artifacts, project: @project
diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml
index 283b845e40d..961b873b571 100644
--- a/app/views/projects/blob/_editor.html.haml
+++ b/app/views/projects/blob/_editor.html.haml
@@ -3,20 +3,22 @@
- is_markdown = Gitlab::MarkupHelper.gitlab_markdown?(file_name)
.file-holder-bottom-radius.file-holder.file.append-bottom-default
- .js-file-title.file-title.clearfix{ data: { current_action: action } }
+ .js-file-title.file-title.align-items-center.clearfix{ data: { current_action: action } }
.editor-ref.block-truncated
= sprite_icon('fork', size: 12)
= ref
- if current_action?(:edit) || current_action?(:update)
%span.pull-left.append-right-10
- = text_field_tag 'file_path', (params[:file_path] || @path),
- class: 'form-control new-file-path js-file-path-name-input'
+ = text_field_tag 'file_path', (params[:file_path] || @path),
+ class: 'form-control new-file-path js-file-path-name-input'
+ = render 'template_selectors'
- if current_action?(:new) || current_action?(:create)
%span.pull-left.append-right-10
\/
= text_field_tag 'file_name', params[:file_name], placeholder: "File name",
required: true, class: 'form-control new-file-name js-file-path-name-input'
+ = render 'template_selectors'
.file-buttons
- if is_markdown
diff --git a/app/views/projects/blob/_template_selectors.html.haml b/app/views/projects/blob/_template_selectors.html.haml
index bd46f5a4349..5ecfa135446 100644
--- a/app/views/projects/blob/_template_selectors.html.haml
+++ b/app/views/projects/blob/_template_selectors.html.haml
@@ -1,17 +1,12 @@
-.template-selectors-menu
- .templates-selectors-label
- Template
+.template-selectors-menu.gl-pl-2
.template-selector-dropdowns-wrap
.template-type-selector.js-template-type-selector-wrap.hidden
- = dropdown_tag("Choose type", options: { toggle_class: 'js-template-type-selector qa-template-type-dropdown', title: "Choose a template type" } )
+ = dropdown_tag(_("Select a template type"), options: { toggle_class: 'js-template-type-selector qa-template-type-dropdown', dropdown_class: 'dropdown-menu-selectable'} )
.license-selector.js-license-selector-wrap.js-template-selector-wrap.hidden
- = dropdown_tag("Apply a license template", options: { toggle_class: 'js-license-selector qa-license-dropdown', title: "Apply a license", filter: true, placeholder: "Filter", data: { data: licenses_for_select(@project), project: @project.name, fullname: @project.namespace.human_name } } )
+ = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-license-selector qa-license-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: licenses_for_select(@project), project: @project.name, fullname: @project.namespace.human_name } } )
.gitignore-selector.js-gitignore-selector-wrap.js-template-selector-wrap.hidden
- = dropdown_tag("Apply a .gitignore template", options: { toggle_class: 'js-gitignore-selector qa-gitignore-dropdown', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: gitignore_names(@project) } } )
+ = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitignore-selector qa-gitignore-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitignore_names(@project) } } )
.gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.js-template-selector-wrap.hidden
- = dropdown_tag("Apply a GitLab CI Yaml template", options: { toggle_class: 'js-gitlab-ci-yml-selector qa-gitlab-ci-yml-dropdown', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project) } } )
+ = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitlab-ci-yml-selector qa-gitlab-ci-yml-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project) } } )
.dockerfile-selector.js-dockerfile-selector-wrap.js-template-selector-wrap.hidden
- = dropdown_tag("Apply a Dockerfile template", options: { toggle_class: 'js-dockerfile-selector qa-dockerfile-dropdown', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: dockerfile_names(@project) } } )
- .template-selectors-undo-menu.hidden
- %span.text-info Template applied
- %button.btn.btn-sm.btn-info Undo
+ = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-dockerfile-selector qa-dockerfile-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: dockerfile_names(@project) } } )
diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml
index 51e42091ab8..870e37488cf 100644
--- a/app/views/projects/blob/edit.html.haml
+++ b/app/views/projects/blob/edit.html.haml
@@ -11,7 +11,6 @@
.editor-title-row
%h3.page-title.blob-edit-page-title
Edit file
- = render 'template_selectors'
.file-editor
%ul.nav-links.no-bottom.js-edit-mode.nav.nav-tabs
%li.active
diff --git a/app/views/projects/blob/new.html.haml b/app/views/projects/blob/new.html.haml
index 4be87b9e074..c5e3923f1e8 100644
--- a/app/views/projects/blob/new.html.haml
+++ b/app/views/projects/blob/new.html.haml
@@ -5,7 +5,6 @@
.editor-title-row
%h3.page-title.blob-new-page-title
New file
- = render 'template_selectors'
.file-editor
= form_tag(project_create_blob_path(@project, @id), method: :post, class: 'js-edit-blob-form js-new-blob-form js-quick-submit js-requires-input', data: blob_editor_paths(@project)) do
= render 'projects/blob/editor', ref: @ref
diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml
index 688b8f001c3..7c73bbc7479 100644
--- a/app/views/projects/blob/show.html.haml
+++ b/app/views/projects/blob/show.html.haml
@@ -1,6 +1,6 @@
- breadcrumb_title "Repository"
- page_title @blob.path, @ref
-- signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @last_commit)
+- signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @last_commit, limit: 1)
.js-signature-container{ data: { 'signatures-path': signatures_path } }
diff --git a/app/views/projects/blob/viewers/_audio.html.haml b/app/views/projects/blob/viewers/_audio.html.haml
new file mode 100644
index 00000000000..dbdf243c36b
--- /dev/null
+++ b/app/views/projects/blob/viewers/_audio.html.haml
@@ -0,0 +1,2 @@
+.file-content.audio
+ %audio{ src: blob_raw_path, controls: true, data: { setup: '{}' } }
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index dbff2115f50..3e53cb510b0 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -34,8 +34,9 @@
= _('Merge request')
- if branch.name != @repository.root_ref
- = link_to project_compare_path(@project, @repository.root_ref, branch.name),
+ = link_to project_compare_index_path(@project, from: @repository.root_ref, to: branch.name),
class: "btn btn-default js-onboarding-compare-branches #{'prepend-left-10' unless merge_project}",
+ method: :post,
title: s_('Branches|Compare') do
= s_('Branches|Compare')
diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml
index 09f05b30433..abef33ca01c 100644
--- a/app/views/projects/buttons/_clone.html.haml
+++ b/app/views/projects/buttons/_clone.html.haml
@@ -13,7 +13,7 @@
.input-group
= text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control qa-ssh-clone-url", readonly: true, aria: { label: 'Project clone URL' }
.input-group-append
- = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard")
+ = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard")
= render_if_exists 'projects/buttons/geo'
- if http_enabled?
%li.pt-2
@@ -22,7 +22,7 @@
.input-group
= text_field_tag :http_project_clone, project.http_url_to_repo, class: "js-select-on-focus form-control qa-http-clone-url", readonly: true, aria: { label: 'Project clone URL' }
.input-group-append
- = clipboard_button(target: '#http_project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard")
+ = clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard")
= render_if_exists 'projects/buttons/geo'
= render_if_exists 'projects/buttons/kerberos_clone_field'
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 6c77036a85b..d07407a6d13 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -6,7 +6,7 @@
%strong
#{ s_('CommitBoxTitle|Commit') }
%span.commit-sha{ data: { qa_selector: 'commit_sha_content' } }= @commit.short_id
- = clipboard_button(text: @commit.id, title: _('Copy commit SHA to clipboard'))
+ = clipboard_button(text: @commit.id, title: _('Copy commit SHA'))
%span.d-none.d-sm-inline= _('authored')
#{time_ago_with_tooltip(@commit.authored_date)}
%span= s_('ByAuthor|by')
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index 2c78e74be2f..3a9c7a8bec5 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -6,7 +6,8 @@
- merge_request = local_assigns.fetch(:merge_request, nil)
- project = local_assigns.fetch(:project) { merge_request&.project }
- ref = local_assigns.fetch(:ref) { merge_request&.source_branch }
-- commit_status = commit.present(current_user: current_user).status_for(ref)
+- commit = commit.present(current_user: current_user)
+- commit_status = commit.status_for(ref)
- link = commit_path(project, commit, merge_request: merge_request)
@@ -48,12 +49,12 @@
= render partial: 'projects/commit/ajax_signature', locals: { commit: commit }
- if commit_status
- = render_commit_status(commit, ref: ref)
+ = render_commit_status(commit, commit_status, ref: ref)
.js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id, ref: ref) } }
.commit-sha-group.d-none.d-sm-flex
.label.label-monospace.monospace
= commit.short_id
- = clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard"), class: "btn btn-default", container: "body")
+ = clipboard_button(text: commit.id, title: _("Copy commit SHA"), class: "btn btn-default", container: "body")
= link_to_browse_code(project, commit)
diff --git a/app/views/projects/cycle_analytics/_overview.html.haml b/app/views/projects/cycle_analytics/_overview.html.haml
index 5b0d73b8c68..ea94b637f89 100644
--- a/app/views/projects/cycle_analytics/_overview.html.haml
+++ b/app/views/projects/cycle_analytics/_overview.html.haml
@@ -9,7 +9,7 @@
Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.
To set up CA, you must first define a production environment by setting up your CI and then deploy to production.
%p
- %a.btn{ href: help_page_path('user/project/cycle_analytics'), target: '_blank' } Read more
+ %a.btn{ href: help_page_path('user/analytics/cycle_analytics.md'), target: '_blank' } Read more
.col-md-6.overview-image
%span.overview-icon
= custom_icon ('icon_cycle_analytics_overview')
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index 6b56a4ee7ab..7fedd1ab785 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -3,7 +3,7 @@
#cycle-analytics{ "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } }
- if @cycle_analytics_no_data
%banner{ "v-if" => "!isOverviewDialogDismissed",
- "documentation-link": help_page_path('user/project/cycle_analytics'),
+ "documentation-link": help_page_path('user/analytics/cycle_analytics.md'),
"v-on:dismiss-overview-dialog" => "dismissOverviewDialog()" }
= icon("spinner spin", "v-show" => "isLoading")
.wrapper{ "v-show" => "!isLoading && !hasError" }
diff --git a/app/views/projects/deploy_tokens/_new_deploy_token.html.haml b/app/views/projects/deploy_tokens/_new_deploy_token.html.haml
index c805ee73acc..f295fa82192 100644
--- a/app/views/projects/deploy_tokens/_new_deploy_token.html.haml
+++ b/app/views/projects/deploy_tokens/_new_deploy_token.html.haml
@@ -7,12 +7,12 @@
.input-group
= text_field_tag 'deploy-token-user', deploy_token.username, readonly: true, class: 'deploy-token-field form-control js-select-on-focus qa-deploy-token-user'
.input-group-append
- = clipboard_button(text: deploy_token.username, title: s_('DeployTokens|Copy username to clipboard'), placement: 'left')
+ = clipboard_button(text: deploy_token.username, title: s_('DeployTokens|Copy username'), placement: 'left')
%span.deploy-token-help-block.prepend-top-5.text-success= s_("DeployTokens|Use this username as a login.")
.form-group
.input-group
= text_field_tag 'deploy-token', deploy_token.token, readonly: true, class: 'deploy-token-field form-control js-select-on-focus qa-deploy-token'
.input-group-append
- = clipboard_button(text: deploy_token.token, title: s_('DeployTokens|Copy deploy token to clipboard'), placement: 'left')
+ = clipboard_button(text: deploy_token.token, title: s_('DeployTokens|Copy deploy token'), placement: 'left')
%span.deploy-token-help-block.prepend-top-5.text-danger= s_("DeployTokens|Use this token as a password. Make sure you save it - you won't be able to access it again.")
diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml
index ef2ab4c698e..8270477ed3f 100644
--- a/app/views/projects/deployments/_deployment.html.haml
+++ b/app/views/projects/deployments/_deployment.html.haml
@@ -1,31 +1,49 @@
.gl-responsive-table-row.deployment{ role: 'row' }
+ .table-section.section-15{ role: 'gridcell' }
+ .table-mobile-header{ role: 'rowheader' }= _("Status")
+ .table-mobile-content
+ = render_deployment_status(deployment)
+
.table-section.section-10{ role: 'gridcell' }
.table-mobile-header{ role: 'rowheader' }= _("ID")
%strong.table-mobile-content ##{deployment.iid}
- .table-section.section-30{ role: 'gridcell' }
+ .table-section.section-10{ role: 'gridcell' }
+ .table-mobile-header{ role: 'rowheader' }= _("Triggerer")
+ .table-mobile-content
+ - if deployment.deployed_by
+ = user_avatar(user: deployment.deployed_by, size: 26, css_class: "mr-0 float-none")
+
+ .table-section.section-25{ role: 'gridcell' }
.table-mobile-header{ role: 'rowheader' }= _("Commit")
= render 'projects/deployments/commit', deployment: deployment
- .table-section.section-25.build-column{ role: 'gridcell' }
+ .table-section.section-10.build-column{ role: 'gridcell' }
.table-mobile-header{ role: 'rowheader' }= _("Job")
- if deployment.deployable
.table-mobile-content
.flex-truncate-parent
.flex-truncate-child
- = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable], class: 'build-link' do
+ = link_to deployment_path(deployment), class: 'build-link' do
#{deployment.deployable.name} (##{deployment.deployable.id})
- - if deployment.deployed_by
- %div
- by
- = user_avatar(user: deployment.deployed_by, size: 20, css_class: "mr-0 float-none")
+ - else
+ .badge.badge-info.suggestion-help-hover{ title: s_('Deployment|This deployment was created using the API') }
+ = s_('Deployment|API')
- .table-section.section-15{ role: 'gridcell' }
+ .table-section.section-10{ role: 'gridcell' }
.table-mobile-header{ role: 'rowheader' }= _("Created")
+ %span.table-mobile-content.flex-truncate-parent
+ %span.flex-truncate-child
+ = time_ago_with_tooltip(deployment.created_at)
+
+ .table-section.section-10{ role: 'gridcell' }
+ .table-mobile-header{ role: 'rowheader' }= _("Deployed")
- if deployment.deployed_at
- %span.table-mobile-content= time_ago_with_tooltip(deployment.deployed_at)
+ %span.table-mobile-content.flex-truncate-parent
+ %span.flex-truncate-child
+ = time_ago_with_tooltip(deployment.deployed_at)
- .table-section.section-20.table-button-footer{ role: 'gridcell' }
+ .table-section.section-10.table-button-footer{ role: 'gridcell' }
.btn-group.table-action-buttons
= render 'projects/deployments/actions', deployment: deployment
= render 'projects/deployments/rollback', deployment: deployment
diff --git a/app/views/projects/deployments/_rollback.haml b/app/views/projects/deployments/_rollback.haml
index d6bf8d564de..dffa5e4ba40 100644
--- a/app/views/projects/deployments/_rollback.haml
+++ b/app/views/projects/deployments/_rollback.haml
@@ -1,4 +1,4 @@
-- if can?(current_user, :create_deployment, deployment)
+- if deployment.deployable && can?(current_user, :create_deployment, deployment)
- tooltip = deployment.last? ? s_('Environments|Re-deploy to environment') : s_('Environments|Rollback environment')
= button_tag class: 'btn btn-default btn-build has-tooltip', type: 'button', data: { toggle: 'modal', target: "#confirm-rollback-modal-#{deployment.id}" }, title: tooltip do
- if deployment.last?
diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml
index 2dba3fcd664..cf7fe36af9d 100644
--- a/app/views/projects/diffs/_diffs.html.haml
+++ b/app/views/projects/diffs/_diffs.html.haml
@@ -21,7 +21,7 @@
= parallel_diff_btn
= render 'projects/diffs/stats', diff_files: diff_files
-- if render_overflow_warning?(diff_files)
+- if render_overflow_warning?(diffs)
= render 'projects/diffs/warning', diff_files: diffs
.files{ data: { can_create_note: can_create_note } }
diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml
index c9057f385da..86e6e732610 100644
--- a/app/views/projects/diffs/_stats.html.haml
+++ b/app/views/projects/diffs/_stats.html.haml
@@ -24,9 +24,9 @@
%a.diff-changed-file{ href: "##{hexdigest(diff_file.file_path)}", title: diff_file.new_path }
= sprite_icon(diff_file_changed_icon(diff_file), size: 16, css_class: "#{diff_file_changed_icon_color(diff_file)} diff-file-changed-icon append-right-8")
%span.diff-changed-file-content.append-right-8
- - if diff_file.blob&.name
+ - if diff_file.file_path
%strong.diff-changed-file-name
- = diff_file.blob.name
+ = diff_file.file_path
- else
%strong.diff-changed-blank-file-name
= s_('Diffs|No file name available')
diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml
index 75da151f329..c4c39c227c6 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -60,10 +60,13 @@
.table-holder
.ci-table.environments{ role: 'grid' }
.gl-responsive-table-row.table-row-header{ role: 'row' }
+ .table-section.section-15{ role: 'columnheader' }= _('Status')
.table-section.section-10{ role: 'columnheader' }= _('ID')
- .table-section.section-30{ role: 'columnheader' }= _('Commit')
- .table-section.section-25{ role: 'columnheader' }= _('Job')
- .table-section.section-15{ role: 'columnheader' }= _('Created')
+ .table-section.section-10{ role: 'columnheader' }= _('Triggerer')
+ .table-section.section-25{ role: 'columnheader' }= _('Commit')
+ .table-section.section-10{ role: 'columnheader' }= _('Job')
+ .table-section.section-10{ role: 'columnheader' }= _('Created')
+ .table-section.section-10{ role: 'columnheader' }= _('Deployed')
= render @deployments
diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml
index 82f035f24da..caaf164a763 100644
--- a/app/views/projects/find_file/show.html.haml
+++ b/app/views/projects/find_file/show.html.haml
@@ -15,4 +15,12 @@
.table-holder
%table.table.files-slider{ class: "table_#{@hex_path} tree-table" }
%tbody
+ .col-12.empty-state.hidden
+ .svg-250.svg-content
+ = image_tag('illustrations/profile-page/personal-projects.svg', alt: 'No files svg', lazy: true)
+ .text-center
+ %h4
+ = _('There are no matching files')
+ %p.text-secondary
+ = _('Try using a different search term to find the file you are looking for.')
= spinner nil, true
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index de0c21e7cf6..367b8c1138e 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -43,7 +43,10 @@
.issuable-meta
%ul.controls
- - if issue.closed?
+ - if issue.moved?
+ %li.issuable-status
+ = _('CLOSED (MOVED)')
+ - elsif issue.closed?
%li.issuable-status
= _('CLOSED')
- if issue.assignees.any?
diff --git a/app/views/projects/issues/import_csv/_button.html.haml b/app/views/projects/issues/import_csv/_button.html.haml
index acc2c50294f..fe89d2fb748 100644
--- a/app/views/projects/issues/import_csv/_button.html.haml
+++ b/app/views/projects/issues/import_csv/_button.html.haml
@@ -3,7 +3,7 @@
%button.csv-import-button.btn{ title: _('Import CSV'), class: ('has-tooltip' if type == :icon),
data: { toggle: 'modal', target: '.issues-import-modal' } }
- if type == :icon
- = sprite_icon('upload')
+ = sprite_icon('import')
- else
= _('Import CSV')
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index 49e482ff1df..2633a3899f7 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -20,4 +20,5 @@
- if new_issue_email
= render 'projects/issuable_by_email', email: new_issue_email, issuable_type: 'issue'
- else
- = render 'shared/empty_states/issues', button_path: new_project_issue_path(@project), show_import_button: true
+ - new_project_issue_button_path = @project.archived? ? false : new_project_issue_path(@project)
+ = render 'shared/empty_states/issues', new_project_issue_button_path: new_project_issue_button_path, show_import_button: true
diff --git a/app/views/projects/merge_requests/_how_to_merge.html.haml b/app/views/projects/merge_requests/_how_to_merge.html.haml
index 928b54ea28f..57205682bda 100644
--- a/app/views/projects/merge_requests/_how_to_merge.html.haml
+++ b/app/views/projects/merge_requests/_how_to_merge.html.haml
@@ -9,7 +9,7 @@
%p
%strong Step 1.
Fetch and check out the branch for this merge request
- = clipboard_button(target: "pre#merge-info-1", title: "Copy commands to clipboard")
+ = clipboard_button(target: "pre#merge-info-1", title: _("Copy commands"))
%pre.dark#merge-info-1
- if @merge_request.for_fork?
:preserve
@@ -27,7 +27,7 @@
%p
%strong Step 3.
Merge the branch and fix any conflicts that come up
- = clipboard_button(target: "pre#merge-info-3", title: "Copy commands to clipboard")
+ = clipboard_button(target: "pre#merge-info-3", title: _("Copy commands"))
%pre.dark#merge-info-3
- if @merge_request.for_fork?
:preserve
@@ -42,7 +42,7 @@
%p
%strong Step 4.
Push the result of the merge to GitLab
- = clipboard_button(target: "pre#merge-info-4", title: "Copy commands to clipboard")
+ = clipboard_button(target: "pre#merge-info-4", title: _("Copy commands"))
%pre.dark#merge-info-4
:preserve
git push origin "#{h @merge_request.target_branch}"
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index da90c41e2f5..dee6bc8bae4 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -24,6 +24,7 @@
window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}';
window.gl.mrWidgetData.troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/index.md', anchor: 'troubleshooting')}';
+ window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.html', anchor: 'security-approvals-in-merge-requests-ultimate')}';
#js-vue-mr-widget.mr-widget
diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml
index 104c68919f0..80d2d2afada 100644
--- a/app/views/projects/mirrors/_mirror_repos.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos.html.haml
@@ -1,7 +1,7 @@
- expanded = expanded_by_default?
- protocols = Gitlab::UrlSanitizer::ALLOWED_SCHEMES.join('|')
-%section.settings.project-mirror-settings.js-mirror-settings.no-animate.qa-mirroring-repositories-settings#js-push-remote-settings{ class: ('expanded' if expanded) }
+%section.settings.project-mirror-settings.js-mirror-settings.no-animate#js-push-remote-settings{ class: ('expanded' if expanded), data: { qa_selector: 'mirroring_repositories_settings_section' } }
.settings-header
%h4= _('Mirroring repositories')
%button.btn.js-settings-toggle
@@ -59,10 +59,10 @@
- if mirror.disabled?
= render 'projects/mirrors/disabled_mirror_badge'
- if mirror.last_error.present?
- .badge.mirror-error-badge{ data: { toggle: 'tooltip', html: 'true' }, title: html_escape(mirror.last_error.try(:strip)) }= _('Error')
+ .badge.mirror-error-badge{ data: { toggle: 'tooltip', html: 'true', qa_selector: 'mirror_error_badge' }, title: html_escape(mirror.last_error.try(:strip)) }= _('Error')
%td
.btn-group.mirror-actions-group.pull-right{ role: 'group' }
- if mirror.ssh_key_auth?
- = clipboard_button(text: mirror.ssh_public_key, class: 'btn btn-default', title: _('Copy SSH public key'))
+ = clipboard_button(text: mirror.ssh_public_key, class: 'btn btn-default', title: _('Copy SSH public key'), qa_selector: 'copy_public_key_button')
= render 'shared/remote_mirror_update_button', remote_mirror: mirror
%button.js-delete-mirror.qa-delete-mirror.rspec-delete-mirror.btn.btn-danger{ type: 'button', data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' }, title: _('Remove') }= icon('trash-o')
diff --git a/app/views/projects/mirrors/_ssh_host_keys.html.haml b/app/views/projects/mirrors/_ssh_host_keys.html.haml
index 7762fb4b844..3279d3eb251 100644
--- a/app/views/projects/mirrors/_ssh_host_keys.html.haml
+++ b/app/views/projects/mirrors/_ssh_host_keys.html.haml
@@ -3,13 +3,13 @@
- verified_at = mirror.ssh_known_hosts_verified_at
.form-group.js-ssh-host-keys-section{ class: ('collapse' unless mirror.ssh_mirror_url?) }
- %button.btn.btn-inverted.btn-secondary.inline.js-detect-host-keys.append-right-10{ type: 'button' }
+ %button.btn.btn-inverted.btn-secondary.inline.js-detect-host-keys.append-right-10{ type: 'button', data: { qa_selector: 'detect_host_keys' } }
= icon('spinner spin', class: 'js-spinner d-none')
= _('Detect host keys')
.fingerprint-ssh-info.js-fingerprint-ssh-info.prepend-top-10.append-bottom-10{ class: ('collapse' unless mirror.ssh_mirror_url?) }
%label.label-bold
= _('Fingerprints')
- .fingerprints-list.js-fingerprints-list
+ .fingerprints-list.js-fingerprints-list{ data: { qa_selector: 'fingerprints_list' } }
- mirror.ssh_known_hosts_fingerprints.each do |fp|
%code= fp.fingerprint
- if verified_at
diff --git a/app/views/projects/notes/_more_actions_dropdown.html.haml b/app/views/projects/notes/_more_actions_dropdown.html.haml
index 8a6e5fde99b..2f0394538bb 100644
--- a/app/views/projects/notes/_more_actions_dropdown.html.haml
+++ b/app/views/projects/notes/_more_actions_dropdown.html.haml
@@ -7,7 +7,7 @@
= custom_icon('ellipsis_v')
%ul.dropdown-menu.more-actions-dropdown.dropdown-open-left
%li
- = clipboard_button(text: noteable_note_url(note), title: 'Copy reference to clipboard', button_text: 'Copy link', class: 'btn-clipboard', hide_tooltip: true, hide_button_icon: true)
+ = clipboard_button(text: noteable_note_url(note), title: _('Copy reference'), button_text: _('Copy link'), class: 'btn-clipboard', hide_tooltip: true, hide_button_icon: true)
- unless is_current_user
%li
= link_to new_abuse_report_path(user_id: note.author.id, ref_url: noteable_note_url(note)) do
diff --git a/app/views/projects/pages/_access.html.haml b/app/views/projects/pages/_access.html.haml
index 7b6d46964a2..178f0acc5b9 100644
--- a/app/views/projects/pages/_access.html.haml
+++ b/app/views/projects/pages/_access.html.haml
@@ -1,11 +1,11 @@
- if @project.pages_deployed?
.card
.card-header
- Access pages
+ = s_('GitLabPages|Access pages')
.card-body
%p
%strong
- = _("Your pages are served under:")
+ = s_('GitLabPages|Your pages are served under:')
%p
= external_link(@project.pages_url, @project.pages_url)
@@ -14,4 +14,4 @@
%p
= external_link(domain.url, domain.url)
.card-footer.alert-primary
- = _("It may take up to 30 minutes before the site is available after the first deployment.")
+ = s_('GitLabPages|It may take up to 30 minutes before the site is available after the first deployment.')
diff --git a/app/views/projects/pages/_destroy.haml b/app/views/projects/pages/_destroy.haml
index 138e2864bad..58dbbb5bcfc 100644
--- a/app/views/projects/pages/_destroy.haml
+++ b/app/views/projects/pages/_destroy.haml
@@ -1,12 +1,14 @@
- if @project.pages_deployed?
- if can?(current_user, :remove_pages, @project)
.card.border-danger
- .card-header.bg-danger.text-white Remove pages
+ .card-header.bg-danger.text-white
+ = s_('GitLabPages|Remove pages')
.errors-holder
.card-body
%p
- Removing pages will prevent them from being exposed to the outside world.
+ = s_('GitLabPages|Removing pages will prevent them from being exposed to the outside world.')
.form-actions
- = link_to 'Remove pages', project_pages_path(@project), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove"
+ = link_to s_('GitLabPages|Remove pages'), project_pages_path(@project), data: { confirm: s_('GitLabPages|Are you sure?')}, method: :delete, class: "btn btn-remove"
- else
- .nothing-here-block Only project maintainers can remove pages
+ .nothing-here-block
+ = s_('GitLabPages|Only project maintainers can remove pages')
diff --git a/app/views/projects/pages/_https_only.html.haml b/app/views/projects/pages/_https_only.html.haml
index 74478ee011c..d8c4a5f0a5d 100644
--- a/app/views/projects/pages/_https_only.html.haml
+++ b/app/views/projects/pages/_https_only.html.haml
@@ -3,8 +3,9 @@
.form-check
= f.check_box :pages_https_only, class: 'form-check-input', disabled: pages_https_only_disabled?
= f.label :pages_https_only, class: pages_https_only_label_class do
- %strong Force HTTPS (requires valid certificates)
+ %strong
+ = s_('GitLabPages|Force HTTPS (requires valid certificates)')
- unless pages_https_only_disabled?
.prepend-top-10
- = f.submit 'Save', class: 'btn btn-success'
+ = f.submit s_('GitLabPages|Save'), class: 'btn btn-success'
diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml
index c4285e7f3d2..b05491f2c6e 100644
--- a/app/views/projects/pages/_list.html.haml
+++ b/app/views/projects/pages/_list.html.haml
@@ -8,20 +8,25 @@
- @domains.each do |domain|
%li.pages-domain-list-item.list-group-item.d-flex.justify-content-between
- if verification_enabled
- - tooltip, status = domain.unverified? ? [_('Unverified'), 'failed'] : [_('Verified'), 'success']
+ - tooltip, status = domain.unverified? ? [s_('GitLabPages|Unverified'), 'failed'] : [s_('GitLabPages|Verified'), 'success']
.domain-status.ci-status-icon.has-tooltip{ class: "ci-status-icon-#{status}", title: tooltip }
= sprite_icon("status_#{status}", size: 16 )
.domain-name
= external_link(domain.url, domain.url)
- if domain.subject
%div
- %span.badge.badge-gray Certificate: #{domain.subject}
+ %span.badge.badge-gray
+ = s_('GitLabPages|Certificate: %{subject}') % { subject: domain.subject }
- if domain.expired?
- %span.badge.badge-danger Expired
+ %span.badge.badge-danger
+ = s_('GitLabPages|Expired')
%div
- = link_to 'Details', project_pages_domain_path(@project, domain), class: "btn btn-sm btn-grouped"
- = link_to 'Remove', project_pages_domain_path(@project, domain), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove btn-sm btn-grouped"
+ = link_to s_('GitLabPages|Details'), project_pages_domain_path(@project, domain), class: "btn btn-sm btn-grouped"
+ = link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?')}, method: :delete, class: "btn btn-remove btn-sm btn-grouped"
- if verification_enabled && domain.unverified?
%li.list-group-item.bs-callout-warning
- #{domain.domain} is not verified. To learn how to verify ownership, visit your
- #{link_to 'domain details', project_pages_domain_path(@project, domain)}.
+ - 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 }
diff --git a/app/views/projects/pages/_no_domains.html.haml b/app/views/projects/pages/_no_domains.html.haml
index 8c93cf7a8ad..8d6e403b93a 100644
--- a/app/views/projects/pages/_no_domains.html.haml
+++ b/app/views/projects/pages/_no_domains.html.haml
@@ -1,7 +1,6 @@
- if can?(current_user, :update_pages, @project)
.card
.card-header
- Domains
+ = s_('GitLabPages|Domains')
.nothing-here-block
- Support for domains and certificates is disabled.
- Ask your system's administrator to enable it.
+ = 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 988dabef3a0..ab44fd77e1e 100644
--- a/app/views/projects/pages/_use.html.haml
+++ b/app/views/projects/pages/_use.html.haml
@@ -1,10 +1,10 @@
- unless @project.pages_deployed?
.card.border-info
.card-header.bg-info.text-white
- Configure pages
+ = s_('GitLabPages|Configure pages')
.card-body
%p
- Learn how to upload your static site and have it served by
- GitLab by following the
- = succeed '.' do
- = link_to 'documentation on GitLab Pages', help_page_path('user/project/pages/index.md'), target: '_blank'
+ - link_start = "<a href='#{help_page_path('user/project/pages/index.md')}' target='_blank' rel='noopener noreferrer'>".html_safe
+ - link_end = '</a>'.html_safe
+ = s_('GitLabPages|Learn how to upload your static site and have it served by GitLab by following the %{link_start}documentation on GitLab Pages%{link_end}.').html_safe % { link_start: link_start,
+ link_end: link_end }
diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml
index 88ab486a248..0e1f281410a 100644
--- a/app/views/projects/pages/show.html.haml
+++ b/app/views/projects/pages/show.html.haml
@@ -1,17 +1,14 @@
- page_title 'Pages'
%h3.page-title.with-button
- Pages
+ = s_('GitLabPages|Pages')
- 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 btn-success float-right', title: 'New Domain' do
- New Domain
+ = link_to new_project_pages_domain_path(@project), class: 'btn btn-success float-right', title: s_('GitLabPages|New Domain') do
+ = s_('GitLabPages|New Domain')
%p.light
- With GitLab Pages you can host your static websites on GitLab.
- Combined with the power of GitLab CI and the help of GitLab Runner
- you can deploy static pages for your individual projects, your user or your group.
-
+ = s_('GitLabPages|With GitLab Pages you can host your static websites on GitLab. Combined with the power of GitLab CI and the help of GitLab Runner you can deploy static pages for your individual projects, your user or your group.')
- if Gitlab.config.pages.external_https
= render 'https_only'
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index 53bb3c7487d..4eec81c9125 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -43,7 +43,7 @@
} }
Auto DevOps
- if @pipeline.detached_merge_request_pipeline?
- %span.js-pipeline-url-mergerequest.badge.badge-info.has-tooltip{ title: _('Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more on the documentation for Pipelines for Merged Results.') }
+ %span.js-pipeline-url-mergerequest.badge.badge-info.has-tooltip{ title: _('Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for Pipelines for Merged Results.') }
detached
- if @pipeline.stuck?
%span.js-pipeline-url-stuck.badge.badge-warning
@@ -58,4 +58,10 @@
= sprite_icon('ellipsis_h', size: 12)
%span.js-details-content.hide
= link_to @pipeline.sha, project_commit_path(@project, @pipeline.sha), class: "commit-sha commit-hash-full"
- = clipboard_button(text: @pipeline.sha, title: "Copy commit SHA to clipboard")
+ = clipboard_button(text: @pipeline.sha, title: _("Copy commit SHA"))
+
+ .well-segment.related-merge-request-info
+ .icon-container
+ = sprite_icon("git-merge")
+ %span.related-merge-requests
+ = @pipeline.all_related_merge_request_text
diff --git a/app/views/projects/protected_branches/shared/_branches_list.html.haml b/app/views/projects/protected_branches/shared/_branches_list.html.haml
index 1913d06a6f8..9dff251101b 100644
--- a/app/views/projects/protected_branches/shared/_branches_list.html.haml
+++ b/app/views/projects/protected_branches/shared/_branches_list.html.haml
@@ -1,9 +1,9 @@
.protected-branches-list.js-protected-branches-list.qa-protected-branches-list
- if @protected_branches.empty?
.card-header.bg-white
- Protected branch (#{@protected_branches_count})
+ = s_("ProtectedBranch|Protected branch (%{protected_branches_count})") % { protected_branches_count: @protected_branches_count }
%p.settings-message.text-center
- There are currently no protected branches, protect a branch with the form above.
+ = s_("ProtectedBranch|There are currently no protected branches, protect a branch with the form above.")
- else
%table.table.table-bordered
%colgroup
@@ -15,10 +15,15 @@
%col
%thead
%tr
- %th Protected branch (#{@protected_branches_count})
- %th Last commit
- %th Allowed to merge
- %th Allowed to push
+ %th
+ = s_("ProtectedBranch|Branch")
+ %th
+ = s_("ProtectedBranch|Allowed to merge")
+ %th
+ = s_("ProtectedBranch|Allowed to push")
+
+ = render_if_exists 'projects/protected_branches/ee/code_owner_approval_table_head'
+
- if can_admin_project
%th
%tbody
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 bba4949277d..f84c7b39733 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
@@ -2,7 +2,7 @@
%input{ type: 'hidden', name: 'update_section', value: 'js-protected-branches-settings' }
.card
.card-header
- Protect a branch
+ = s_("ProtectedBranch|Protect a branch")
.card-body
= form_errors(@protected_branch)
.form-group.row
@@ -11,22 +11,19 @@
.col-md-10
= render partial: "projects/protected_branches/shared/dropdown", locals: { f: f }
.form-text.text-muted
- = link_to 'Wildcards', help_page_path('user/project/protected_branches', anchor: 'wildcard-protected-branches')
- such as
- %code *-stable
- or
- %code production/*
- are supported
+ - wildcards_url = help_page_url('user/project/protected_branches', anchor: 'wildcard-protected-branches')
+ - wildcards_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wildcards_url }
+ = (s_("ProtectedBranch|%{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}*-stable%{code_tag_end} or %{code_tag_start}production/*%{code_tag_end} are supported") % { wildcards_link_start: wildcards_link_start, wildcards_link_end: '</a>', code_tag_start: '<code>', code_tag_end: '</code>' }).html_safe
.form-group.row
%label.col-md-2.text-right{ for: 'merge_access_levels_attributes' }
- Allowed to merge:
+ = s_("ProtectedBranch|Allowed to merge:")
.col-md-10
= yield :merge_access_levels
.form-group.row
%label.col-md-2.text-right{ for: 'push_access_levels_attributes' }
- Allowed to push:
+ = s_("ProtectedBranch|Allowed to push:")
.col-md-10
= yield :push_access_levels
-
+ = render_if_exists 'projects/protected_branches/ee/code_owner_approval_form'
.card-footer
- = f.submit 'Protect', class: 'btn-success btn', disabled: true, data: { qa_selector: 'protect_button' }
+ = f.submit s_('ProtectedBranch|Protect'), class: 'btn-success btn', disabled: true, data: { qa_selector: 'protect_button' }
diff --git a/app/views/projects/protected_branches/shared/_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_protected_branch.html.haml
index 81dcab1d1ab..4ca6ebe9c78 100644
--- a/app/views/projects/protected_branches/shared/_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/shared/_protected_branch.html.haml
@@ -5,20 +5,19 @@
%span.ref-name= protected_branch.name
- if @project.root_ref?(protected_branch.name)
- %span.badge.badge-info.prepend-left-5 default
- %td
- - if protected_branch.wildcard?
- - matching_branches = protected_branch.matching(repository.branches)
- = link_to pluralize(matching_branches.count, "matching branch"), namespace_project_protected_branch_path(@project.namespace, @project, protected_branch)
- - else
- - if commit = protected_branch.commit
- = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit-sha')
- = time_ago_with_tooltip(commit.committed_date)
- - else
- (branch was deleted from repository)
+ %span.badge.badge-info.d-inline default
+
+ %div
+ - if protected_branch.wildcard?
+ - matching_branches = protected_branch.matching(repository.branches)
+ = link_to pluralize(matching_branches.count, "matching branch"), namespace_project_protected_branch_path(@project.namespace, @project, protected_branch)
+ - elsif !protected_branch.commit
+ %span.text-muted Branch was deleted.
= yield
+ = render_if_exists 'projects/protected_branches/ee/code_owner_approval_table', protected_branch: protected_branch
+
- if can_admin_project
%td
= link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch, { update_section: 'js-protected-branches-settings' }], disabled: local_assigns[:disabled], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: "btn btn-warning"
diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index d0d06a0df7e..b2e160e37bc 100644
--- a/app/views/projects/registry/repositories/index.html.haml
+++ b/app/views/projects/registry/repositories/index.html.haml
@@ -1,9 +1,14 @@
+- page_title _("Container Registry")
+
%section
.row.registry-placeholder.prepend-bottom-10
.col-12
#js-vue-registry-images{ data: { endpoint: project_container_registry_index_path(@project, format: :json),
"help_page_path" => help_page_path('user/packages/container_registry/index'),
+ "two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'),
+ "personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'),
"no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
"containers_error_image" => image_path('illustrations/docker-error-state.svg'),
"repository_url" => escape_once(@project.container_registry_url),
+ "registry_host_url_with_port" => escape_once(registry_config.host_port),
character_error: @character_error.to_s } }
diff --git a/app/views/projects/releases/edit.html.haml b/app/views/projects/releases/edit.html.haml
new file mode 100644
index 00000000000..88ca64f2af0
--- /dev/null
+++ b/app/views/projects/releases/edit.html.haml
@@ -0,0 +1,3 @@
+- page_title _('Edit Release')
+
+#js-edit-release-page{ data: data_for_edit_release_page }
diff --git a/app/views/projects/runners/_specific_runners.html.haml b/app/views/projects/runners/_specific_runners.html.haml
index dc56a515d4c..4cc67a8f5d8 100644
--- a/app/views/projects/runners/_specific_runners.html.haml
+++ b/app/views/projects/runners/_specific_runners.html.haml
@@ -2,28 +2,9 @@
= _('Specific Runners')
.bs-callout.help-callout
- .append-bottom-10
- %h4= _('Set up a specific Runner automatically')
-
- %p
- - link_to_help_page = link_to(_('Learn more about Kubernetes'),
- help_page_path('user/project/clusters/index'),
- target: '_blank',
- rel: 'noopener noreferrer')
-
- = _('You can easily install a Runner on a Kubernetes cluster. %{link_to_help_page}').html_safe % { link_to_help_page: link_to_help_page }
-
- %ol
- %li
- = _('Click the button below to begin the install process by navigating to the Kubernetes page')
- %li
- = _('Select an existing Kubernetes cluster or create a new one')
- %li
- = _('From the Kubernetes cluster details view, install Runner from the applications list')
-
- = link_to _('Install Runner on Kubernetes'),
- project_clusters_path(@project),
- class: 'btn btn-info'
+ = render partial: 'ci/runner/how_to_setup_runner_automatically',
+ locals: { type: 'specific',
+ clusters_path: project_clusters_path(@project) }
%hr
= render partial: 'ci/runner/how_to_setup_runner',
locals: { registration_token: @project.runners_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 1d5d90593ae..6702786fdb3 100644
--- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
@@ -46,12 +46,12 @@
= form.radio_button :deploy_strategy, 'timed_incremental', class: 'form-check-input'
= form.label :deploy_strategy_timed_incremental, class: 'form-check-label' do
= s_('CICD|Continuous deployment to production using timed incremental rollout')
- = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'timed-incremental-rollout-to-production'), target: '_blank'
+ = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'timed-incremental-rollout-to-production-premium'), target: '_blank'
.form-check
= form.radio_button :deploy_strategy, 'manual', class: 'form-check-input'
= form.label :deploy_strategy_manual, class: 'form-check-label' do
= s_('CICD|Automatic deployment to staging, manual deployment to production')
- = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'incremental-rollout-to-production'), target: '_blank'
+ = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'incremental-rollout-to-production-premium'), target: '_blank'
= f.submit _('Save changes'), class: "btn btn-success prepend-top-15", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index 430d6071468..66ed1cadf6a 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -40,12 +40,21 @@
= _('If any job surpasses this timeout threshold, it will be marked as failed. Human readable time input language is accepted like "1 hour". Values without specification represent seconds.')
= link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout'), target: '_blank'
+ - if can?(current_user, :update_max_artifacts_size, @project)
+ %hr
+ .form-group
+ = f.label :max_artifacts_size, _('Maximum artifacts size (MB)'), class: 'label-bold'
+ = f.number_field :max_artifacts_size, class: 'form-control'
+ %p.form-text.text-muted
+ = _("Set the maximum file size for each job's artifacts")
+ = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size-core-only'), target: '_blank'
+
%hr
.form-group
- = f.label :ci_config_path, _('Custom CI config path'), class: 'label-bold'
+ = f.label :ci_config_path, _('Custom CI configuration path'), class: 'label-bold'
= f.text_field :ci_config_path, class: 'form-control', placeholder: '.gitlab-ci.yml'
%p.form-text.text-muted
- = _("The path to CI config file. Defaults to <code>.gitlab-ci.yml</code>").html_safe
+ = _("The path to the CI configuration file. Defaults to <code>.gitlab-ci.yml</code>").html_safe
= link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'custom-ci-config-path'), target: '_blank'
%hr
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index b1432917f1d..c7bd0262c54 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -19,9 +19,15 @@
- else
%p
= s_("TagsPage|Can't find HEAD commit for this tag")
- - if release && release.description.present?
- .description.md.prepend-top-default
- = markdown_field(release, :description)
+
+ - if release
+ .text-secondary
+ = icon('rocket')
+ = _("Release")
+ = link_to release.name, project_releases_path(@project, anchor: release.tag), class: 'tag-release-link'
+ - if release.description.present?
+ .description.md.prepend-top-default
+ = markdown_field(release, :description)
.row-fixed-content.controls.flex-row
= render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name]
diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml
index 5e6d06d980e..a7f739ab13d 100644
--- a/app/views/projects/tags/new.html.haml
+++ b/app/views/projects/tags/new.html.haml
@@ -31,7 +31,7 @@
.col-sm-10
= text_area_tag :message, @message, required: false, class: 'form-control', rows: 5
.form-text.text-muted
- = s_('TagsPage|Optionally, add a message to the tag.')
+ = tag_description_help_text
%hr
.form-group.row
= label_tag :release_description, s_('TagsPage|Release notes'), class: 'col-form-label col-sm-2'
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index 41cd044a5b0..38422d4533d 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -41,7 +41,7 @@
%li
= link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do
#{ _('New directory') }
- - elsif can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project)
+ - elsif can_create_mr_from_fork
%li
- continue_params = { to: project_new_blob_path(@project, @id),
notice: edit_in_new_fork_notice,
@@ -81,10 +81,15 @@
= render 'projects/find_file_link'
- - if can_collaborate
+ - if can_create_mr_from_fork
= succeed " " do
- = link_to ide_edit_path(@project, @ref, @path), class: 'btn btn-default qa-web-ide-button' do
- = _('Web IDE')
+ - if can_collaborate || current_user&.already_forked?(@project)
+ = link_to ide_edit_path(@project, @ref, @path), class: 'btn btn-default qa-web-ide-button' do
+ = _('Web IDE')
+ - else
+ = link_to '#modal-confirm-fork', class: 'btn btn-default qa-web-ide-button', data: { target: '#modal-confirm-fork', toggle: 'modal'} do
+ = _('Web IDE')
+ = render 'shared/confirm_fork_modal', fork_path: ide_fork_and_edit_path(@project, @ref, @path)
- if show_xcode_link?(@project)
.project-action-button.project-xcode.inline
diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml
index 9899cf9c6de..60de3630bb5 100644
--- a/app/views/projects/triggers/_trigger.html.haml
+++ b/app/views/projects/triggers/_trigger.html.haml
@@ -2,7 +2,7 @@
%td
- if trigger.has_token_exposed?
%span= trigger.token
- = clipboard_button(text: trigger.token, title: "Copy trigger token to clipboard")
+ = clipboard_button(text: trigger.token, title: _("Copy trigger token"))
- else
%span= trigger.short_token
diff --git a/app/views/registrations/welcome.html.haml b/app/views/registrations/welcome.html.haml
new file mode 100644
index 00000000000..02ab974ecc0
--- /dev/null
+++ b/app/views/registrations/welcome.html.haml
@@ -0,0 +1,17 @@
+- content_for(:page_title, _('Welcome to GitLab<br>%{username}!' % { username: html_escape(current_user.username) }).html_safe)
+- max_name_length = 128
+.text-center.mb-3
+ = _('In order to tailor your experience with GitLab<br>we would like to know a bit more about you.').html_safe
+.signup-box.p-3.mb-2
+ .signup-body
+ = form_for(current_user, url: users_sign_up_update_role_path, html: { class: 'new_new_user gl-show-field-errors', 'aria-live' => 'assertive' }) do |f|
+ .devise-errors.mt-0
+ = render 'devise/shared/error_messages', resource: current_user
+ .name.form-group
+ = f.label :name, _('Full name'), class: 'label-bold'
+ = f.text_field :name, class: 'form-control top js-block-emoji js-validate-length', :data => { :max_length => max_name_length, :max_length_message => s_('Name is too long (maximum is %{max_length} characters).') % { max_length: max_name_length }, :qa_selector => 'new_user_name_field' }, required: true, title: _('This field is required.')
+ .form-group
+ = f.label :role, _('Role'), class: 'label-bold'
+ = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, {}, class: 'form-control'
+ .submit-container.mt-3
+ = f.submit _('Get started!'), class: 'btn-register btn btn-block mb-0 p-2'
diff --git a/app/views/search/_form.html.haml b/app/views/search/_form.html.haml
index 464db94b7f4..dc75918eb93 100644
--- a/app/views/search/_form.html.haml
+++ b/app/views/search/_form.html.haml
@@ -18,4 +18,3 @@
= render 'filter'
.d-flex-center.flex-column.flex-lg-row
= button_tag _("Search"), class: "btn btn-success btn-search form-control mt-lg-0 ml-lg-1 align-self-end"
- = render_if_exists 'search/form_elasticsearch'
diff --git a/app/views/search/results/_empty.html.haml b/app/views/search/results/_empty.html.haml
index 9d15995bb51..6c7c6de1178 100644
--- a/app/views/search/results/_empty.html.haml
+++ b/app/views/search/results/_empty.html.haml
@@ -2,5 +2,4 @@
.search_glyph
%h4
= icon('search')
- = _("We couldn't find any results matching")
- %code= @search_term
+ = search_entries_empty_message(@scope, @search_term)
diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml
index 9235678bc1d..f300e1d4841 100644
--- a/app/views/search/show.html.haml
+++ b/app/views/search/show.html.haml
@@ -2,9 +2,10 @@
- page_title @search_term
- @hide_breadcrumbs = true
-.page-title-holder.d-flex.align-items-center
+.page-title-holder.d-sm-flex.align-items-sm-center
%h1.page-title<
= _('Search')
+ = render_if_exists 'search/form_elasticsearch', attrs: { class: 'ml-sm-auto' }
.prepend-top-default
= render 'search/form'
diff --git a/app/views/shared/_allow_request_access.html.haml b/app/views/shared/_allow_request_access.html.haml
index 2b24bde9e59..ca82f2f3377 100644
--- a/app/views/shared/_allow_request_access.html.haml
+++ b/app/views/shared/_allow_request_access.html.haml
@@ -3,6 +3,4 @@
.form-check
= form.check_box :request_access_enabled, class: 'form-check-input', data: { qa_selector: 'request_access_checkbox' }
= form.label :request_access_enabled, class: 'form-check-label' do
- %span{ class: label_class }= _('Allow users to request access')
- %br
- %span.text-muted= _('Allow users to request access if visibility is public or internal.')
+ %span{ class: label_class }= _('Allow users to request access (if visibility is public or internal)')
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 755fd3a17d3..fb03e6e12e3 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)
- .qa-auto-devops-banner.auto-devops-implicitly-enabled-banner.alert.alert-warning
+ .qa-auto-devops-banner.auto-devops-implicitly-enabled-banner.alert.alert-info
- more_information_link = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank', class: 'alert-link'
- auto_devops_message = s_("AutoDevOps|The Auto DevOps pipeline has been enabled and will be used if no alternative CI configuration file is found. %{more_information_link}") % { more_information_link: more_information_link }
= auto_devops_message.html_safe
diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml
index 1e509ea0d1f..cb834878276 100644
--- a/app/views/shared/_clone_panel.html.haml
+++ b/app/views/shared/_clone_panel.html.haml
@@ -20,7 +20,7 @@
= text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: 'Project clone URL' }
.input-group-append
- = clipboard_button(target: '#project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard")
+ = clipboard_button(target: '#project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard")
= render_if_exists 'shared/geo_modal_button'
diff --git a/app/views/shared/_confirm_fork_modal.html.haml b/app/views/shared/_confirm_fork_modal.html.haml
new file mode 100644
index 00000000000..db50ea41387
--- /dev/null
+++ b/app/views/shared/_confirm_fork_modal.html.haml
@@ -0,0 +1,12 @@
+#modal-confirm-fork.modal.qa-confirm-fork-modal
+ .modal-dialog
+ .modal-content
+ .modal-header
+ %h3.page-title= _('Fork project?')
+ %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
+ %span{ "aria-hidden": true } &times;
+ .modal-body.p-3
+ %p= _("You're not allowed to %{tag_start}edit%{tag_end} files in this project directly. Please fork this project, make your changes there, and submit a merge request.") % { tag_start: '', tag_end: ''}
+ .modal-footer
+ = link_to _('Cancel'), '#', class: "btn btn-cancel", "data-dismiss" => "modal"
+ = link_to _('Fork project'), fork_path, class: 'btn btn-success', method: :post
diff --git a/app/views/shared/_confirm_modal.html.haml b/app/views/shared/_confirm_modal.html.haml
index 8e3b482e27d..ecb462205b0 100644
--- a/app/views/shared/_confirm_modal.html.haml
+++ b/app/views/shared/_confirm_modal.html.haml
@@ -12,9 +12,8 @@
%p
%span.js-warning-text= _('This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention.')
%br
- Please type
- %code.js-confirm-danger-match= phrase
- to proceed or close this modal to cancel.
+ - phrase_code = '<code class="js-confirm-danger-match">%{phrase_name}</code>'.html_safe % { phrase_name: phrase }
+ = _('Please type %{phrase_code} to proceed or close this modal to cancel.').html_safe % { phrase_code: phrase_code }
.form-group
= text_field_tag 'confirm_name_input', '', class: 'form-control js-confirm-danger-input qa-confirm-input'
diff --git a/app/views/shared/_event_filter.html.haml b/app/views/shared/_event_filter.html.haml
index 6612497e7e2..ad9eb325ff0 100644
--- a/app/views/shared/_event_filter.html.haml
+++ b/app/views/shared/_event_filter.html.haml
@@ -1,3 +1,5 @@
+- show_group_events = local_assigns.fetch(:show_group_events, false)
+
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller.flex-fill
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
@@ -9,6 +11,8 @@
= event_filter_link EventFilter::MERGED, _('Merge events'), s_('EventFilterBy|Filter by merge events')
- if event_filter_visible(:issues)
= event_filter_link EventFilter::ISSUE, _('Issue events'), s_('EventFilterBy|Filter by issue events')
+ - if show_group_events
+ = render_if_exists 'events/epics_filter'
- if comments_visible?
= event_filter_link EventFilter::COMMENTS, _('Comments'), s_('EventFilterBy|Filter by comments')
= event_filter_link EventFilter::TEAM, _('Team'), s_('EventFilterBy|Filter by team')
diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml
index 973c756f496..959792718ca 100644
--- a/app/views/shared/_group_form.html.haml
+++ b/app/views/shared/_group_form.html.haml
@@ -30,9 +30,9 @@
- if @group.persisted?
.alert.alert-warning.prepend-top-10
- Changing group path can have unintended side effects.
+ = _('Changing group path can have unintended side effects.')
= succeed '.' do
- = link_to 'Learn more', help_page_path('user/group/index', anchor: 'changing-a-groups-path'), target: '_blank'
+ = link_to _('Learn more'), help_page_path('user/group/index', anchor: 'changing-a-groups-path'), target: '_blank'
- if @group.persisted?
.row
diff --git a/app/views/shared/_personal_access_tokens_created_container.html.haml b/app/views/shared/_personal_access_tokens_created_container.html.haml
index 42989b145a2..df4577e2862 100644
--- a/app/views/shared/_personal_access_tokens_created_container.html.haml
+++ b/app/views/shared/_personal_access_tokens_created_container.html.haml
@@ -1,5 +1,5 @@
- container_title = local_assigns.fetch(:container_title, _('Your New Personal Access Token'))
-- clipboard_button_title = local_assigns.fetch(:clipboard_button_title, _('Copy personal access token to clipboard'))
+- clipboard_button_title = local_assigns.fetch(:clipboard_button_title, _('Copy personal access token'))
.created-personal-access-token-container
%h5.prepend-top-0
diff --git a/app/views/shared/_remote_mirror_update_button.html.haml b/app/views/shared/_remote_mirror_update_button.html.haml
index 4b39c8b06e9..54bd4ba04a0 100644
--- a/app/views/shared/_remote_mirror_update_button.html.haml
+++ b/app/views/shared/_remote_mirror_update_button.html.haml
@@ -1,5 +1,5 @@
- if remote_mirror.update_in_progress?
- %button.btn.disabled{ type: 'button', data: { toggle: 'tooltip', container: 'body' }, title: _('Updating') }
+ %button.btn.disabled{ type: 'button', data: { toggle: 'tooltip', container: 'body', qa_selector: 'updating_button' }, title: _('Updating') }
= icon("refresh spin")
- elsif remote_mirror.enabled?
= link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn qa-update-now-button rspec-update-now-button", data: { toggle: 'tooltip', container: 'body' }, title: _('Update now') do
diff --git a/app/views/shared/_user_dropdown_contributing_link.html.haml b/app/views/shared/_user_dropdown_contributing_link.html.haml
index 564d21a39be..d4c3e11d051 100644
--- a/app/views/shared/_user_dropdown_contributing_link.html.haml
+++ b/app/views/shared/_user_dropdown_contributing_link.html.haml
@@ -1,3 +1,2 @@
-%li
- = link_to "https://about.gitlab.com/contributing", target: '_blank', class: 'text-nowrap' do
- = _("Contribute to GitLab")
+= link_to "https://about.gitlab.com/contributing", target: '_blank', class: 'text-nowrap' do
+ = _("Contribute to GitLab")
diff --git a/app/views/shared/boards/_switcher.html.haml b/app/views/shared/boards/_switcher.html.haml
index 79118630762..09a365a290a 100644
--- a/app/views/shared/boards/_switcher.html.haml
+++ b/app/views/shared/boards/_switcher.html.haml
@@ -1,4 +1,4 @@
-- parent = board.parent
+- parent = board.resource_parent
- milestone_filter_opts = { format: :json }
- milestone_filter_opts = milestone_filter_opts.merge(only_group_milestones: true) if board.group_board?
- weights = Gitlab.ee? ? ([Issue::WEIGHT_ANY] + Issue.weight_options) : []
diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml
index bc0dc7f9631..1944c293be1 100644
--- a/app/views/shared/deploy_keys/_form.html.haml
+++ b/app/views/shared/deploy_keys/_form.html.haml
@@ -6,7 +6,7 @@
.form-group
= form.label :title, class: 'col-form-label col-sm-2'
- .col-sm-10= form.text_field :title, class: 'form-control'
+ .col-sm-10= form.text_field :title, class: 'form-control', readonly: ('readonly' unless can?(current_user, :update_deploy_key, deploy_key))
.form-group
- if deploy_key.new_record?
diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml
index 9173b802dd4..325e01bb5c8 100644
--- a/app/views/shared/empty_states/_issues.html.haml
+++ b/app/views/shared/empty_states/_issues.html.haml
@@ -1,4 +1,4 @@
-- button_path = local_assigns.fetch(:button_path, false)
+- button_path = local_assigns.fetch(:new_project_issue_button_path, false)
- project_select_button = local_assigns.fetch(:project_select_button, false)
- show_import_button = local_assigns.fetch(:show_import_button, false) && can?(current_user, :import_issues, @project)
- has_button = button_path || project_select_button
@@ -56,4 +56,3 @@
- if show_import_button
= render 'projects/issues/import_csv/modal'
-
diff --git a/app/views/shared/form_elements/_apply_template_warning.html.haml b/app/views/shared/form_elements/_apply_template_warning.html.haml
new file mode 100644
index 00000000000..09ca59a520c
--- /dev/null
+++ b/app/views/shared/form_elements/_apply_template_warning.html.haml
@@ -0,0 +1,14 @@
+.form-group.row.js-template-warning.mb-0.hidden.js-issuable-template-warning
+ .offset-sm-2.col-sm-10
+
+ .warning_message.mb-0{ role: 'alert' }
+ %btn.js-close-btn.js-dismiss-btn.close{ type: "button", "aria-hidden": true, "aria-label": _("Close") }
+ = sprite_icon("close")
+
+ %p
+ = _("Applying a template will replace the existing issue description. Any changes you have made will be lost.")
+
+ %button.js-override-template.btn.btn-warning.mr-2{ type: 'button' }
+ = _("Apply template")
+ %button.js-close-btn.js-cancel-btn.btn.btn-inverted{ type: 'button' }
+ = _("Cancel")
diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml
index 24734ed66cf..cec865ec8de 100644
--- a/app/views/shared/issuable/_assignees.html.haml
+++ b/app/views/shared/issuable/_assignees.html.haml
@@ -7,4 +7,4 @@
= link_to_member(@project, assignee, name: false, title: "Assigned to :name")
- if more_assignees_count.positive?
- %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', 'original-title' => "+#{more_assignees_count} more assignees" } } +#{more_assignees_count}
+ %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', 'original-title' => "+#{more_assignees_count} more assignees", qa_selector: 'avatar_counter' } } +#{more_assignees_count}
diff --git a/app/views/shared/issuable/_board_create_list_dropdown.html.haml b/app/views/shared/issuable/_board_create_list_dropdown.html.haml
index 416b4a34651..ae0e5e45afe 100644
--- a/app/views/shared/issuable/_board_create_list_dropdown.html.haml
+++ b/app/views/shared/issuable/_board_create_list_dropdown.html.haml
@@ -3,6 +3,6 @@
Add list
.dropdown-menu.dropdown-extended-height.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels
= render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" }
- - if can?(current_user, :admin_label, board.parent)
+ - if can?(current_user, :admin_label, board.resource_parent)
= render partial: "shared/issuable/label_page_create", locals: { show_add_list: true, add_list: true, add_list_class: 'd-none' }
= dropdown_loading
diff --git a/app/views/shared/issuable/_feed_buttons.html.haml b/app/views/shared/issuable/_feed_buttons.html.haml
index 83f60fa6fe2..4fed95e2607 100644
--- a/app/views/shared/issuable/_feed_buttons.html.haml
+++ b/app/views/shared/issuable/_feed_buttons.html.haml
@@ -1,4 +1,4 @@
-= link_to safe_params.merge(rss_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: _('Subscribe to RSS feed') do
- = icon('rss')
+= link_to safe_params.merge(rss_url_options), class: 'btn has-tooltip js-rss-button', data: { container: 'body' }, title: _('Subscribe to RSS feed') do
+ = sprite_icon('rss')
= link_to safe_params.merge(calendar_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: _('Subscribe to calendar') do
- = custom_icon('icon_calendar')
+ = sprite_icon('calendar')
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index 04a70e406ca..5e2b5f95ee3 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -19,6 +19,7 @@
= render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?)
#js-suggestions{ data: { project_path: @project.full_path } }
+= render 'shared/form_elements/apply_template_warning'
= render 'shared/form_elements/description', model: issuable, form: form, project: project
- if issuable.respond_to?(:confidential)
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index c9458475aa5..9d580930fb8 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -2,7 +2,7 @@
- board = local_assigns.fetch(:board, nil)
- is_not_boards_modal_or_productivity_analytics = type != :boards_modal && type != :productivity_analytics
- block_css_class = is_not_boards_modal_or_productivity_analytics ? 'row-content-block second-block' : ''
-- user_can_admin_list = board && can?(current_user, :admin_list, board.parent)
+- user_can_admin_list = board && can?(current_user, :admin_list, board.resource_parent)
.issues-filters{ class: ("w-100" if type == :boards_modal) }
.issues-details-filters.filtered-search-block.d-flex.flex-column.flex-md-row{ class: block_css_class, "v-pre" => type == :boards_modal }
@@ -17,12 +17,11 @@
.issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row
.filtered-search-box
- if type != :boards_modal && type != :boards
- = dropdown_tag(custom_icon('icon_history'),
+ = dropdown_tag(_('Recent searches'),
options: { wrapper_class: "filtered-search-history-dropdown-wrapper",
toggle_class: "filtered-search-history-dropdown-toggle-button",
dropdown_class: "filtered-search-history-dropdown",
- content_class: "filtered-search-history-dropdown-content",
- title: "Recent searches" }) do
+ content_class: "filtered-search-history-dropdown-content" }) do
.js-filtered-search-history-dropdown{ data: { full_path: search_history_storage_prefix } }
.filtered-search-box-input-container.droplab-dropdown
.scroll-container
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 3b26b8df8a1..c8b2adcf084 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -5,174 +5,178 @@
- signed_in = !!issuable_sidebar.dig(:current_user, :id)
- can_edit_issuable = issuable_sidebar.dig(:current_user, :can_edit)
-%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in } }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
- .issuable-sidebar
- .block.issuable-sidebar-header
- - if signed_in
- %span.issuable-header-text.hide-collapsed.float-left
- = _('To Do')
- %a.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => "Toggle sidebar", title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } }
- = sidebar_gutter_toggle_icon
- - if signed_in
- = render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar
-
- = form_for issuable_type, url: issuable_sidebar[:issuable_json_path], remote: true, html: { class: 'issuable-context-form inline-update js-issuable-update' } do |f|
- - if signed_in
- .block.todo.hide-expanded
- = render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar, is_collapsed: true
- .block.assignee.qa-assignee-block
- = render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees
-
- = render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar
-
- - milestone = issuable_sidebar[:milestone] || {}
- .block.milestone
- .sidebar-collapsed-icon.has-tooltip{ title: sidebar_milestone_tooltip_label(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } }
- = icon('clock-o', 'aria-hidden': 'true')
- %span.milestone-title.collapse-truncated-title
- - if milestone.present?
- = milestone[:title]
- - else
- = _('None')
- .title.hide-collapsed
- = _('Milestone')
- = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- - if can_edit_issuable
- = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { track_label: "right_sidebar", track_property: "milestone", track_event: "click_edit_button", track_value: "" }
- .value.hide-collapsed
- - if milestone.present?
- = link_to milestone[:title], milestone[:web_url], class: "bold has-tooltip", title: sidebar_milestone_remaining_days(milestone), data: { container: "body", html: 'true', boundary: 'viewport', qa_selector: 'milestone_link' }
- - else
- %span.no-value
- = _('None')
-
- .selectbox.hide-collapsed
- = f.hidden_field 'milestone_id', value: milestone[:id], id: nil
- = dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable_type}[milestone_id]", project_id: issuable_sidebar[:project_id], issuable_id: issuable_sidebar[:id], milestones: issuable_sidebar[:project_milestones_path], ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], use_id: true, default_no: true, selected: milestone[:title], null_default: true, display: 'static' }})
-
- #issuable-time-tracker.block
- // Fallback while content is loading
- .title.hide-collapsed
- = _('Time tracking')
- = icon('spinner spin', 'aria-hidden': 'true')
-
- - if issuable_sidebar.has_key?(:due_date)
- .block.due_date
- .sidebar-collapsed-icon.has-tooltip{ data: { placement: 'left', container: 'body', html: 'true', boundary: 'viewport' }, title: sidebar_due_date_tooltip_label(issuable_sidebar[:due_date]) }
- = icon('calendar', 'aria-hidden': 'true')
- %span.js-due-date-sidebar-value
- = issuable_sidebar[:due_date].try(:to_s, :medium) || 'None'
+- if Feature.enabled?(:vue_issuable_sidebar, @project.group)
+ %aside#js-vue-issuable-sidebar{ data: { signed_in: signed_in,
+ sidebar_status_class: sidebar_gutter_collapsed_class } }
+- else
+ %aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in } }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
+ .issuable-sidebar
+ .block.issuable-sidebar-header
+ - if signed_in
+ %span.issuable-header-text.hide-collapsed.float-left
+ = _('To Do')
+ %a.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => "Toggle sidebar", title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } }
+ = sidebar_gutter_toggle_icon
+ - if signed_in
+ = render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar
+
+ = form_for issuable_type, url: issuable_sidebar[:issuable_json_path], remote: true, html: { class: 'issuable-context-form inline-update js-issuable-update' } do |f|
+ - if signed_in
+ .block.todo.hide-expanded
+ = render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar, is_collapsed: true
+ .block.assignee.qa-assignee-block
+ = render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees
+
+ = render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar
+
+ - milestone = issuable_sidebar[:milestone] || {}
+ .block.milestone
+ .sidebar-collapsed-icon.has-tooltip{ title: sidebar_milestone_tooltip_label(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } }
+ = icon('clock-o', 'aria-hidden': 'true')
+ %span.milestone-title.collapse-truncated-title
+ - if milestone.present?
+ = milestone[:title]
+ - else
+ = _('None')
.title.hide-collapsed
- = _('Due date')
+ = _('Milestone')
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
- = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { track_label: "right_sidebar", track_property: "due_date", track_event: "click_edit_button", track_value: "" }
+ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { track_label: "right_sidebar", track_property: "milestone", track_event: "click_edit_button", track_value: "" }
.value.hide-collapsed
- %span.value-content
- - if issuable_sidebar[:due_date]
- %span.bold= issuable_sidebar[:due_date].to_s(:medium)
- - else
- %span.no-value
- = _('None')
+ - if milestone.present?
+ = link_to milestone[:title], milestone[:web_url], class: "bold has-tooltip", title: sidebar_milestone_remaining_days(milestone), data: { container: "body", html: 'true', boundary: 'viewport', qa_selector: 'milestone_link' }
+ - else
+ %span.no-value
+ = _('None')
+
+ .selectbox.hide-collapsed
+ = f.hidden_field 'milestone_id', value: milestone[:id], id: nil
+ = dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable_type}[milestone_id]", project_id: issuable_sidebar[:project_id], issuable_id: issuable_sidebar[:id], milestones: issuable_sidebar[:project_milestones_path], ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], use_id: true, default_no: true, selected: milestone[:title], null_default: true, display: 'static' }})
+
+ #issuable-time-tracker.block
+ // Fallback while content is loading
+ .title.hide-collapsed
+ = _('Time tracking')
+ = icon('spinner spin', 'aria-hidden': 'true')
+
+ - if issuable_sidebar.has_key?(:due_date)
+ .block.due_date
+ .sidebar-collapsed-icon.has-tooltip{ data: { placement: 'left', container: 'body', html: 'true', boundary: 'viewport' }, title: sidebar_due_date_tooltip_label(issuable_sidebar[:due_date]) }
+ = icon('calendar', 'aria-hidden': 'true')
+ %span.js-due-date-sidebar-value
+ = issuable_sidebar[:due_date].try(:to_s, :medium) || 'None'
+ .title.hide-collapsed
+ = _('Due date')
+ = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
+ - if can_edit_issuable
+ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { track_label: "right_sidebar", track_property: "due_date", track_event: "click_edit_button", track_value: "" }
+ .value.hide-collapsed
+ %span.value-content
+ - if issuable_sidebar[:due_date]
+ %span.bold= issuable_sidebar[:due_date].to_s(:medium)
+ - else
+ %span.no-value
+ = _('None')
+ - if can_edit_issuable
+ %span.no-value.js-remove-due-date-holder{ class: ("hidden" if issuable_sidebar[:due_date].nil?) }
+ \-
+ %a.js-remove-due-date{ href: "#", role: "button" }
+ = _('remove due date')
- if can_edit_issuable
- %span.no-value.js-remove-due-date-holder{ class: ("hidden" if issuable_sidebar[:due_date].nil?) }
- \-
- %a.js-remove-due-date{ href: "#", role: "button" }
- = _('remove due date')
- - if can_edit_issuable
- .selectbox.hide-collapsed
- = f.hidden_field :due_date, value: issuable_sidebar[:due_date].try(:strftime, 'yy-mm-dd')
- .dropdown
- %button.dropdown-menu-toggle.js-due-date-select{ type: 'button', data: { toggle: 'dropdown', field_name: "#{issuable_type}[due_date]", ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], display: 'static' } }
- %span.dropdown-toggle-text
- = _('Due date')
- = icon('chevron-down', 'aria-hidden': 'true')
- .dropdown-menu.dropdown-menu-due-date
- = dropdown_title(_('Due date'))
- = dropdown_content do
- .js-due-date-calendar
-
- - selected_labels = issuable_sidebar[:labels]
- .block.labels
- .sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(selected_labels), data: { placement: "left", container: "body", boundary: 'viewport' } }
- = icon('tags', 'aria-hidden': 'true')
- %span
- = selected_labels.size
- .title.hide-collapsed
- = _('Labels')
- = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- - if can_edit_issuable
- = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link qa-edit-link-labels float-right', data: { track_label: "right_sidebar", track_property: "labels", track_event: "click_edit_button", track_value: "" }
- .value.issuable-show-labels.dont-hide.hide-collapsed.qa-labels-block{ class: ("has-labels" if selected_labels.any?) }
- - if selected_labels.any?
- - selected_labels.each do |label_hash|
- = render_label(label_from_hash(label_hash).present(issuable_subject: nil), link: sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label_hash[:title]))
- - else
- %span.no-value
- = _('None')
- .selectbox.hide-collapsed
- - selected_labels.each do |label|
- = hidden_field_tag "#{issuable_type}[label_names][]", label[:id], id: nil
- .dropdown
- %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: sidebar_label_dropdown_data(issuable_type, issuable_sidebar) }
- %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) }
- = multi_label_name(selected_labels, "Labels")
- = icon('chevron-down', 'aria-hidden': 'true')
- .dropdown-menu.dropdown-select.dropdown-menu-paging.qa-dropdown-menu-labels.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height
- = render partial: "shared/issuable/label_page_default"
- - if issuable_sidebar.dig(:current_user, :can_admin_label)
- = render partial: "shared/issuable/label_page_create"
-
- = render_if_exists 'shared/issuable/sidebar_weight', issuable_sidebar: issuable_sidebar
-
- - if issuable_sidebar.has_key?(:confidential)
+ .selectbox.hide-collapsed
+ = f.hidden_field :due_date, value: issuable_sidebar[:due_date].try(:strftime, 'yy-mm-dd')
+ .dropdown
+ %button.dropdown-menu-toggle.js-due-date-select{ type: 'button', data: { toggle: 'dropdown', field_name: "#{issuable_type}[due_date]", ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], display: 'static' } }
+ %span.dropdown-toggle-text
+ = _('Due date')
+ = icon('chevron-down', 'aria-hidden': 'true')
+ .dropdown-menu.dropdown-menu-due-date
+ = dropdown_title(_('Due date'))
+ = dropdown_content do
+ .js-due-date-calendar
+
+ - selected_labels = issuable_sidebar[:labels]
+ .block.labels
+ .sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(selected_labels), data: { placement: "left", container: "body", boundary: 'viewport' } }
+ = icon('tags', 'aria-hidden': 'true')
+ %span
+ = selected_labels.size
+ .title.hide-collapsed
+ = _('Labels')
+ = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
+ - if can_edit_issuable
+ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link qa-edit-link-labels float-right', data: { track_label: "right_sidebar", track_property: "labels", track_event: "click_edit_button", track_value: "" }
+ .value.issuable-show-labels.dont-hide.hide-collapsed.qa-labels-block{ class: ("has-labels" if selected_labels.any?) }
+ - if selected_labels.any?
+ - selected_labels.each do |label_hash|
+ = render_label(label_from_hash(label_hash).present(issuable_subject: nil), link: sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label_hash[:title]))
+ - else
+ %span.no-value
+ = _('None')
+ .selectbox.hide-collapsed
+ - selected_labels.each do |label|
+ = hidden_field_tag "#{issuable_type}[label_names][]", label[:id], id: nil
+ .dropdown
+ %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: sidebar_label_dropdown_data(issuable_type, issuable_sidebar) }
+ %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) }
+ = multi_label_name(selected_labels, "Labels")
+ = icon('chevron-down', 'aria-hidden': 'true')
+ .dropdown-menu.dropdown-select.dropdown-menu-paging.qa-dropdown-menu-labels.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height
+ = render partial: "shared/issuable/label_page_default"
+ - if issuable_sidebar.dig(:current_user, :can_admin_label)
+ = render partial: "shared/issuable/label_page_create"
+
+ = render_if_exists 'shared/issuable/sidebar_weight', issuable_sidebar: issuable_sidebar
+
+ - if issuable_sidebar.has_key?(:confidential)
+ -# haml-lint:disable InlineJavaScript
+ %script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: issuable_sidebar[:confidential], is_editable: can_edit_issuable }.to_json.html_safe
+ #js-confidential-entry-point
+
-# haml-lint:disable InlineJavaScript
- %script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: issuable_sidebar[:confidential], is_editable: can_edit_issuable }.to_json.html_safe
- #js-confidential-entry-point
+ %script#js-lock-issue-data{ type: "application/json" }= { is_locked: !!issuable_sidebar[:discussion_locked], is_editable: can_edit_issuable }.to_json.html_safe
+ #js-lock-entry-point
+
+ .js-sidebar-participants-entry-point
+
+ - if signed_in
+ - if issuable_sidebar[:project_emails_disabled]
+ .block.js-emails-disabled
+ .sidebar-collapsed-icon.has-tooltip{ title: notification_description(:owner_disabled), data: { placement: "left", container: "body", boundary: 'viewport' } }
+ = notification_setting_icon
+ .hide-collapsed= notification_description(:owner_disabled)
+ - else
+ .js-sidebar-subscriptions-entry-point
+
+ - project_ref = issuable_sidebar[:reference]
+ .block.project-reference
+ .sidebar-collapsed-icon.dont-change-state
+ = clipboard_button(text: project_ref, title: _('Copy reference'), placement: "left", boundary: 'viewport')
+ .cross-project-reference.hide-collapsed
+ %span
+ = _('Reference:')
+ %cite{ title: project_ref }
+ = project_ref
+ = clipboard_button(text: project_ref, title: _('Copy reference'), placement: "left", boundary: 'viewport')
+
+ - if issuable_sidebar.dig(:current_user, :can_move)
+ .block.js-sidebar-move-issue-block
+ .sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body', boundary: 'viewport' }, title: _('Move issue') }
+ = custom_icon('icon_arrow_right')
+ .dropdown.sidebar-move-issue-dropdown.hide-collapsed
+ %button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button',
+ data: { toggle: 'dropdown', display: 'static', track_label: "right_sidebar", track_property: "move_issue", track_event: "click_button", track_value: "" } }
+ = _('Move issue')
+ .dropdown-menu.dropdown-menu-selectable.dropdown-extended-height
+ = dropdown_title(_('Move issue'))
+ = dropdown_filter(_('Search project'), search_id: 'sidebar-move-issue-dropdown-search')
+ = dropdown_content
+ = dropdown_loading
+ = dropdown_footer add_content_class: true do
+ %button.btn.btn-success.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ type: 'button', disabled: true }
+ = _('Move')
+ = icon('spinner spin', class: 'sidebar-move-issue-confirmation-loading-icon')
-# haml-lint:disable InlineJavaScript
- %script#js-lock-issue-data{ type: "application/json" }= { is_locked: !!issuable_sidebar[:discussion_locked], is_editable: can_edit_issuable }.to_json.html_safe
- #js-lock-entry-point
-
- .js-sidebar-participants-entry-point
-
- - if signed_in
- - if issuable_sidebar[:project_emails_disabled]
- .block.js-emails-disabled
- .sidebar-collapsed-icon.has-tooltip{ title: notification_description(:owner_disabled), data: { placement: "left", container: "body", boundary: 'viewport' } }
- = notification_setting_icon
- .hide-collapsed= notification_description(:owner_disabled)
- - else
- .js-sidebar-subscriptions-entry-point
-
- - project_ref = issuable_sidebar[:reference]
- .block.project-reference
- .sidebar-collapsed-icon.dont-change-state
- = clipboard_button(text: project_ref, title: _('Copy reference to clipboard'), placement: "left", boundary: 'viewport')
- .cross-project-reference.hide-collapsed
- %span
- = _('Reference:')
- %cite{ title: project_ref }
- = project_ref
- = clipboard_button(text: project_ref, title: _('Copy reference to clipboard'), placement: "left", boundary: 'viewport')
-
- - if issuable_sidebar.dig(:current_user, :can_move)
- .block.js-sidebar-move-issue-block
- .sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body', boundary: 'viewport' }, title: _('Move issue') }
- = custom_icon('icon_arrow_right')
- .dropdown.sidebar-move-issue-dropdown.hide-collapsed
- %button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button',
- data: { toggle: 'dropdown', display: 'static', track_label: "right_sidebar", track_property: "move_issue", track_event: "click_button", track_value: "" } }
- = _('Move issue')
- .dropdown-menu.dropdown-menu-selectable.dropdown-extended-height
- = dropdown_title(_('Move issue'))
- = dropdown_filter(_('Search project'), search_id: 'sidebar-move-issue-dropdown-search')
- = dropdown_content
- = dropdown_loading
- = dropdown_footer add_content_class: true do
- %button.btn.btn-success.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ type: 'button', disabled: true }
- = _('Move')
- = icon('spinner spin', class: 'sidebar-move-issue-confirmation-loading-icon')
-
- -# haml-lint:disable InlineJavaScript
- %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable_sidebar).to_json.html_safe
+ %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable_sidebar).to_json.html_safe
diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml
index dfb0e7ed297..e6b8e299e1c 100644
--- a/app/views/shared/issuable/_sidebar_assignees.html.haml
+++ b/app/views/shared/issuable/_sidebar_assignees.html.haml
@@ -11,7 +11,7 @@
= hidden_field_tag "#{issuable_type}[assignee_ids][]", 0, id: nil
- else
- assignees.each do |assignee|
- = hidden_field_tag "#{issuable_type}[assignee_ids][]", assignee.id, id: nil, data: { avatar_url: assignee.avatar_url, name: assignee.name, username: assignee.username }
+ = hidden_field_tag "#{issuable_type}[assignee_ids][]", assignee.id, id: nil, data: assignee_sidebar_data(assignee, merge_request: @merge_request)
- options = { toggle_class: 'js-user-search js-author-search',
title: _('Assign to'),
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index ced6af50501..22a6d5e33f0 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -142,10 +142,10 @@
- if milestone_ref.present?
.block.reference
.sidebar-collapsed-icon.dont-change-state
- = clipboard_button(text: milestone_ref, title: "Copy reference to clipboard", placement: "left", boundary: 'viewport')
+ = clipboard_button(text: milestone_ref, title: _("Copy reference"), placement: "left", boundary: 'viewport')
.cross-project-reference.hide-collapsed
%span
Reference:
%cite{ title: milestone_ref }
= milestone_ref
- = clipboard_button(text: milestone_ref, title: "Copy reference to clipboard", placement: "left", boundary: 'viewport')
+ = clipboard_button(text: milestone_ref, title: _("Copy reference"), placement: "left", boundary: 'viewport')
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index bcce7cb52fb..67dad9b7a75 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -12,7 +12,9 @@
- css_class += " no-description" if project.description.blank? && !show_last_commit_as_description
- cache_key = project_list_cache_key(project, pipeline_status: pipeline_status)
- updated_tooltip = time_ago_with_tooltip(project.last_activity_date)
-- css_controls_class = compact_mode ? "" : "flex-lg-row justify-content-lg-between"
+- show_pipeline_status_icon = pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project)
+- css_controls_class = compact_mode ? [] : ["flex-lg-row", "justify-content-lg-between"]
+- css_controls_class << "with-pipeline-status" if show_pipeline_status_icon
- avatar_container_class = project.creator && use_creator_avatar ? '' : 'rect-avatar'
%li.project-row.d-flex{ class: css_class }
@@ -58,12 +60,12 @@
.description.d-none.d-sm-block.append-right-default
= markdown_field(project, :description)
- .controls.d-flex.flex-sm-column.align-items-center.align-items-sm-end.flex-wrap.flex-shrink-0.text-secondary{ class: css_controls_class }
+ .controls.d-flex.flex-sm-column.align-items-center.align-items-sm-end.flex-wrap.flex-shrink-0.text-secondary{ class: css_controls_class.join(" ") }
.icon-container.d-flex.align-items-center
- - if pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project)
+ - if show_pipeline_status_icon
- pipeline_path = pipelines_project_commit_path(project.pipeline_status.project, project.pipeline_status.sha, ref: project.pipeline_status.ref)
%span.icon-wrapper.pipeline-status
- = render 'ci/status/icon', status: project.commit.last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path
+ = render 'ci/status/icon', status: project.last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path
- if project.archived
%span.d-flex.icon-wrapper.badge.badge-warning archived
diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml
index 69481293f90..8d94a87a775 100644
--- a/app/views/shared/snippets/_header.html.haml
+++ b/app/views/shared/snippets/_header.html.haml
@@ -46,5 +46,5 @@
%strong.embed-toggle-list-item= _("Share")
%input.js-snippet-url-area.snippet-embed-input.form-control{ type: "text", autocomplete: 'off', value: snippet_embed }
.input-group-append
- = clipboard_button(title: s_('Copy to clipboard'), class: 'js-clipboard-btn snippet-clipboard-btn btn btn-default', target: '.js-snippet-url-area')
+ = clipboard_button(title: _('Copy'), class: 'js-clipboard-btn snippet-clipboard-btn btn btn-default', target: '.js-snippet-url-area')
.clearfix
diff --git a/app/workers/admin_email_worker.rb b/app/workers/admin_email_worker.rb
index f69e74b2674..be05d2a6752 100644
--- a/app/workers/admin_email_worker.rb
+++ b/app/workers/admin_email_worker.rb
@@ -4,6 +4,8 @@ class AdminEmailWorker
include ApplicationWorker
include CronjobQueue
+ feature_category_not_owned!
+
def perform
send_repository_check_mail if Gitlab::CurrentSettings.repository_checks_enabled
end
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index a33afd436b0..b161cc65602 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -119,6 +119,8 @@
- container_repository:delete_container_repository
- container_repository:cleanup_container_repository
+- notifications:new_release
+
- default
- mailers # ActionMailer::DeliveryJob.queue_name
@@ -173,3 +175,4 @@
- delete_stored_files
- import_issues_csv
- project_daily_statistics
+- create_evidence
diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb
index c9ddeb08613..577c439f4a2 100644
--- a/app/workers/authorized_projects_worker.rb
+++ b/app/workers/authorized_projects_worker.rb
@@ -4,6 +4,8 @@ class AuthorizedProjectsWorker
include ApplicationWorker
prepend WaitableWorker
+ feature_category :authentication_and_authorization
+
# This is a workaround for a Ruby 2.3.7 bug. rspec-mocks cannot restore the
# visibility of prepended modules. See https://github.com/rspec/rspec-mocks/issues/1231
# for more details.
diff --git a/app/workers/auto_merge_process_worker.rb b/app/workers/auto_merge_process_worker.rb
index cd81cdbc60c..e4dccb891ce 100644
--- a/app/workers/auto_merge_process_worker.rb
+++ b/app/workers/auto_merge_process_worker.rb
@@ -4,6 +4,7 @@ class AutoMergeProcessWorker
include ApplicationWorker
queue_namespace :auto_merge
+ feature_category :continuous_delivery
def perform(merge_request_id)
MergeRequest.find_by_id(merge_request_id).try do |merge_request|
diff --git a/app/workers/background_migration_worker.rb b/app/workers/background_migration_worker.rb
index b83412b5e6e..20e2cdd7f96 100644
--- a/app/workers/background_migration_worker.rb
+++ b/app/workers/background_migration_worker.rb
@@ -3,6 +3,8 @@
class BackgroundMigrationWorker
include ApplicationWorker
+ feature_category_not_owned!
+
# The minimum amount of time between processing two jobs of the same migration
# class.
#
diff --git a/app/workers/build_hooks_worker.rb b/app/workers/build_hooks_worker.rb
index b0c3676714c..15b31acf3e5 100644
--- a/app/workers/build_hooks_worker.rb
+++ b/app/workers/build_hooks_worker.rb
@@ -5,6 +5,7 @@ class BuildHooksWorker
include PipelineQueue
queue_namespace :pipeline_hooks
+ feature_category :continuous_integration
# rubocop: disable CodeReuse/ActiveRecord
def perform(build_id)
diff --git a/app/workers/build_queue_worker.rb b/app/workers/build_queue_worker.rb
index 67d5b0f5f5b..6584fba4c65 100644
--- a/app/workers/build_queue_worker.rb
+++ b/app/workers/build_queue_worker.rb
@@ -5,6 +5,7 @@ class BuildQueueWorker
include PipelineQueue
queue_namespace :pipeline_processing
+ feature_category :continuous_integration
# rubocop: disable CodeReuse/ActiveRecord
def perform(build_id)
diff --git a/app/workers/chat_notification_worker.rb b/app/workers/chat_notification_worker.rb
index 25a306e94d8..3bc2edad62c 100644
--- a/app/workers/chat_notification_worker.rb
+++ b/app/workers/chat_notification_worker.rb
@@ -3,6 +3,8 @@
class ChatNotificationWorker
include ApplicationWorker
+ feature_category :chatops
+
RESCHEDULE_INTERVAL = 2.seconds
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/workers/ci/archive_traces_cron_worker.rb b/app/workers/ci/archive_traces_cron_worker.rb
index ad7a29719ac..74f389175b9 100644
--- a/app/workers/ci/archive_traces_cron_worker.rb
+++ b/app/workers/ci/archive_traces_cron_worker.rb
@@ -5,6 +5,8 @@ module Ci
include ApplicationWorker
include CronjobQueue
+ feature_category :continuous_integration
+
# rubocop: disable CodeReuse/ActiveRecord
def perform
# Archive stale live traces which still resides in redis or database
diff --git a/app/workers/ci/build_prepare_worker.rb b/app/workers/ci/build_prepare_worker.rb
index 1a35a74ae53..20208c18d03 100644
--- a/app/workers/ci/build_prepare_worker.rb
+++ b/app/workers/ci/build_prepare_worker.rb
@@ -6,6 +6,7 @@ module Ci
include PipelineQueue
queue_namespace :pipeline_processing
+ feature_category :continuous_integration
def perform(build_id)
Ci::Build.find_by_id(build_id).try do |build|
diff --git a/app/workers/ci/build_schedule_worker.rb b/app/workers/ci/build_schedule_worker.rb
index da219adffc6..f22ec4c7810 100644
--- a/app/workers/ci/build_schedule_worker.rb
+++ b/app/workers/ci/build_schedule_worker.rb
@@ -6,6 +6,7 @@ module Ci
include PipelineQueue
queue_namespace :pipeline_processing
+ feature_category :continuous_integration
def perform(build_id)
::Ci::Build.find_by_id(build_id).try do |build|
diff --git a/app/workers/cleanup_container_repository_worker.rb b/app/workers/cleanup_container_repository_worker.rb
index 0331fc7b01c..83fb3e58d29 100644
--- a/app/workers/cleanup_container_repository_worker.rb
+++ b/app/workers/cleanup_container_repository_worker.rb
@@ -4,6 +4,7 @@ class CleanupContainerRepositoryWorker
include ApplicationWorker
queue_namespace :container_repository
+ feature_category :container_registry
attr_reader :container_repository, :current_user
diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb
index 2b36ccb8304..62748808ff1 100644
--- a/app/workers/concerns/application_worker.rb
+++ b/app/workers/concerns/application_worker.rb
@@ -8,6 +8,7 @@ module ApplicationWorker
extend ActiveSupport::Concern
include Sidekiq::Worker # rubocop:disable Cop/IncludeSidekiqWorker
+ include WorkerAttributes
included do
set_queue
diff --git a/app/workers/concerns/auto_devops_queue.rb b/app/workers/concerns/auto_devops_queue.rb
index aba928ccaab..61e3c1544bd 100644
--- a/app/workers/concerns/auto_devops_queue.rb
+++ b/app/workers/concerns/auto_devops_queue.rb
@@ -5,5 +5,6 @@ module AutoDevopsQueue
included do
queue_namespace :auto_devops
+ feature_category :auto_devops
end
end
diff --git a/app/workers/concerns/chaos_queue.rb b/app/workers/concerns/chaos_queue.rb
index e406509d12d..c5db10491f2 100644
--- a/app/workers/concerns/chaos_queue.rb
+++ b/app/workers/concerns/chaos_queue.rb
@@ -5,5 +5,6 @@ module ChaosQueue
included do
queue_namespace :chaos
+ feature_category :chaos_engineering
end
end
diff --git a/app/workers/concerns/cluster_queue.rb b/app/workers/concerns/cluster_queue.rb
index e44b40c36c9..180b86b0124 100644
--- a/app/workers/concerns/cluster_queue.rb
+++ b/app/workers/concerns/cluster_queue.rb
@@ -8,5 +8,6 @@ module ClusterQueue
included do
queue_namespace :gcp_cluster
+ feature_category :kubernetes_configuration
end
end
diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb
index eeeff6e93a0..b856a9329dd 100644
--- a/app/workers/concerns/gitlab/github_import/object_importer.rb
+++ b/app/workers/concerns/gitlab/github_import/object_importer.rb
@@ -12,6 +12,8 @@ module Gitlab
include GithubImport::Queue
include ReschedulingMethods
include NotifyUponDeath
+
+ feature_category :importers
end
# project - An instance of `Project` to import the data into.
diff --git a/app/workers/concerns/gitlab/github_import/queue.rb b/app/workers/concerns/gitlab/github_import/queue.rb
index 59b621f16ab..7cc23dd7c0b 100644
--- a/app/workers/concerns/gitlab/github_import/queue.rb
+++ b/app/workers/concerns/gitlab/github_import/queue.rb
@@ -7,6 +7,7 @@ module Gitlab
included do
queue_namespace :github_importer
+ feature_category :importers
# If a job produces an error it may block a stage from advancing
# forever. To prevent this from happening we prevent jobs from going to
diff --git a/app/workers/concerns/object_pool_queue.rb b/app/workers/concerns/object_pool_queue.rb
index 5b648df9c72..c2e84470fba 100644
--- a/app/workers/concerns/object_pool_queue.rb
+++ b/app/workers/concerns/object_pool_queue.rb
@@ -8,5 +8,6 @@ module ObjectPoolQueue
included do
queue_namespace :object_pool
+ feature_category :gitaly
end
end
diff --git a/app/workers/concerns/pipeline_background_queue.rb b/app/workers/concerns/pipeline_background_queue.rb
index bbb8ad0c982..0a23780b807 100644
--- a/app/workers/concerns/pipeline_background_queue.rb
+++ b/app/workers/concerns/pipeline_background_queue.rb
@@ -8,5 +8,6 @@ module PipelineBackgroundQueue
included do
queue_namespace :pipeline_background
+ feature_category :continuous_integration
end
end
diff --git a/app/workers/concerns/pipeline_queue.rb b/app/workers/concerns/pipeline_queue.rb
index 3aaed4669e5..27cbf6eb61c 100644
--- a/app/workers/concerns/pipeline_queue.rb
+++ b/app/workers/concerns/pipeline_queue.rb
@@ -8,5 +8,6 @@ module PipelineQueue
included do
queue_namespace :pipeline_default
+ feature_category :continuous_integration
end
end
diff --git a/app/workers/concerns/repository_check_queue.rb b/app/workers/concerns/repository_check_queue.rb
index 216d67e5dbc..76f6e1c2e91 100644
--- a/app/workers/concerns/repository_check_queue.rb
+++ b/app/workers/concerns/repository_check_queue.rb
@@ -6,7 +6,7 @@ module RepositoryCheckQueue
included do
queue_namespace :repository_check
-
sidekiq_options retry: false
+ feature_category :source_code_management
end
end
diff --git a/app/workers/concerns/todos_destroyer_queue.rb b/app/workers/concerns/todos_destroyer_queue.rb
index 8e2b1d30579..1bbccbfb1f9 100644
--- a/app/workers/concerns/todos_destroyer_queue.rb
+++ b/app/workers/concerns/todos_destroyer_queue.rb
@@ -8,5 +8,6 @@ module TodosDestroyerQueue
included do
queue_namespace :todos_destroyer
+ feature_category :issue_tracking
end
end
diff --git a/app/workers/create_evidence_worker.rb b/app/workers/create_evidence_worker.rb
new file mode 100644
index 00000000000..027dbd2f101
--- /dev/null
+++ b/app/workers/create_evidence_worker.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class CreateEvidenceWorker
+ include ApplicationWorker
+
+ feature_category :release_governance
+
+ def perform(release_id)
+ release = Release.find_by_id(release_id)
+ return unless release
+
+ Evidence.create!(release: release)
+ end
+end
diff --git a/app/workers/create_gpg_signature_worker.rb b/app/workers/create_gpg_signature_worker.rb
index e3fb5d479ae..fc36a2adccd 100644
--- a/app/workers/create_gpg_signature_worker.rb
+++ b/app/workers/create_gpg_signature_worker.rb
@@ -3,6 +3,8 @@
class CreateGpgSignatureWorker
include ApplicationWorker
+ feature_category :source_code_management
+
# rubocop: disable CodeReuse/ActiveRecord
def perform(commit_shas, project_id)
# Older versions of Git::BranchPushService may push a single commit ID on
diff --git a/app/workers/create_note_diff_file_worker.rb b/app/workers/create_note_diff_file_worker.rb
index 0850250f7e3..ca200bd17b4 100644
--- a/app/workers/create_note_diff_file_worker.rb
+++ b/app/workers/create_note_diff_file_worker.rb
@@ -3,6 +3,8 @@
class CreateNoteDiffFileWorker
include ApplicationWorker
+ feature_category :source_code_management
+
def perform(diff_note_id)
diff_note = DiffNote.find(diff_note_id)
diff --git a/app/workers/create_pipeline_worker.rb b/app/workers/create_pipeline_worker.rb
index 037b4a57d4b..70412ffd095 100644
--- a/app/workers/create_pipeline_worker.rb
+++ b/app/workers/create_pipeline_worker.rb
@@ -5,6 +5,7 @@ class CreatePipelineWorker
include PipelineQueue
queue_namespace :pipeline_creation
+ feature_category :continuous_integration
def perform(project_id, user_id, ref, source, params = {})
project = Project.find(project_id)
diff --git a/app/workers/delete_container_repository_worker.rb b/app/workers/delete_container_repository_worker.rb
index 42e66513ff1..e70b4fb0a58 100644
--- a/app/workers/delete_container_repository_worker.rb
+++ b/app/workers/delete_container_repository_worker.rb
@@ -5,6 +5,7 @@ class DeleteContainerRepositoryWorker
include ExclusiveLeaseGuard
queue_namespace :container_repository
+ feature_category :container_registry
LEASE_TIMEOUT = 1.hour
diff --git a/app/workers/delete_diff_files_worker.rb b/app/workers/delete_diff_files_worker.rb
index f518dfe871c..e0c1724f1f7 100644
--- a/app/workers/delete_diff_files_worker.rb
+++ b/app/workers/delete_diff_files_worker.rb
@@ -3,6 +3,8 @@
class DeleteDiffFilesWorker
include ApplicationWorker
+ feature_category :source_code_management
+
# rubocop: disable CodeReuse/ActiveRecord
def perform(merge_request_diff_id)
merge_request_diff = MergeRequestDiff.find(merge_request_diff_id)
diff --git a/app/workers/delete_merged_branches_worker.rb b/app/workers/delete_merged_branches_worker.rb
index 017d7fd1cb0..44b3db30d0d 100644
--- a/app/workers/delete_merged_branches_worker.rb
+++ b/app/workers/delete_merged_branches_worker.rb
@@ -3,6 +3,8 @@
class DeleteMergedBranchesWorker
include ApplicationWorker
+ feature_category :source_code_management
+
def perform(project_id, user_id)
begin
project = Project.find(project_id)
diff --git a/app/workers/delete_stored_files_worker.rb b/app/workers/delete_stored_files_worker.rb
index ff7931849d8..8a693a64055 100644
--- a/app/workers/delete_stored_files_worker.rb
+++ b/app/workers/delete_stored_files_worker.rb
@@ -3,6 +3,8 @@
class DeleteStoredFilesWorker
include ApplicationWorker
+ feature_category_not_owned!
+
def perform(class_name, keys)
klass = begin
class_name.constantize
diff --git a/app/workers/delete_user_worker.rb b/app/workers/delete_user_worker.rb
index efa8794b214..0e49e787d8a 100644
--- a/app/workers/delete_user_worker.rb
+++ b/app/workers/delete_user_worker.rb
@@ -3,6 +3,8 @@
class DeleteUserWorker
include ApplicationWorker
+ feature_category :authentication_and_authorization
+
def perform(current_user_id, delete_user_id, options = {})
delete_user = User.find(delete_user_id)
current_user = User.find(current_user_id)
diff --git a/app/workers/deployments/finished_worker.rb b/app/workers/deployments/finished_worker.rb
index c9d448d5d18..79a1caccc92 100644
--- a/app/workers/deployments/finished_worker.rb
+++ b/app/workers/deployments/finished_worker.rb
@@ -5,6 +5,7 @@ module Deployments
include ApplicationWorker
queue_namespace :deployment
+ feature_category :continuous_delivery
def perform(deployment_id)
Deployment.find_by_id(deployment_id).try(:execute_hooks)
diff --git a/app/workers/deployments/success_worker.rb b/app/workers/deployments/success_worker.rb
index da517f3fb26..f6520307186 100644
--- a/app/workers/deployments/success_worker.rb
+++ b/app/workers/deployments/success_worker.rb
@@ -5,12 +5,13 @@ module Deployments
include ApplicationWorker
queue_namespace :deployment
+ feature_category :continuous_delivery
def perform(deployment_id)
Deployment.find_by_id(deployment_id).try do |deployment|
break unless deployment.success?
- UpdateDeploymentService.new(deployment).execute
+ Deployments::AfterCreateService.new(deployment).execute
end
end
end
diff --git a/app/workers/detect_repository_languages_worker.rb b/app/workers/detect_repository_languages_worker.rb
index 838c3be78f0..954d0f9336b 100644
--- a/app/workers/detect_repository_languages_worker.rb
+++ b/app/workers/detect_repository_languages_worker.rb
@@ -6,6 +6,7 @@ class DetectRepositoryLanguagesWorker
include ExclusiveLeaseGuard
sidekiq_options retry: 1
+ feature_category :source_code_management
LEASE_TIMEOUT = 300
diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb
index e70bf17d5a9..c82728be329 100644
--- a/app/workers/email_receiver_worker.rb
+++ b/app/workers/email_receiver_worker.rb
@@ -3,6 +3,8 @@
class EmailReceiverWorker
include ApplicationWorker
+ feature_category :issue_tracking
+
def perform(raw)
return unless Gitlab::IncomingEmail.enabled?
diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb
index ed3e354e4c2..2231c91a720 100644
--- a/app/workers/emails_on_push_worker.rb
+++ b/app/workers/emails_on_push_worker.rb
@@ -5,6 +5,8 @@ class EmailsOnPushWorker
attr_reader :email, :skip_premailer
+ feature_category :source_code_management
+
def perform(project_id, recipients, push_data, options = {})
options.symbolize_keys!
options.reverse_merge!(
diff --git a/app/workers/expire_build_artifacts_worker.rb b/app/workers/expire_build_artifacts_worker.rb
index 6f0e0fd33f7..9545227fa31 100644
--- a/app/workers/expire_build_artifacts_worker.rb
+++ b/app/workers/expire_build_artifacts_worker.rb
@@ -4,6 +4,8 @@ class ExpireBuildArtifactsWorker
include ApplicationWorker
include CronjobQueue
+ feature_category :continuous_integration
+
def perform
if Feature.enabled?(:ci_new_expire_job_artifacts_service, default_enabled: true)
perform_efficient_artifacts_removal
diff --git a/app/workers/expire_build_instance_artifacts_worker.rb b/app/workers/expire_build_instance_artifacts_worker.rb
index 71e61dcb878..db5240d5c8e 100644
--- a/app/workers/expire_build_instance_artifacts_worker.rb
+++ b/app/workers/expire_build_instance_artifacts_worker.rb
@@ -3,6 +3,8 @@
class ExpireBuildInstanceArtifactsWorker
include ApplicationWorker
+ feature_category :continuous_integration
+
# rubocop: disable CodeReuse/ActiveRecord
def perform(build_id)
build = Ci::Build
diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb
index 5499e12e49b..ad119917774 100644
--- a/app/workers/git_garbage_collect_worker.rb
+++ b/app/workers/git_garbage_collect_worker.rb
@@ -4,6 +4,7 @@ class GitGarbageCollectWorker
include ApplicationWorker
sidekiq_options retry: false
+ feature_category :gitaly
# Timeout set to 24h
LEASE_TIMEOUT = 86400
diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb
index 0b3437a8a33..44e69e48694 100644
--- a/app/workers/gitlab/github_import/advance_stage_worker.rb
+++ b/app/workers/gitlab/github_import/advance_stage_worker.rb
@@ -10,6 +10,7 @@ module Gitlab
include ApplicationWorker
sidekiq_options dead: false
+ feature_category :importers
INTERVAL = 30.seconds.to_i
diff --git a/app/workers/gitlab/github_import/stage/finish_import_worker.rb b/app/workers/gitlab/github_import/stage/finish_import_worker.rb
index a779e631516..ee64f62637e 100644
--- a/app/workers/gitlab/github_import/stage/finish_import_worker.rb
+++ b/app/workers/gitlab/github_import/stage/finish_import_worker.rb
@@ -8,6 +8,10 @@ module Gitlab
include GithubImport::Queue
include StageMethods
+ # technical debt: https://gitlab.com/gitlab-org/gitlab/issues/33991
+ sidekiq_options memory_killer_memory_growth_kb: ENV.fetch('MEMORY_KILLER_FINISH_IMPORT_WORKER_MEMORY_GROWTH_KB', 50).to_i
+ sidekiq_options memory_killer_max_memory_growth_kb: ENV.fetch('MEMORY_KILLER_FINISH_IMPORT_WORKER_MAX_MEMORY_GROWTH_KB', 200_000).to_i
+
# project - An instance of Project.
def import(_, project)
project.after_import
diff --git a/app/workers/gitlab/github_import/stage/import_repository_worker.rb b/app/workers/gitlab/github_import/stage/import_repository_worker.rb
index 4d16cef1130..b5e30470070 100644
--- a/app/workers/gitlab/github_import/stage/import_repository_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_repository_worker.rb
@@ -8,6 +8,10 @@ module Gitlab
include GithubImport::Queue
include StageMethods
+ # technical debt: https://gitlab.com/gitlab-org/gitlab/issues/33991
+ sidekiq_options memory_killer_memory_growth_kb: ENV.fetch('MEMORY_KILLER_IMPORT_REPOSITORY_WORKER_MEMORY_GROWTH_KB', 50).to_i
+ sidekiq_options memory_killer_max_memory_growth_kb: ENV.fetch('MEMORY_KILLER_IMPORT_REPOSITORY_WORKER_MAX_MEMORY_GROWTH_KB', 300_000).to_i
+
# client - An instance of Gitlab::GithubImport::Client.
# project - An instance of Project.
def import(client, project)
diff --git a/app/workers/gitlab_shell_worker.rb b/app/workers/gitlab_shell_worker.rb
index 0e4d40acc5c..9766331cf4b 100644
--- a/app/workers/gitlab_shell_worker.rb
+++ b/app/workers/gitlab_shell_worker.rb
@@ -4,6 +4,8 @@ class GitlabShellWorker
include ApplicationWorker
include Gitlab::ShellAdapter
+ feature_category :source_code_management
+
def perform(action, *arg)
gitlab_shell.__send__(action, *arg) # rubocop:disable GitlabSecurity/PublicSend
end
diff --git a/app/workers/gitlab_usage_ping_worker.rb b/app/workers/gitlab_usage_ping_worker.rb
index a5e22f88a3b..ad8302a844a 100644
--- a/app/workers/gitlab_usage_ping_worker.rb
+++ b/app/workers/gitlab_usage_ping_worker.rb
@@ -6,6 +6,8 @@ class GitlabUsagePingWorker
include ApplicationWorker
include CronjobQueue
+ feature_category_not_owned!
+
# Retry for up to approximately three hours then give up.
sidekiq_options retry: 10, dead: false
diff --git a/app/workers/group_destroy_worker.rb b/app/workers/group_destroy_worker.rb
index b4a3ddcae51..553fd359baf 100644
--- a/app/workers/group_destroy_worker.rb
+++ b/app/workers/group_destroy_worker.rb
@@ -4,6 +4,8 @@ class GroupDestroyWorker
include ApplicationWorker
include ExceptionBacktrace
+ feature_category :groups
+
def perform(group_id, user_id)
begin
group = Group.find(group_id)
diff --git a/app/workers/hashed_storage/base_worker.rb b/app/workers/hashed_storage/base_worker.rb
index 237e278c537..1ab2108f6bb 100644
--- a/app/workers/hashed_storage/base_worker.rb
+++ b/app/workers/hashed_storage/base_worker.rb
@@ -3,6 +3,9 @@
module HashedStorage
class BaseWorker
include ExclusiveLeaseGuard
+ include WorkerAttributes
+
+ feature_category :source_code_management
LEASE_TIMEOUT = 30.seconds.to_i
LEASE_KEY_SEGMENT = 'project_migrate_hashed_storage_worker'
diff --git a/app/workers/hashed_storage/migrator_worker.rb b/app/workers/hashed_storage/migrator_worker.rb
index 49e347d4060..72a3faec5f4 100644
--- a/app/workers/hashed_storage/migrator_worker.rb
+++ b/app/workers/hashed_storage/migrator_worker.rb
@@ -5,6 +5,7 @@ module HashedStorage
include ApplicationWorker
queue_namespace :hashed_storage
+ feature_category :source_code_management
# @param [Integer] start initial ID of the batch
# @param [Integer] finish last ID of the batch
diff --git a/app/workers/hashed_storage/rollbacker_worker.rb b/app/workers/hashed_storage/rollbacker_worker.rb
index a4da8443787..8babdcfb96d 100644
--- a/app/workers/hashed_storage/rollbacker_worker.rb
+++ b/app/workers/hashed_storage/rollbacker_worker.rb
@@ -5,6 +5,7 @@ module HashedStorage
include ApplicationWorker
queue_namespace :hashed_storage
+ feature_category :source_code_management
# @param [Integer] start initial ID of the batch
# @param [Integer] finish last ID of the batch
diff --git a/app/workers/import_export_project_cleanup_worker.rb b/app/workers/import_export_project_cleanup_worker.rb
index da3debdeede..07c29d40b54 100644
--- a/app/workers/import_export_project_cleanup_worker.rb
+++ b/app/workers/import_export_project_cleanup_worker.rb
@@ -4,6 +4,8 @@ class ImportExportProjectCleanupWorker
include ApplicationWorker
include CronjobQueue
+ feature_category :importers
+
def perform
ImportExportCleanUpService.new.execute
end
diff --git a/app/workers/import_issues_csv_worker.rb b/app/workers/import_issues_csv_worker.rb
index d44fdfec8ae..d9834320318 100644
--- a/app/workers/import_issues_csv_worker.rb
+++ b/app/workers/import_issues_csv_worker.rb
@@ -3,6 +3,8 @@
class ImportIssuesCsvWorker
include ApplicationWorker
+ feature_category :issue_tracking
+
sidekiq_retries_exhausted do |job|
Upload.find(job['args'][2]).destroy
end
@@ -12,7 +14,7 @@ class ImportIssuesCsvWorker
@project = Project.find(project_id)
@upload = Upload.find(upload_id)
- importer = Issues::ImportCsvService.new(@user, @project, @upload.build_uploader)
+ importer = Issues::ImportCsvService.new(@user, @project, @upload.retrieve_uploader)
importer.execute
@upload.destroy
diff --git a/app/workers/invalid_gpg_signature_update_worker.rb b/app/workers/invalid_gpg_signature_update_worker.rb
index fc8a731b427..573efdf9fb1 100644
--- a/app/workers/invalid_gpg_signature_update_worker.rb
+++ b/app/workers/invalid_gpg_signature_update_worker.rb
@@ -3,6 +3,8 @@
class InvalidGpgSignatureUpdateWorker
include ApplicationWorker
+ feature_category :source_code_management
+
# rubocop: disable CodeReuse/ActiveRecord
def perform(gpg_key_id)
gpg_key = GpgKey.find_by(id: gpg_key_id)
diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb
index 29631c6b7ac..a133ed6ed1b 100644
--- a/app/workers/irker_worker.rb
+++ b/app/workers/irker_worker.rb
@@ -6,6 +6,8 @@ require 'socket'
class IrkerWorker
include ApplicationWorker
+ feature_category :integrations
+
def perform(project_id, chans, colors, push_data, settings)
project = Project.find(project_id)
diff --git a/app/workers/issue_due_scheduler_worker.rb b/app/workers/issue_due_scheduler_worker.rb
index 476cba47ad7..d4d47659ef0 100644
--- a/app/workers/issue_due_scheduler_worker.rb
+++ b/app/workers/issue_due_scheduler_worker.rb
@@ -4,6 +4,8 @@ class IssueDueSchedulerWorker
include ApplicationWorker
include CronjobQueue
+ feature_category :issue_tracking
+
# rubocop: disable CodeReuse/ActiveRecord
def perform
project_ids = Issue.opened.due_tomorrow.group(:project_id).pluck(:project_id).map { |id| [id] }
diff --git a/app/workers/mail_scheduler/issue_due_worker.rb b/app/workers/mail_scheduler/issue_due_worker.rb
index 1e1dde1e829..6df816de71f 100644
--- a/app/workers/mail_scheduler/issue_due_worker.rb
+++ b/app/workers/mail_scheduler/issue_due_worker.rb
@@ -5,6 +5,8 @@ module MailScheduler
include ApplicationWorker
include MailSchedulerQueue
+ feature_category :issue_tracking
+
# rubocop: disable CodeReuse/ActiveRecord
def perform(project_id)
Issue.opened.due_tomorrow.in_projects(project_id).preload(:project).find_each do |issue|
diff --git a/app/workers/mail_scheduler/notification_service_worker.rb b/app/workers/mail_scheduler/notification_service_worker.rb
index 421fbf04e28..0d06dab3b2e 100644
--- a/app/workers/mail_scheduler/notification_service_worker.rb
+++ b/app/workers/mail_scheduler/notification_service_worker.rb
@@ -7,6 +7,8 @@ module MailScheduler
include ApplicationWorker
include MailSchedulerQueue
+ feature_category :issue_tracking
+
def perform(meth, *args)
check_arguments!(args)
diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb
index ee864b733cd..70b909afea8 100644
--- a/app/workers/merge_worker.rb
+++ b/app/workers/merge_worker.rb
@@ -3,6 +3,8 @@
class MergeWorker
include ApplicationWorker
+ feature_category :source_code_management
+
def perform(merge_request_id, current_user_id, params)
params = params.with_indifferent_access
current_user = User.find(current_user_id)
diff --git a/app/workers/migrate_external_diffs_worker.rb b/app/workers/migrate_external_diffs_worker.rb
index debac97af2c..d248e2b5500 100644
--- a/app/workers/migrate_external_diffs_worker.rb
+++ b/app/workers/migrate_external_diffs_worker.rb
@@ -1,8 +1,10 @@
-# frozen_string_literal: false
+# frozen_string_literal: true
class MigrateExternalDiffsWorker
include ApplicationWorker
+ feature_category :source_code_management
+
def perform(merge_request_diff_id)
diff = MergeRequestDiff.find_by_id(merge_request_diff_id)
return unless diff
diff --git a/app/workers/namespaceless_project_destroy_worker.rb b/app/workers/namespaceless_project_destroy_worker.rb
index f6e98746055..113afc268f2 100644
--- a/app/workers/namespaceless_project_destroy_worker.rb
+++ b/app/workers/namespaceless_project_destroy_worker.rb
@@ -10,6 +10,8 @@ class NamespacelessProjectDestroyWorker
include ApplicationWorker
include ExceptionBacktrace
+ feature_category :authentication_and_authorization
+
def perform(project_id)
begin
project = Project.unscoped.find(project_id)
@@ -31,6 +33,6 @@ class NamespacelessProjectDestroyWorker
def unlink_fork(project)
merge_requests = project.forked_from_project.merge_requests.opened.from_project(project)
- merge_requests.update_all(state: 'closed')
+ merge_requests.update_all(state_id: MergeRequest.available_states[:closed])
end
end
diff --git a/app/workers/namespaces/prune_aggregation_schedules_worker.rb b/app/workers/namespaces/prune_aggregation_schedules_worker.rb
index 4e40feee702..16259ffbfa6 100644
--- a/app/workers/namespaces/prune_aggregation_schedules_worker.rb
+++ b/app/workers/namespaces/prune_aggregation_schedules_worker.rb
@@ -5,6 +5,8 @@ module Namespaces
include ApplicationWorker
include CronjobQueue
+ feature_category :source_code_management
+
# Worker to prune pending rows on Namespace::AggregationSchedule
# It's scheduled to run once a day at 1:05am.
def perform
diff --git a/app/workers/namespaces/root_statistics_worker.rb b/app/workers/namespaces/root_statistics_worker.rb
index 0c1ca5eb975..fd772c8cff6 100644
--- a/app/workers/namespaces/root_statistics_worker.rb
+++ b/app/workers/namespaces/root_statistics_worker.rb
@@ -5,6 +5,7 @@ module Namespaces
include ApplicationWorker
queue_namespace :update_namespace_statistics
+ feature_category :source_code_management
def perform(namespace_id)
namespace = Namespace.find(namespace_id)
diff --git a/app/workers/namespaces/schedule_aggregation_worker.rb b/app/workers/namespaces/schedule_aggregation_worker.rb
index b7d580220d6..87e135fbf21 100644
--- a/app/workers/namespaces/schedule_aggregation_worker.rb
+++ b/app/workers/namespaces/schedule_aggregation_worker.rb
@@ -5,6 +5,7 @@ module Namespaces
include ApplicationWorker
queue_namespace :update_namespace_statistics
+ feature_category :source_code_management
def perform(namespace_id)
return unless aggregation_schedules_table_exists?
diff --git a/app/workers/new_issue_worker.rb b/app/workers/new_issue_worker.rb
index 85b53973f56..1b0fec597e7 100644
--- a/app/workers/new_issue_worker.rb
+++ b/app/workers/new_issue_worker.rb
@@ -4,6 +4,8 @@ class NewIssueWorker
include ApplicationWorker
include NewIssuable
+ feature_category :issue_tracking
+
def perform(issue_id, user_id)
return unless objects_found?(issue_id, user_id)
diff --git a/app/workers/new_merge_request_worker.rb b/app/workers/new_merge_request_worker.rb
index fa48c1b29a8..0a5b2f86331 100644
--- a/app/workers/new_merge_request_worker.rb
+++ b/app/workers/new_merge_request_worker.rb
@@ -4,6 +4,8 @@ class NewMergeRequestWorker
include ApplicationWorker
include NewIssuable
+ feature_category :source_code_management
+
def perform(merge_request_id, user_id)
return unless objects_found?(merge_request_id, user_id)
diff --git a/app/workers/new_note_worker.rb b/app/workers/new_note_worker.rb
index 7648af3a8b9..d0d2a563738 100644
--- a/app/workers/new_note_worker.rb
+++ b/app/workers/new_note_worker.rb
@@ -3,6 +3,8 @@
class NewNoteWorker
include ApplicationWorker
+ feature_category :issue_tracking
+
# Keep extra parameter to preserve backwards compatibility with
# old `NewNoteWorker` jobs (can remove later)
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/workers/new_release_worker.rb b/app/workers/new_release_worker.rb
new file mode 100644
index 00000000000..28d2517238e
--- /dev/null
+++ b/app/workers/new_release_worker.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class NewReleaseWorker
+ include ApplicationWorker
+
+ queue_namespace :notifications
+ feature_category :release_orchestration
+
+ def perform(release_id)
+ release = Release.with_project_and_namespace.find_by_id(release_id)
+ return unless release
+
+ NotificationService.new.send_new_release_notifications(release)
+ end
+end
diff --git a/app/workers/object_storage/background_move_worker.rb b/app/workers/object_storage/background_move_worker.rb
index 8dff65e46e3..55f8e1c3ede 100644
--- a/app/workers/object_storage/background_move_worker.rb
+++ b/app/workers/object_storage/background_move_worker.rb
@@ -6,6 +6,7 @@ module ObjectStorage
include ObjectStorageQueue
sidekiq_options retry: 5
+ feature_category_not_owned!
def perform(uploader_class_name, subject_class_name, file_field, subject_id)
uploader_class = uploader_class_name.constantize
@@ -22,7 +23,7 @@ module ObjectStorage
def build_uploader(subject, mount_point)
case subject
- when Upload then subject.build_uploader(mount_point)
+ when Upload then subject.retrieve_uploader(mount_point)
else
subject.send(mount_point) # rubocop:disable GitlabSecurity/PublicSend
end
diff --git a/app/workers/object_storage/migrate_uploads_worker.rb b/app/workers/object_storage/migrate_uploads_worker.rb
index 55ac7cd9b3c..01e6fdb2d3e 100644
--- a/app/workers/object_storage/migrate_uploads_worker.rb
+++ b/app/workers/object_storage/migrate_uploads_worker.rb
@@ -5,6 +5,8 @@ module ObjectStorage
include ApplicationWorker
include ObjectStorageQueue
+ feature_category_not_owned!
+
SanityCheckError = Class.new(StandardError)
class MigrationResult
@@ -119,7 +121,7 @@ module ObjectStorage
end
def build_uploaders(uploads)
- uploads.map { |upload| upload.build_uploader(@mounted_as) }
+ uploads.map { |upload| upload.retrieve_uploader(@mounted_as) }
end
def migrate(uploads)
diff --git a/app/workers/pages_domain_removal_cron_worker.rb b/app/workers/pages_domain_removal_cron_worker.rb
index 79f38e1b89f..25e747c78d0 100644
--- a/app/workers/pages_domain_removal_cron_worker.rb
+++ b/app/workers/pages_domain_removal_cron_worker.rb
@@ -4,6 +4,8 @@ class PagesDomainRemovalCronWorker
include ApplicationWorker
include CronjobQueue
+ feature_category :pages
+
def perform
PagesDomain.for_removal.find_each do |domain|
domain.destroy!
diff --git a/app/workers/pages_domain_ssl_renewal_cron_worker.rb b/app/workers/pages_domain_ssl_renewal_cron_worker.rb
index e5dde07a648..f7a243e9b3b 100644
--- a/app/workers/pages_domain_ssl_renewal_cron_worker.rb
+++ b/app/workers/pages_domain_ssl_renewal_cron_worker.rb
@@ -4,6 +4,8 @@ class PagesDomainSslRenewalCronWorker
include ApplicationWorker
include CronjobQueue
+ feature_category :pages
+
def perform
return unless ::Gitlab::LetsEncrypt.enabled?
diff --git a/app/workers/pages_domain_ssl_renewal_worker.rb b/app/workers/pages_domain_ssl_renewal_worker.rb
index 87fd8059946..4db7d22ef7e 100644
--- a/app/workers/pages_domain_ssl_renewal_worker.rb
+++ b/app/workers/pages_domain_ssl_renewal_worker.rb
@@ -3,6 +3,8 @@
class PagesDomainSslRenewalWorker
include ApplicationWorker
+ feature_category :pages
+
def perform(domain_id)
domain = PagesDomain.find_by_id(domain_id)
return unless domain&.enabled?
diff --git a/app/workers/pages_domain_verification_cron_worker.rb b/app/workers/pages_domain_verification_cron_worker.rb
index 60703c83e9e..bb3a7fede9a 100644
--- a/app/workers/pages_domain_verification_cron_worker.rb
+++ b/app/workers/pages_domain_verification_cron_worker.rb
@@ -4,6 +4,8 @@ class PagesDomainVerificationCronWorker
include ApplicationWorker
include CronjobQueue
+ feature_category :pages
+
def perform
return if Gitlab::Database.read_only?
diff --git a/app/workers/pages_domain_verification_worker.rb b/app/workers/pages_domain_verification_worker.rb
index 7817b2ee5fc..b0888036498 100644
--- a/app/workers/pages_domain_verification_worker.rb
+++ b/app/workers/pages_domain_verification_worker.rb
@@ -3,6 +3,8 @@
class PagesDomainVerificationWorker
include ApplicationWorker
+ feature_category :pages
+
# rubocop: disable CodeReuse/ActiveRecord
def perform(domain_id)
return if Gitlab::Database.read_only?
diff --git a/app/workers/pages_worker.rb b/app/workers/pages_worker.rb
index fa0dfa2ff4b..484d9053849 100644
--- a/app/workers/pages_worker.rb
+++ b/app/workers/pages_worker.rb
@@ -4,6 +4,7 @@ class PagesWorker
include ApplicationWorker
sidekiq_options retry: 3
+ feature_category :pages
def perform(action, *arg)
send(action, *arg) # rubocop:disable GitlabSecurity/PublicSend
diff --git a/app/workers/pipeline_process_worker.rb b/app/workers/pipeline_process_worker.rb
index 96524d93f8d..96f3725dbbe 100644
--- a/app/workers/pipeline_process_worker.rb
+++ b/app/workers/pipeline_process_worker.rb
@@ -5,6 +5,7 @@ class PipelineProcessWorker
include PipelineQueue
queue_namespace :pipeline_processing
+ feature_category :continuous_integration
# rubocop: disable CodeReuse/ActiveRecord
def perform(pipeline_id, build_ids = nil)
diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb
index 9410fd1a786..f500ea08353 100644
--- a/app/workers/pipeline_schedule_worker.rb
+++ b/app/workers/pipeline_schedule_worker.rb
@@ -4,6 +4,8 @@ class PipelineScheduleWorker
include ApplicationWorker
include CronjobQueue
+ feature_category :continuous_integration
+
def perform
Ci::PipelineSchedule.runnable_schedules.preloaded.find_in_batches do |schedules|
schedules.each do |schedule|
diff --git a/app/workers/plugin_worker.rb b/app/workers/plugin_worker.rb
index c293e28be4a..e708031abdf 100644
--- a/app/workers/plugin_worker.rb
+++ b/app/workers/plugin_worker.rb
@@ -4,6 +4,7 @@ class PluginWorker
include ApplicationWorker
sidekiq_options retry: false
+ feature_category :integrations
def perform(file_name, data)
success, message = Gitlab::Plugin.execute(file_name, data)
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index 843ba3e980e..a3bc7e5b9c9 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -3,7 +3,7 @@
class PostReceive
include ApplicationWorker
- PIPELINE_PROCESS_LIMIT = 4
+ feature_category :source_code_management
def perform(gl_repository, identifier, changes, push_options = {})
project, repo_type = Gitlab::GlRepository.parse(gl_repository)
@@ -37,53 +37,31 @@ class PostReceive
end
def process_project_changes(post_received)
- changes = []
- refs = Set.new
user = identify_user(post_received)
+
return false unless user
+ project = post_received.project
+ push_options = post_received.push_options
+ changes = post_received.changes
+
# We only need to expire certain caches once per push
- expire_caches(post_received)
-
- post_received.enum_for(:changes_refs).with_index do |(oldrev, newrev, ref), index|
- service_klass =
- if Gitlab::Git.tag_ref?(ref)
- Git::TagPushService
- elsif Gitlab::Git.branch_ref?(ref)
- Git::BranchPushService
- end
-
- if service_klass
- service_klass.new(
- post_received.project,
- user,
- oldrev: oldrev,
- newrev: newrev,
- ref: ref,
- push_options: post_received.push_options,
- create_pipelines: index < PIPELINE_PROCESS_LIMIT || Feature.enabled?(:git_push_create_all_pipelines, post_received.project)
- ).execute
- end
-
- changes << Gitlab::DataBuilder::Repository.single_change(oldrev, newrev, ref)
- refs << ref
- end
+ expire_caches(post_received, post_received.project.repository)
+ enqueue_repository_cache_update(post_received)
- after_project_changes_hooks(post_received, user, refs.to_a, changes)
+ process_ref_changes(project, user, push_options: push_options, changes: changes)
+ update_remote_mirrors(post_received)
+ after_project_changes_hooks(project, user, changes.refs, changes.repository_data)
end
- # Expire the project, branch, and tag cache once per push. Schedule an
- # update for the repository size and commit count if necessary.
- def expire_caches(post_received)
- project = post_received.project
-
- project.repository.expire_status_cache if project.empty_repo?
- project.repository.expire_branches_cache if post_received.includes_branches?
- project.repository.expire_caches_for_tags if post_received.includes_tags?
-
- enqueue_repository_cache_update(post_received)
+ # Expire the repository status, branch, and tag cache once per push.
+ def expire_caches(post_received, repository)
+ repository.expire_status_cache if repository.empty?
+ repository.expire_branches_cache if post_received.includes_branches?
+ repository.expire_caches_for_tags if post_received.includes_tags?
end
+ # Schedule an update for the repository size and commit count if necessary.
def enqueue_repository_cache_update(post_received)
stats_to_invalidate = [:repository_size]
stats_to_invalidate << :commit_count if post_received.includes_default_branch?
@@ -96,9 +74,25 @@ class PostReceive
)
end
- def after_project_changes_hooks(post_received, user, refs, changes)
- hook_data = Gitlab::DataBuilder::Repository.update(post_received.project, user, changes, refs)
- SystemHooksService.new.execute_hooks(hook_data, :repository_update_hooks)
+ def process_ref_changes(project, user, params = {})
+ return unless params[:changes].any?
+
+ Git::ProcessRefChangesService.new(project, user, params).execute
+ end
+
+ def update_remote_mirrors(post_received)
+ return unless post_received.includes_branches? || post_received.includes_tags?
+
+ project = post_received.project
+ return unless project.has_remote_mirror?
+
+ project.mark_stuck_remote_mirrors_as_failed!
+ project.update_remote_mirrors
+ end
+
+ def after_project_changes_hooks(project, user, refs, changes)
+ repository_update_hook_data = Gitlab::DataBuilder::Repository.update(project, user, changes, refs)
+ SystemHooksService.new.execute_hooks(repository_update_hook_data, :repository_update_hooks)
Gitlab::UsageDataCounters::SourceCodeCounter.count(:pushes)
end
@@ -110,7 +104,10 @@ class PostReceive
user = identify_user(post_received)
return false unless user
- ::Git::WikiPushService.new(post_received.project, user, changes: post_received.enum_for(:changes_refs)).execute
+ # We only need to expire certain caches once per push
+ expire_caches(post_received, post_received.project.wiki.repository)
+
+ ::Git::WikiPushService.new(post_received.project, user, changes: post_received.changes).execute
end
def log(message)
diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb
index f6ebe4ab006..1e4561fc6ea 100644
--- a/app/workers/process_commit_worker.rb
+++ b/app/workers/process_commit_worker.rb
@@ -10,6 +10,8 @@
class ProcessCommitWorker
include ApplicationWorker
+ feature_category :source_code_management
+
# project_id - The ID of the project this commit belongs to.
# user_id - The ID of the user that pushed the commit.
# commit_hash - Hash containing commit details to use for constructing a
diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb
index e3f1f61991c..57a01c0dd8e 100644
--- a/app/workers/project_cache_worker.rb
+++ b/app/workers/project_cache_worker.rb
@@ -5,6 +5,8 @@ class ProjectCacheWorker
include ApplicationWorker
LEASE_TIMEOUT = 15.minutes.to_i
+ feature_category :source_code_management
+
# project_id - The ID of the project for which to flush the cache.
# files - An Array containing extra types of files to refresh such as
# `:readme` to flush the README and `:changelog` to flush the
diff --git a/app/workers/project_daily_statistics_worker.rb b/app/workers/project_daily_statistics_worker.rb
index 101f5c28459..19c2fd67763 100644
--- a/app/workers/project_daily_statistics_worker.rb
+++ b/app/workers/project_daily_statistics_worker.rb
@@ -3,6 +3,8 @@
class ProjectDailyStatisticsWorker
include ApplicationWorker
+ feature_category :source_code_management
+
def perform(project_id)
project = Project.find_by_id(project_id)
diff --git a/app/workers/project_destroy_worker.rb b/app/workers/project_destroy_worker.rb
index 4447e867240..1d20837faa2 100644
--- a/app/workers/project_destroy_worker.rb
+++ b/app/workers/project_destroy_worker.rb
@@ -4,6 +4,8 @@ class ProjectDestroyWorker
include ApplicationWorker
include ExceptionBacktrace
+ feature_category :source_code_management
+
def perform(project_id, user_id, params)
project = Project.find(project_id)
user = User.find(user_id)
diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb
index ed9da39c7c3..bbcf3b72718 100644
--- a/app/workers/project_export_worker.rb
+++ b/app/workers/project_export_worker.rb
@@ -5,6 +5,7 @@ class ProjectExportWorker
include ExceptionBacktrace
sidekiq_options retry: 3
+ feature_category :source_code_management
def perform(current_user_id, project_id, after_export_strategy = {}, params = {})
current_user = User.find(current_user_id)
diff --git a/app/workers/project_service_worker.rb b/app/workers/project_service_worker.rb
index 25567cec08b..8041404fc71 100644
--- a/app/workers/project_service_worker.rb
+++ b/app/workers/project_service_worker.rb
@@ -4,6 +4,7 @@ class ProjectServiceWorker
include ApplicationWorker
sidekiq_options dead: false
+ feature_category :integrations
def perform(hook_id, data)
data = data.with_indifferent_access
diff --git a/app/workers/propagate_service_template_worker.rb b/app/workers/propagate_service_template_worker.rb
index 3ccd7615697..73a2b453207 100644
--- a/app/workers/propagate_service_template_worker.rb
+++ b/app/workers/propagate_service_template_worker.rb
@@ -4,6 +4,8 @@
class PropagateServiceTemplateWorker
include ApplicationWorker
+ feature_category :source_code_management
+
LEASE_TIMEOUT = 4.hours.to_i
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/workers/prune_old_events_worker.rb b/app/workers/prune_old_events_worker.rb
index dc4b7670131..f421e8dbf59 100644
--- a/app/workers/prune_old_events_worker.rb
+++ b/app/workers/prune_old_events_worker.rb
@@ -4,15 +4,17 @@ class PruneOldEventsWorker
include ApplicationWorker
include CronjobQueue
+ feature_category_not_owned!
+
# rubocop: disable CodeReuse/ActiveRecord
def perform
- # Contribution calendar shows maximum 12 months of events, we retain 2 years for data integrity.
+ # Contribution calendar shows maximum 12 months of events, we retain 3 years for data integrity.
# Double nested query is used because MySQL doesn't allow DELETE subqueries on the same table.
Event.unscoped.where(
'(id IN (SELECT id FROM (?) ids_to_remove))',
Event.unscoped.where(
'created_at < ?',
- (2.years + 1.day).ago)
+ (3.years + 1.day).ago)
.select(:id)
.limit(10_000))
.delete_all
diff --git a/app/workers/prune_web_hook_logs_worker.rb b/app/workers/prune_web_hook_logs_worker.rb
index 38054069f4e..8e48b45fc34 100644
--- a/app/workers/prune_web_hook_logs_worker.rb
+++ b/app/workers/prune_web_hook_logs_worker.rb
@@ -6,6 +6,8 @@ class PruneWebHookLogsWorker
include ApplicationWorker
include CronjobQueue
+ feature_category :integrations
+
# The maximum number of rows to remove in a single job.
DELETE_LIMIT = 50_000
diff --git a/app/workers/reactive_caching_worker.rb b/app/workers/reactive_caching_worker.rb
index b30864db802..af4a3def062 100644
--- a/app/workers/reactive_caching_worker.rb
+++ b/app/workers/reactive_caching_worker.rb
@@ -3,6 +3,8 @@
class ReactiveCachingWorker
include ApplicationWorker
+ feature_category_not_owned!
+
def perform(class_name, id, *args)
klass = begin
class_name.constantize
diff --git a/app/workers/rebase_worker.rb b/app/workers/rebase_worker.rb
index 8d06adcd993..7343226fdcd 100644
--- a/app/workers/rebase_worker.rb
+++ b/app/workers/rebase_worker.rb
@@ -5,6 +5,8 @@
class RebaseWorker
include ApplicationWorker
+ feature_category :source_code_management
+
def perform(merge_request_id, current_user_id)
current_user = User.find(current_user_id)
merge_request = MergeRequest.find(merge_request_id)
diff --git a/app/workers/remote_mirror_notification_worker.rb b/app/workers/remote_mirror_notification_worker.rb
index 368abfeda99..8bc19230caf 100644
--- a/app/workers/remote_mirror_notification_worker.rb
+++ b/app/workers/remote_mirror_notification_worker.rb
@@ -3,6 +3,8 @@
class RemoteMirrorNotificationWorker
include ApplicationWorker
+ feature_category :source_code_management
+
def perform(remote_mirror_id)
remote_mirror = RemoteMirror.find_by_id(remote_mirror_id)
diff --git a/app/workers/remove_expired_group_links_worker.rb b/app/workers/remove_expired_group_links_worker.rb
index 25128caf72f..147b412b772 100644
--- a/app/workers/remove_expired_group_links_worker.rb
+++ b/app/workers/remove_expired_group_links_worker.rb
@@ -4,6 +4,8 @@ class RemoveExpiredGroupLinksWorker
include ApplicationWorker
include CronjobQueue
+ feature_category :authentication_and_authorization
+
def perform
ProjectGroupLink.expired.destroy_all # rubocop: disable DestroyAll
end
diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb
index 3497a1f9280..75f06fd9f6b 100644
--- a/app/workers/remove_expired_members_worker.rb
+++ b/app/workers/remove_expired_members_worker.rb
@@ -4,6 +4,8 @@ class RemoveExpiredMembersWorker
include ApplicationWorker
include CronjobQueue
+ feature_category :authentication_and_authorization
+
def perform
Member.expired.find_each do |member|
Members::DestroyService.new.execute(member, skip_authorization: true)
diff --git a/app/workers/remove_unreferenced_lfs_objects_worker.rb b/app/workers/remove_unreferenced_lfs_objects_worker.rb
index 95e7a9f537f..7f2c23f4685 100644
--- a/app/workers/remove_unreferenced_lfs_objects_worker.rb
+++ b/app/workers/remove_unreferenced_lfs_objects_worker.rb
@@ -4,6 +4,8 @@ class RemoveUnreferencedLfsObjectsWorker
include ApplicationWorker
include CronjobQueue
+ feature_category :source_code_management
+
def perform
LfsObject.destroy_unreferenced
end
diff --git a/app/workers/repository_archive_cache_worker.rb b/app/workers/repository_archive_cache_worker.rb
index c1dff8ced90..ebc83c1b17a 100644
--- a/app/workers/repository_archive_cache_worker.rb
+++ b/app/workers/repository_archive_cache_worker.rb
@@ -4,6 +4,8 @@ class RepositoryArchiveCacheWorker
include ApplicationWorker
include CronjobQueue
+ feature_category :source_code_management
+
def perform
RepositoryArchiveCleanUpService.new.execute
end
diff --git a/app/workers/repository_check/dispatch_worker.rb b/app/workers/repository_check/dispatch_worker.rb
index 0a7d9a14c6a..d2bd5f9b967 100644
--- a/app/workers/repository_check/dispatch_worker.rb
+++ b/app/workers/repository_check/dispatch_worker.rb
@@ -7,6 +7,8 @@ module RepositoryCheck
include ::EachShardWorker
include ExclusiveLeaseGuard
+ feature_category :source_code_management
+
LEASE_TIMEOUT = 1.hour
def perform
diff --git a/app/workers/repository_cleanup_worker.rb b/app/workers/repository_cleanup_worker.rb
index aa26c173a72..dd2cbd42d1f 100644
--- a/app/workers/repository_cleanup_worker.rb
+++ b/app/workers/repository_cleanup_worker.rb
@@ -4,6 +4,7 @@ class RepositoryCleanupWorker
include ApplicationWorker
sidekiq_options retry: 3
+ feature_category :source_code_management
sidekiq_retries_exhausted do |msg, err|
next if err.is_a?(ActiveRecord::RecordNotFound)
diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb
index 35e9c58eb13..0adf745c7ac 100644
--- a/app/workers/repository_fork_worker.rb
+++ b/app/workers/repository_fork_worker.rb
@@ -6,6 +6,8 @@ class RepositoryForkWorker
include ProjectStartImport
include ProjectImportOptions
+ feature_category :source_code_management
+
def perform(*args)
target_project_id = args.shift
target_project = Project.find(target_project_id)
diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb
index 5be439ecbc5..bc2d0366fdd 100644
--- a/app/workers/repository_import_worker.rb
+++ b/app/workers/repository_import_worker.rb
@@ -6,6 +6,12 @@ class RepositoryImportWorker
include ProjectStartImport
include ProjectImportOptions
+ feature_category :importers
+
+ # technical debt: https://gitlab.com/gitlab-org/gitlab/issues/33991
+ sidekiq_options memory_killer_memory_growth_kb: ENV.fetch('MEMORY_KILLER_REPOSITORY_IMPORT_WORKER_MEMORY_GROWTH_KB', 50).to_i
+ sidekiq_options memory_killer_max_memory_growth_kb: ENV.fetch('MEMORY_KILLER_REPOSITORY_IMPORT_WORKER_MAX_MEMORY_GROWTH_KB', 300_000).to_i
+
def perform(project_id)
@project = Project.find(project_id)
diff --git a/app/workers/repository_remove_remote_worker.rb b/app/workers/repository_remove_remote_worker.rb
index a85e9fa9394..3e55ebc77ed 100644
--- a/app/workers/repository_remove_remote_worker.rb
+++ b/app/workers/repository_remove_remote_worker.rb
@@ -4,6 +4,8 @@ class RepositoryRemoveRemoteWorker
include ApplicationWorker
include ExclusiveLeaseGuard
+ feature_category :source_code_management
+
LEASE_TIMEOUT = 1.hour
attr_reader :project, :remote_name
diff --git a/app/workers/repository_update_remote_mirror_worker.rb b/app/workers/repository_update_remote_mirror_worker.rb
index d13c7641eb3..b4d96546fa4 100644
--- a/app/workers/repository_update_remote_mirror_worker.rb
+++ b/app/workers/repository_update_remote_mirror_worker.rb
@@ -7,6 +7,7 @@ class RepositoryUpdateRemoteMirrorWorker
include Gitlab::ExclusiveLeaseHelpers
sidekiq_options retry: 3, dead: false
+ feature_category :source_code_management
LOCK_WAIT_TIME = 30.seconds
MAX_TRIES = 3
diff --git a/app/workers/requests_profiles_worker.rb b/app/workers/requests_profiles_worker.rb
index ae022d43e29..6ab020afb10 100644
--- a/app/workers/requests_profiles_worker.rb
+++ b/app/workers/requests_profiles_worker.rb
@@ -4,6 +4,8 @@ class RequestsProfilesWorker
include ApplicationWorker
include CronjobQueue
+ feature_category :source_code_management
+
def perform
Gitlab::RequestProfiler.remove_all_profiles
end
diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb
index 659f8b80397..853f774875a 100644
--- a/app/workers/run_pipeline_schedule_worker.rb
+++ b/app/workers/run_pipeline_schedule_worker.rb
@@ -5,6 +5,7 @@ class RunPipelineScheduleWorker
include PipelineQueue
queue_namespace :pipeline_creation
+ feature_category :continuous_integration
# rubocop: disable CodeReuse/ActiveRecord
def perform(schedule_id, user_id)
diff --git a/app/workers/schedule_migrate_external_diffs_worker.rb b/app/workers/schedule_migrate_external_diffs_worker.rb
index 70910f7ca04..8abb5922b54 100644
--- a/app/workers/schedule_migrate_external_diffs_worker.rb
+++ b/app/workers/schedule_migrate_external_diffs_worker.rb
@@ -1,10 +1,12 @@
-# frozen_string_literal: false
+# frozen_string_literal: true
class ScheduleMigrateExternalDiffsWorker
include ApplicationWorker
include CronjobQueue
include Gitlab::ExclusiveLeaseHelpers
+ feature_category :source_code_management
+
def perform
in_lock(self.class.name.underscore, ttl: 2.hours, retries: 0) do
MergeRequests::MigrateExternalDiffsService.enqueue!
diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb
index 7e002d8822c..971edb1f14f 100644
--- a/app/workers/stuck_ci_jobs_worker.rb
+++ b/app/workers/stuck_ci_jobs_worker.rb
@@ -4,6 +4,8 @@ class StuckCiJobsWorker
include ApplicationWorker
include CronjobQueue
+ feature_category :continuous_integration
+
EXCLUSIVE_LEASE_KEY = 'stuck_ci_builds_worker_lease'
BUILD_RUNNING_OUTDATED_TIMEOUT = 1.hour
diff --git a/app/workers/stuck_import_jobs_worker.rb b/app/workers/stuck_import_jobs_worker.rb
index a9ff5b22b25..4993cd1220c 100644
--- a/app/workers/stuck_import_jobs_worker.rb
+++ b/app/workers/stuck_import_jobs_worker.rb
@@ -4,6 +4,8 @@ class StuckImportJobsWorker
include ApplicationWorker
include CronjobQueue
+ feature_category :importers
+
IMPORT_JOBS_EXPIRATION = 15.hours.to_i
def perform
diff --git a/app/workers/stuck_merge_jobs_worker.rb b/app/workers/stuck_merge_jobs_worker.rb
index e840ae47421..024863ab530 100644
--- a/app/workers/stuck_merge_jobs_worker.rb
+++ b/app/workers/stuck_merge_jobs_worker.rb
@@ -4,6 +4,8 @@ class StuckMergeJobsWorker
include ApplicationWorker
include CronjobQueue
+ feature_category :source_code_management
+
def self.logger
Rails.logger # rubocop:disable Gitlab/RailsLogger
end
@@ -31,7 +33,7 @@ class StuckMergeJobsWorker
def apply_current_state!(completed_jids, completed_ids)
merge_requests = MergeRequest.where(id: completed_ids)
- merge_requests.where.not(merge_commit_sha: nil).update_all(state: :merged)
+ merge_requests.where.not(merge_commit_sha: nil).update_all(state_id: MergeRequest.available_states[:merged])
merge_requests_to_reopen = merge_requests.where(merge_commit_sha: nil)
diff --git a/app/workers/system_hook_push_worker.rb b/app/workers/system_hook_push_worker.rb
index 15e369ebcfb..fc6237f359a 100644
--- a/app/workers/system_hook_push_worker.rb
+++ b/app/workers/system_hook_push_worker.rb
@@ -3,6 +3,8 @@
class SystemHookPushWorker
include ApplicationWorker
+ feature_category :source_code_management
+
def perform(push_data, hook_id)
SystemHooksService.new.execute_hooks(push_data, hook_id)
end
diff --git a/app/workers/trending_projects_worker.rb b/app/workers/trending_projects_worker.rb
index 55b599ba38f..4c8ee1ee425 100644
--- a/app/workers/trending_projects_worker.rb
+++ b/app/workers/trending_projects_worker.rb
@@ -4,6 +4,8 @@ class TrendingProjectsWorker
include ApplicationWorker
include CronjobQueue
+ feature_category :source_code_management
+
def perform
Rails.logger.info('Refreshing trending projects') # rubocop:disable Gitlab/RailsLogger
diff --git a/app/workers/update_external_pull_requests_worker.rb b/app/workers/update_external_pull_requests_worker.rb
index c5acfa82ada..8b0952528fa 100644
--- a/app/workers/update_external_pull_requests_worker.rb
+++ b/app/workers/update_external_pull_requests_worker.rb
@@ -3,6 +3,8 @@
class UpdateExternalPullRequestsWorker
include ApplicationWorker
+ feature_category :source_code_management
+
def perform(project_id, user_id, ref)
project = Project.find_by_id(project_id)
return unless project
diff --git a/app/workers/update_head_pipeline_for_merge_request_worker.rb b/app/workers/update_head_pipeline_for_merge_request_worker.rb
index 4ec2b9d8fbe..77859abfea4 100644
--- a/app/workers/update_head_pipeline_for_merge_request_worker.rb
+++ b/app/workers/update_head_pipeline_for_merge_request_worker.rb
@@ -5,6 +5,7 @@ class UpdateHeadPipelineForMergeRequestWorker
include PipelineQueue
queue_namespace :pipeline_processing
+ feature_category :continuous_integration
def perform(merge_request_id)
MergeRequest.find_by_id(merge_request_id).try do |merge_request|
diff --git a/app/workers/update_merge_requests_worker.rb b/app/workers/update_merge_requests_worker.rb
index 6c0e472e05a..8e1703cdd0b 100644
--- a/app/workers/update_merge_requests_worker.rb
+++ b/app/workers/update_merge_requests_worker.rb
@@ -3,6 +3,8 @@
class UpdateMergeRequestsWorker
include ApplicationWorker
+ feature_category :source_code_management
+
LOG_TIME_THRESHOLD = 90 # seconds
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/workers/update_project_statistics_worker.rb b/app/workers/update_project_statistics_worker.rb
index 9a29cc12707..e36cebf6f4f 100644
--- a/app/workers/update_project_statistics_worker.rb
+++ b/app/workers/update_project_statistics_worker.rb
@@ -1,10 +1,11 @@
-
# frozen_string_literal: true
# Worker for updating project statistics.
class UpdateProjectStatisticsWorker
include ApplicationWorker
+ 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
diff --git a/app/workers/upload_checksum_worker.rb b/app/workers/upload_checksum_worker.rb
index 834dcaa435d..d35367145b8 100644
--- a/app/workers/upload_checksum_worker.rb
+++ b/app/workers/upload_checksum_worker.rb
@@ -3,6 +3,8 @@
class UploadChecksumWorker
include ApplicationWorker
+ feature_category :geo_replication
+
def perform(upload_id)
upload = Upload.find(upload_id)
upload.calculate_checksum!
diff --git a/app/workers/web_hook_worker.rb b/app/workers/web_hook_worker.rb
index 09219a24a16..fd7ca93683e 100644
--- a/app/workers/web_hook_worker.rb
+++ b/app/workers/web_hook_worker.rb
@@ -3,6 +3,7 @@
class WebHookWorker
include ApplicationWorker
+ feature_category :integrations
sidekiq_options retry: 4, dead: false
def perform(hook_id, data, hook_name)