summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-02-20 12:52:10 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-02-20 12:52:10 +0000
commitdba864470fbcbb6bdd5b94eb510acdce62c962d8 (patch)
treee8ead0b84e7b814f5891d2c8cd3db2d6b635fb64 /app
parentb7d29500f28ff59c8898cdf889a40d3da908f162 (diff)
downloadgitlab-ce-dba864470fbcbb6bdd5b94eb510acdce62c962d8.tar.gz
Add latest changes from gitlab-org/gitlab@12-8-stable-ee
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue168
-rw-r--r--app/assets/javascripts/alerts_service_settings/index.js27
-rw-r--r--app/assets/javascripts/api.js35
-rw-r--r--app/assets/javascripts/behaviors/markdown/constants.js3
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/inline_html.js4
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/math.js3
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/audio.js54
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/image.js3
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js3
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/playable.js73
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/reference.js3
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js3
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js5
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/task_list.js3
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js3
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/video.js56
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js22
-rw-r--r--app/assets/javascripts/behaviors/requires_input.js6
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts.js23
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js47
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.js22
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue60
-rw-r--r--app/assets/javascripts/blob/components/blob_content.vue51
-rw-r--r--app/assets/javascripts/blob/components/blob_content_error.vue15
-rw-r--r--app/assets/javascripts/blob/components/blob_embeddable.vue41
-rw-r--r--app/assets/javascripts/blob/components/blob_header.vue82
-rw-r--r--app/assets/javascripts/blob/components/blob_header_default_actions.vue74
-rw-r--r--app/assets/javascripts/blob/components/blob_header_filepath.vue47
-rw-r--r--app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue70
-rw-r--r--app/assets/javascripts/blob/components/constants.js11
-rw-r--r--app/assets/javascripts/blob/file_template_mediator.js6
-rw-r--r--app/assets/javascripts/blob/notebook/index.js4
-rw-r--r--app/assets/javascripts/blob/pdf/index.js12
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue2
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.vue10
-rw-r--r--app/assets/javascripts/boards/components/issue_count.vue2
-rw-r--r--app/assets/javascripts/boards/mixins/sortable_default_options.js1
-rw-r--r--app/assets/javascripts/boards/models/issue.js1
-rw-r--r--app/assets/javascripts/boards/models/list.js67
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js72
-rw-r--r--app/assets/javascripts/broadcast_notification.js21
-rw-r--r--app/assets/javascripts/ci_variable_list/ajax_variable_list.js4
-rw-r--r--app/assets/javascripts/clusters/components/application_row.vue2
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue4
-rw-r--r--app/assets/javascripts/clusters/stores/clusters_store.js1
-rw-r--r--app/assets/javascripts/code_navigation/components/app.vue43
-rw-r--r--app/assets/javascripts/code_navigation/components/popover.vue76
-rw-r--r--app/assets/javascripts/code_navigation/index.js20
-rw-r--r--app/assets/javascripts/code_navigation/store/actions.js59
-rw-r--r--app/assets/javascripts/code_navigation/store/index.js10
-rw-r--r--app/assets/javascripts/code_navigation/store/mutation_types.js5
-rw-r--r--app/assets/javascripts/code_navigation/store/mutations.js23
-rw-r--r--app/assets/javascripts/code_navigation/store/state.js9
-rw-r--r--app/assets/javascripts/code_navigation/utils/index.js20
-rw-r--r--app/assets/javascripts/commons/jquery.js2
-rw-r--r--app/assets/javascripts/commons/polyfills.js22
-rw-r--r--app/assets/javascripts/contributors/components/contributors.vue36
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue4
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue2
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue15
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_submit_button.vue18
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/index.js10
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/store/getters.js2
-rw-r--r--app/assets/javascripts/cycle_analytics/components/banner.vue6
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js7
-rw-r--r--app/assets/javascripts/diff_notes/components/jump_to_discussion.js4
-rw-r--r--app/assets/javascripts/diff_notes/models/discussion.js6
-rw-r--r--app/assets/javascripts/diff_notes/stores/comments.js6
-rw-r--r--app/assets/javascripts/diffs/components/app.vue4
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue51
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue22
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_row.vue40
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_gutter_content.vue147
-rw-r--r--app/assets/javascripts/diffs/components/diff_stats.vue26
-rw-r--r--app/assets/javascripts/diffs/components/diff_table_cell.vue98
-rw-r--r--app/assets/javascripts/diffs/components/settings_dropdown.vue2
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue21
-rw-r--r--app/assets/javascripts/diffs/store/actions.js27
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js4
-rw-r--r--app/assets/javascripts/diffs/store/utils.js79
-rw-r--r--app/assets/javascripts/due_date_select.js1
-rw-r--r--app/assets/javascripts/editor/editor_lite.js68
-rw-r--r--app/assets/javascripts/editor/utils.js11
-rw-r--r--app/assets/javascripts/environments/components/container.vue2
-rw-r--r--app/assets/javascripts/environments/components/enable_review_app_button.vue107
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue24
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue5
-rw-r--r--app/assets/javascripts/environments/mixins/environments_mixin.js1
-rw-r--r--app/assets/javascripts/environments/stores/environments_store.js6
-rw-r--r--app/assets/javascripts/error_tracking/components/constants.js21
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details.vue190
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue87
-rw-r--r--app/assets/javascripts/error_tracking/components/stacktrace_entry.vue4
-rw-r--r--app/assets/javascripts/error_tracking/details.js26
-rw-r--r--app/assets/javascripts/error_tracking/list.js30
-rw-r--r--app/assets/javascripts/error_tracking/queries/details.query.graphql39
-rw-r--r--app/assets/javascripts/error_tracking/store/actions.js37
-rw-r--r--app/assets/javascripts/error_tracking/store/details/actions.js27
-rw-r--r--app/assets/javascripts/error_tracking/store/details/mutation_types.js2
-rw-r--r--app/assets/javascripts/error_tracking/store/details/mutations.js6
-rw-r--r--app/assets/javascripts/error_tracking/store/details/state.js3
-rw-r--r--app/assets/javascripts/error_tracking/store/list/actions.js4
-rw-r--r--app/assets/javascripts/error_tracking/store/list/mutation_types.js1
-rw-r--r--app/assets/javascripts/error_tracking/store/list/mutations.js3
-rw-r--r--app/assets/javascripts/error_tracking/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/error_tracking/store/mutations.js3
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/getters.js4
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/mutations.js9
-rw-r--r--app/assets/javascripts/error_tracking_settings/utils.js2
-rw-r--r--app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue17
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_operator.js4
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown.js2
-rw-r--r--app/assets/javascripts/flash.js22
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue4
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js61
-rw-r--r--app/assets/javascripts/gl_dropdown.js1391
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/author.fragment.graphql6
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/blobviewer.fragment.graphql7
-rw-r--r--app/assets/javascripts/graphql_shared/utils.js12
-rw-r--r--app/assets/javascripts/groups/components/group_folder.vue2
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue13
-rw-r--r--app/assets/javascripts/groups/store/groups_store.js2
-rw-r--r--app/assets/javascripts/helpers/avatar_helper.js6
-rw-r--r--app/assets/javascripts/helpers/diffs_helper.js10
-rw-r--r--app/assets/javascripts/ide/components/activity_bar.vue16
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/actions.vue10
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/form.vue21
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue34
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue9
-rw-r--r--app/assets/javascripts/ide/components/error_message.vue41
-rw-r--r--app/assets/javascripts/ide/components/file_row_extra.vue15
-rw-r--r--app/assets/javascripts/ide/components/ide_file_row.vue38
-rw-r--r--app/assets/javascripts/ide/components/ide_side_bar.vue8
-rw-r--r--app/assets/javascripts/ide/components/ide_status_bar.vue12
-rw-r--r--app/assets/javascripts/ide/components/ide_status_mr.vue28
-rw-r--r--app/assets/javascripts/ide/components/ide_tree_list.vue12
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail/description.vue2
-rw-r--r--app/assets/javascripts/ide/components/jobs/stage.vue4
-rw-r--r--app/assets/javascripts/ide/components/merge_requests/info.vue38
-rw-r--r--app/assets/javascripts/ide/components/nav_dropdown.vue10
-rw-r--r--app/assets/javascripts/ide/components/nav_dropdown_button.vue11
-rw-r--r--app/assets/javascripts/ide/components/nav_form.vue18
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/index.vue4
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/upload.vue29
-rw-r--r--app/assets/javascripts/ide/components/panes/right.vue7
-rw-r--r--app/assets/javascripts/ide/components/pipelines/list.vue6
-rw-r--r--app/assets/javascripts/ide/components/repo_commit_section.vue4
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue10
-rw-r--r--app/assets/javascripts/ide/constants.js13
-rw-r--r--app/assets/javascripts/ide/ide_router.js6
-rw-r--r--app/assets/javascripts/ide/ide_router_extension.js21
-rw-r--r--app/assets/javascripts/ide/index.js2
-rw-r--r--app/assets/javascripts/ide/lib/editor.js20
-rw-r--r--app/assets/javascripts/ide/lib/themes/dark.js268
-rw-r--r--app/assets/javascripts/ide/lib/themes/gl_theme.js15
-rw-r--r--app/assets/javascripts/ide/lib/themes/index.js15
-rw-r--r--app/assets/javascripts/ide/lib/themes/white.js12
-rw-r--r--app/assets/javascripts/ide/queries/getUserPermissions.query.graphql8
-rw-r--r--app/assets/javascripts/ide/services/gql.js8
-rw-r--r--app/assets/javascripts/ide/services/index.js26
-rw-r--r--app/assets/javascripts/ide/stores/actions.js20
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js13
-rw-r--r--app/assets/javascripts/ide/stores/actions/merge_request.js26
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js6
-rw-r--r--app/assets/javascripts/ide/stores/actions/tree.js2
-rw-r--r--app/assets/javascripts/ide/stores/getters.js24
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js12
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/getters.js6
-rw-r--r--app/assets/javascripts/ide/stores/modules/file_templates/getters.js4
-rw-r--r--app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js7
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/actions.js6
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js7
-rw-r--r--app/assets/javascripts/ide/stores/mutations/file.js3
-rw-r--r--app/assets/javascripts/ide/stores/state.js6
-rw-r--r--app/assets/javascripts/ide/stores/utils.js2
-rw-r--r--app/assets/javascripts/issuables_list/components/issuable.vue9
-rw-r--r--app/assets/javascripts/jobs/components/environments_block.vue196
-rw-r--r--app/assets/javascripts/jobs/components/erased_block.vue4
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue42
-rw-r--r--app/assets/javascripts/jobs/components/manual_variables_form.vue8
-rw-r--r--app/assets/javascripts/jobs/components/sidebar.vue7
-rw-r--r--app/assets/javascripts/jobs/components/stages_dropdown.vue4
-rw-r--r--app/assets/javascripts/jobs/index.js7
-rw-r--r--app/assets/javascripts/jobs/store/actions.js39
-rw-r--r--app/assets/javascripts/jobs/store/getters.js14
-rw-r--r--app/assets/javascripts/jobs/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/jobs/store/mutations.js9
-rw-r--r--app/assets/javascripts/jobs/store/state.js1
-rw-r--r--app/assets/javascripts/lib/graphql.js13
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js5
-rw-r--r--app/assets/javascripts/lib/utils/datetime_range.js320
-rw-r--r--app/assets/javascripts/lib/utils/http_status.js1
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js42
-rw-r--r--app/assets/javascripts/lib/utils/webpack.js2
-rw-r--r--app/assets/javascripts/main.js16
-rw-r--r--app/assets/javascripts/manual_ordering.js1
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_store.js2
-rw-r--r--app/assets/javascripts/merge_request.js2
-rw-r--r--app/assets/javascripts/merge_request_tabs.js18
-rw-r--r--app/assets/javascripts/mirrors/mirror_repos.js4
-rw-r--r--app/assets/javascripts/mirrors/ssh_mirror.js4
-rw-r--r--app/assets/javascripts/monitoring/components/charts/anomaly.vue6
-rw-r--r--app/assets/javascripts/monitoring/components/charts/column.vue22
-rw-r--r--app/assets/javascripts/monitoring/components/charts/heatmap.vue46
-rw-r--r--app/assets/javascripts/monitoring/components/charts/single_stat.vue19
-rw-r--r--app/assets/javascripts/monitoring/components/charts/stacked_column.vue103
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue145
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue291
-rw-r--r--app/assets/javascripts/monitoring/components/dashboards_dropdown.vue114
-rw-r--r--app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue180
-rw-r--r--app/assets/javascripts/monitoring/components/embed.vue27
-rw-r--r--app/assets/javascripts/monitoring/components/panel_type.vue59
-rw-r--r--app/assets/javascripts/monitoring/constants.js73
-rw-r--r--app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql10
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js59
-rw-r--r--app/assets/javascripts/monitoring/stores/getters.js13
-rw-r--r--app/assets/javascripts/monitoring/stores/mutation_types.js4
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js34
-rw-r--r--app/assets/javascripts/monitoring/stores/state.js18
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js42
-rw-r--r--app/assets/javascripts/monitoring/utils.js102
-rw-r--r--app/assets/javascripts/mr_notes/init_notes.js18
-rw-r--r--app/assets/javascripts/mr_popover/constants.js1
-rw-r--r--app/assets/javascripts/network/branch_graph.js10
-rw-r--r--app/assets/javascripts/notes.js28
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue4
-rw-r--r--app/assets/javascripts/notes/components/diff_discussion_header.vue4
-rw-r--r--app/assets/javascripts/notes/components/discussion_actions.vue2
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue18
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter_note.vue6
-rw-r--r--app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue6
-rw-r--r--app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue39
-rw-r--r--app/assets/javascripts/notes/components/note_attachment.vue16
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue7
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue18
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue2
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue2
-rw-r--r--app/assets/javascripts/notes/components/toggle_replies_widget.vue4
-rw-r--r--app/assets/javascripts/notes/constants.js1
-rw-r--r--app/assets/javascripts/notes/mixins/description_version_history.js2
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js29
-rw-r--r--app/assets/javascripts/notes/stores/actions.js52
-rw-r--r--app/assets/javascripts/notes/stores/collapse_utils.js8
-rw-r--r--app/assets/javascripts/notes/stores/getters.js5
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js4
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js9
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js26
-rw-r--r--app/assets/javascripts/notifications_form.js6
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/usage_ping_payload.js6
-rw-r--r--app/assets/javascripts/pages/admin/serverless/domains/index.js19
-rw-r--r--app/assets/javascripts/pages/groups/registry/repositories/index.js10
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/graphs/charts/index.js181
-rw-r--r--app/assets/javascripts/pages/projects/graphs/charts/series_data_mixin.js11
-rw-r--r--app/assets/javascripts/pages/projects/init_blob.js3
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/charts/index.js84
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/init_pipelines.js12
-rw-r--r--app/assets/javascripts/pages/projects/registry/repositories/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/releases/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/releases/index/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/services/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/wikis/wikis.js2
-rw-r--r--app/assets/javascripts/pages/registrations/welcome/index.js7
-rw-r--r--app/assets/javascripts/pages/users/activity_calendar.js2
-rw-r--r--app/assets/javascripts/pages/users/user_tabs.js11
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue3
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue112
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_url.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_table_row.vue13
-rw-r--r--app/assets/javascripts/pipelines/mixins/graph_component_mixin.js4
-rw-r--r--app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js2
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js36
-rw-r--r--app/assets/javascripts/pipelines/stores/pipeline_store.js3
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/app.vue145
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/pipelines_area_chart.vue46
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue30
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/constants.js13
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/index.js67
-rw-r--r--app/assets/javascripts/registry/explorer/components/group_empty_state.vue39
-rw-r--r--app/assets/javascripts/registry/explorer/components/project_empty_state.vue113
-rw-r--r--app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue59
-rw-r--r--app/assets/javascripts/registry/explorer/constants.js32
-rw-r--r--app/assets/javascripts/registry/explorer/index.js58
-rw-r--r--app/assets/javascripts/registry/explorer/pages/details.vue333
-rw-r--r--app/assets/javascripts/registry/explorer/pages/index.vue11
-rw-r--r--app/assets/javascripts/registry/explorer/pages/list.vue214
-rw-r--r--app/assets/javascripts/registry/explorer/router.js44
-rw-r--r--app/assets/javascripts/registry/explorer/stores/actions.js117
-rw-r--r--app/assets/javascripts/registry/explorer/stores/index.js (renamed from app/assets/javascripts/releases/detail/store/index.js)6
-rw-r--r--app/assets/javascripts/registry/explorer/stores/mutation_types.js7
-rw-r--r--app/assets/javascripts/registry/explorer/stores/mutations.js33
-rw-r--r--app/assets/javascripts/registry/explorer/stores/state.js8
-rw-r--r--app/assets/javascripts/registry/explorer/utils.js2
-rw-r--r--app/assets/javascripts/registry/list/index.js15
-rw-r--r--app/assets/javascripts/registry/settings/components/registry_settings_app.vue39
-rw-r--r--app/assets/javascripts/registry/settings/components/settings_form.vue194
-rw-r--r--app/assets/javascripts/registry/settings/registry_settings_bundle.js2
-rw-r--r--app/assets/javascripts/registry/settings/store/actions.js25
-rw-r--r--app/assets/javascripts/registry/settings/store/getters.js15
-rw-r--r--app/assets/javascripts/registry/settings/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/registry/settings/store/mutations.js7
-rw-r--r--app/assets/javascripts/registry/settings/store/state.js4
-rw-r--r--app/assets/javascripts/registry/settings/utils.js6
-rw-r--r--app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue197
-rw-r--r--app/assets/javascripts/registry/shared/constants.js (renamed from app/assets/javascripts/registry/settings/constants.js)0
-rw-r--r--app/assets/javascripts/registry/shared/utils.js19
-rw-r--r--app/assets/javascripts/releases/components/app_edit.vue (renamed from app/assets/javascripts/releases/detail/components/app.vue)12
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue (renamed from app/assets/javascripts/releases/list/components/app.vue)4
-rw-r--r--app/assets/javascripts/releases/components/evidence_block.vue (renamed from app/assets/javascripts/releases/list/components/evidence_block.vue)0
-rw-r--r--app/assets/javascripts/releases/components/release_block.vue (renamed from app/assets/javascripts/releases/list/components/release_block.vue)12
-rw-r--r--app/assets/javascripts/releases/components/release_block_assets.vue (renamed from app/assets/javascripts/releases/list/components/release_block_assets.vue)2
-rw-r--r--app/assets/javascripts/releases/components/release_block_author.vue (renamed from app/assets/javascripts/releases/list/components/release_block_author.vue)2
-rw-r--r--app/assets/javascripts/releases/components/release_block_footer.vue (renamed from app/assets/javascripts/releases/list/components/release_block_footer.vue)0
-rw-r--r--app/assets/javascripts/releases/components/release_block_header.vue (renamed from app/assets/javascripts/releases/list/components/release_block_header.vue)16
-rw-r--r--app/assets/javascripts/releases/components/release_block_metadata.vue (renamed from app/assets/javascripts/releases/list/components/release_block_metadata.vue)0
-rw-r--r--app/assets/javascripts/releases/components/release_block_milestone_info.vue (renamed from app/assets/javascripts/releases/list/components/release_block_milestone_info.vue)0
-rw-r--r--app/assets/javascripts/releases/components/release_block_milestones.vue (renamed from app/assets/javascripts/releases/list/components/release_block_milestones.vue)0
-rw-r--r--app/assets/javascripts/releases/constants.js (renamed from app/assets/javascripts/releases/list/constants.js)0
-rw-r--r--app/assets/javascripts/releases/detail/index.js19
-rw-r--r--app/assets/javascripts/releases/list/index.js24
-rw-r--r--app/assets/javascripts/releases/list/store/index.js14
-rw-r--r--app/assets/javascripts/releases/mount_edit.js17
-rw-r--r--app/assets/javascripts/releases/mount_index.js21
-rw-r--r--app/assets/javascripts/releases/stores/index.js6
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/actions.js (renamed from app/assets/javascripts/releases/detail/store/actions.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/index.js10
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/mutation_types.js (renamed from app/assets/javascripts/releases/detail/store/mutation_types.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/mutations.js (renamed from app/assets/javascripts/releases/detail/store/mutations.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/state.js (renamed from app/assets/javascripts/releases/detail/store/state.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/list/actions.js (renamed from app/assets/javascripts/releases/list/store/actions.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/list/index.js10
-rw-r--r--app/assets/javascripts/releases/stores/modules/list/mutation_types.js (renamed from app/assets/javascripts/releases/list/store/mutation_types.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/list/mutations.js (renamed from app/assets/javascripts/releases/list/store/mutations.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/list/state.js (renamed from app/assets/javascripts/releases/list/store/state.js)0
-rw-r--r--app/assets/javascripts/reports/components/grouped_test_reports_app.vue20
-rw-r--r--app/assets/javascripts/reports/components/modal.vue4
-rw-r--r--app/assets/javascripts/reports/constants.js2
-rw-r--r--app/assets/javascripts/reports/store/mutations.js2
-rw-r--r--app/assets/javascripts/reports/store/state.js7
-rw-r--r--app/assets/javascripts/reports/store/utils.js9
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue23
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue28
-rw-r--r--app/assets/javascripts/repository/components/table/index.vue7
-rw-r--r--app/assets/javascripts/repository/components/table/parent_row.vue2
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue13
-rw-r--r--app/assets/javascripts/repository/components/tree_content.vue10
-rw-r--r--app/assets/javascripts/repository/graphql.js2
-rw-r--r--app/assets/javascripts/repository/index.js22
-rw-r--r--app/assets/javascripts/repository/log_tree.js4
-rw-r--r--app/assets/javascripts/repository/mixins/preload.js4
-rw-r--r--app/assets/javascripts/repository/queries/pathLastCommit.query.graphql1
-rw-r--r--app/assets/javascripts/repository/router.js4
-rw-r--r--app/assets/javascripts/repository/utils/dom.js4
-rw-r--r--app/assets/javascripts/repository/utils/title.js14
-rw-r--r--app/assets/javascripts/self_monitor/components/self_monitor_form.vue2
-rw-r--r--app/assets/javascripts/self_monitor/index.js3
-rw-r--r--app/assets/javascripts/self_monitor/store/actions.js3
-rw-r--r--app/assets/javascripts/self_monitor/store/state.js4
-rw-r--r--app/assets/javascripts/serverless/components/environment_row.vue2
-rw-r--r--app/assets/javascripts/serverless/components/function_details.vue4
-rw-r--r--app/assets/javascripts/serverless/components/function_row.vue6
-rw-r--r--app/assets/javascripts/serverless/components/functions.vue2
-rw-r--r--app/assets/javascripts/serverless/components/url.vue12
-rw-r--r--app/assets/javascripts/single_file_diff.js2
-rw-r--r--app/assets/javascripts/snippet/collapsible_input.js45
-rw-r--r--app/assets/javascripts/snippet/snippet_bundle.js3
-rw-r--r--app/assets/javascripts/snippets/components/app.vue3
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_view.vue97
-rw-r--r--app/assets/javascripts/snippets/components/snippet_header.vue4
-rw-r--r--app/assets/javascripts/snippets/components/snippet_title.vue2
-rw-r--r--app/assets/javascripts/snippets/constants.js3
-rw-r--r--app/assets/javascripts/snippets/fragments/author.fragment.graphql8
-rw-r--r--app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql13
-rw-r--r--app/assets/javascripts/snippets/queries/snippet.blob.query.graphql24
-rw-r--r--app/assets/javascripts/snippets/queries/snippet.query.graphql6
-rw-r--r--app/assets/javascripts/test_utils/index.js2
-rw-r--r--app/assets/javascripts/user_popovers.js172
-rw-r--r--app/assets/javascripts/users_select.js2
-rw-r--r--app/assets/javascripts/vue_alerts.js22
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue4
-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_suggest_pipeline.vue45
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue32
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/bar_chart.vue351
-rw-r--r--app/assets/javascripts/vue_shared/components/bar_chart_constants.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/constants.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/index.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js8
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue68
-rw-r--r--app/assets/javascripts/vue_shared/components/changed_file_icon.vue18
-rw-r--r--app/assets/javascripts/vue_shared/components/clipboard_button.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue218
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue (renamed from app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue)14
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js84
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/dismissible_alert.vue32
-rw-r--r--app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue98
-rw-r--r--app/assets/javascripts/vue_shared/components/file_tree.vue47
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/identicon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_warning.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/modal_copy_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/pagination/graphql_pagination.vue47
-rw-r--r--app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue20
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue11
-rw-r--r--app/assets/javascripts/webpack.js2
-rw-r--r--app/assets/javascripts/zen_mode.js6
-rw-r--r--app/assets/stylesheets/application.scss2
-rw-r--r--app/assets/stylesheets/components/date_time_picker.scss5
-rw-r--r--app/assets/stylesheets/framework.scss1
-rw-r--r--app/assets/stylesheets/framework/blocks.scss5
-rw-r--r--app/assets/stylesheets/framework/carousel.scss202
-rw-r--r--app/assets/stylesheets/framework/common.scss1
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss21
-rw-r--r--app/assets/stylesheets/framework/files.scss12
-rw-r--r--app/assets/stylesheets/framework/filters.scss6
-rw-r--r--app/assets/stylesheets/framework/highlight.scss15
-rw-r--r--app/assets/stylesheets/framework/modal.scss1
-rw-r--r--app/assets/stylesheets/framework/selects.scss3
-rw-r--r--app/assets/stylesheets/framework/snippets.scss9
-rw-r--r--app/assets/stylesheets/framework/spinner.scss6
-rw-r--r--app/assets/stylesheets/framework/variables.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss77
-rw-r--r--app/assets/stylesheets/pages/boards.scss4
-rw-r--r--app/assets/stylesheets/pages/commits.scss10
-rw-r--r--app/assets/stylesheets/pages/cycle_analytics.scss10
-rw-r--r--app/assets/stylesheets/pages/diff.scss20
-rw-r--r--app/assets/stylesheets/pages/experimental_separate_sign_up.scss56
-rw-r--r--app/assets/stylesheets/pages/groups.scss11
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss25
-rw-r--r--app/assets/stylesheets/pages/notes.scss13
-rw-r--r--app/assets/stylesheets/pages/pages.scss11
-rw-r--r--app/assets/stylesheets/pages/projects.scss8
-rw-r--r--app/assets/stylesheets/pages/prometheus.scss20
-rw-r--r--app/assets/stylesheets/pages/tree.scss8
-rw-r--r--app/assets/stylesheets/pages/trials.scss15
-rw-r--r--app/assets/stylesheets/utilities.scss12
-rw-r--r--app/controllers/admin/application_settings_controller.rb74
-rw-r--r--app/controllers/admin/applications_controller.rb4
-rw-r--r--app/controllers/admin/groups_controller.rb7
-rw-r--r--app/controllers/admin/logs_controller.rb2
-rw-r--r--app/controllers/admin/projects_controller.rb2
-rw-r--r--app/controllers/admin/runners_controller.rb5
-rw-r--r--app/controllers/admin/serverless/domains_controller.rb62
-rw-r--r--app/controllers/admin/services_controller.rb2
-rw-r--r--app/controllers/admin/spam_logs_controller.rb2
-rw-r--r--app/controllers/admin/users_controller.rb4
-rw-r--r--app/controllers/application_controller.rb15
-rw-r--r--app/controllers/boards/issues_controller.rb15
-rw-r--r--app/controllers/clusters/clusters_controller.rb3
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor.rb21
-rw-r--r--app/controllers/concerns/confirm_email_warning.rb2
-rw-r--r--app/controllers/concerns/cycle_analytics_params.rb4
-rw-r--r--app/controllers/concerns/invisible_captcha.rb4
-rw-r--r--app/controllers/concerns/lfs_request.rb4
-rw-r--r--app/controllers/concerns/membership_actions.rb4
-rw-r--r--app/controllers/concerns/metrics_dashboard.rb12
-rw-r--r--app/controllers/concerns/page_limiter.rb68
-rw-r--r--app/controllers/concerns/send_file_upload.rb16
-rw-r--r--app/controllers/confirmations_controller.rb2
-rw-r--r--app/controllers/dashboard/projects_controller.rb7
-rw-r--r--app/controllers/dashboard/snippets_controller.rb4
-rw-r--r--app/controllers/explore/projects_controller.rb33
-rw-r--r--app/controllers/groups/application_controller.rb8
-rw-r--r--app/controllers/groups/boards_controller.rb5
-rw-r--r--app/controllers/groups/milestones_controller.rb13
-rw-r--r--app/controllers/groups/registry/repositories_controller.rb19
-rw-r--r--app/controllers/ide_controller.rb4
-rw-r--r--app/controllers/import/base_controller.rb20
-rw-r--r--app/controllers/import/bitbucket_server_controller.rb2
-rw-r--r--app/controllers/import/manifest_controller.rb2
-rw-r--r--app/controllers/oauth/applications_controller.rb8
-rw-r--r--app/controllers/oauth/token_info_controller.rb19
-rw-r--r--app/controllers/profiles/notifications_controller.rb3
-rw-r--r--app/controllers/profiles/preferences_controller.rb1
-rw-r--r--app/controllers/projects/alerting/notifications_controller.rb47
-rw-r--r--app/controllers/projects/blob_controller.rb4
-rw-r--r--app/controllers/projects/environments_controller.rb2
-rw-r--r--app/controllers/projects/error_tracking_controller.rb10
-rw-r--r--app/controllers/projects/git_http_client_controller.rb122
-rw-r--r--app/controllers/projects/git_http_controller.rb117
-rw-r--r--app/controllers/projects/issues_controller.rb3
-rw-r--r--app/controllers/projects/lfs_api_controller.rb140
-rw-r--r--app/controllers/projects/lfs_locks_api_controller.rb76
-rw-r--r--app/controllers/projects/lfs_storage_controller.rb89
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb11
-rw-r--r--app/controllers/projects/merge_requests_controller.rb15
-rw-r--r--app/controllers/projects/pipeline_schedules_controller.rb2
-rw-r--r--app/controllers/projects/pipelines_controller.rb10
-rw-r--r--app/controllers/projects/project_members_controller.rb23
-rw-r--r--app/controllers/projects/registry/repositories_controller.rb23
-rw-r--r--app/controllers/projects/registry/tags_controller.rb7
-rw-r--r--app/controllers/projects/releases_controller.rb17
-rw-r--r--app/controllers/projects/repositories_controller.rb24
-rw-r--r--app/controllers/projects/serverless/functions_controller.rb15
-rw-r--r--app/controllers/projects/services_controller.rb14
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb2
-rw-r--r--app/controllers/projects/settings/operations_controller.rb29
-rw-r--r--app/controllers/projects/settings/repository_controller.rb2
-rw-r--r--app/controllers/projects/snippets_controller.rb28
-rw-r--r--app/controllers/projects/tree_controller.rb2
-rw-r--r--app/controllers/projects_controller.rb2
-rw-r--r--app/controllers/registrations_controller.rb27
-rw-r--r--app/controllers/repositories/application_controller.rb7
-rw-r--r--app/controllers/repositories/git_http_client_controller.rb125
-rw-r--r--app/controllers/repositories/git_http_controller.rb119
-rw-r--r--app/controllers/repositories/lfs_api_controller.rb142
-rw-r--r--app/controllers/repositories/lfs_locks_api_controller.rb78
-rw-r--r--app/controllers/repositories/lfs_storage_controller.rb92
-rw-r--r--app/controllers/root_controller.rb2
-rw-r--r--app/controllers/sent_notifications_controller.rb14
-rw-r--r--app/controllers/snippets/notes_controller.rb2
-rw-r--r--app/controllers/snippets_controller.rb8
-rw-r--r--app/controllers/uploads_controller.rb2
-rw-r--r--app/controllers/user_callouts_controller.rb5
-rw-r--r--app/finders/concerns/finder_with_cross_project_access.rb2
-rw-r--r--app/finders/concerns/time_frame_filter.rb14
-rw-r--r--app/finders/context_commits_finder.rb62
-rw-r--r--app/finders/contributed_projects_finder.rb4
-rw-r--r--app/finders/events_finder.rb9
-rw-r--r--app/finders/issuable_finder.rb25
-rw-r--r--app/finders/keys_finder.rb15
-rw-r--r--app/finders/members_finder.rb37
-rw-r--r--app/finders/milestones_finder.rb2
-rw-r--r--app/finders/personal_projects_finder.rb4
-rw-r--r--app/finders/pipelines_finder.rb2
-rw-r--r--app/finders/projects/prometheus/alerts_finder.rb71
-rw-r--r--app/finders/projects/serverless/functions_finder.rb62
-rw-r--r--app/finders/projects_finder.rb4
-rw-r--r--app/finders/protected_branches_finder.rb35
-rw-r--r--app/graphql/mutations/issues/update.rb25
-rw-r--r--app/graphql/mutations/notes/update.rb38
-rw-r--r--app/graphql/mutations/notes/update/base.rb48
-rw-r--r--app/graphql/mutations/notes/update/image_diff_note.rb60
-rw-r--r--app/graphql/mutations/notes/update/note.rb22
-rw-r--r--app/graphql/mutations/snippets/create.rb4
-rw-r--r--app/graphql/mutations/todos/restore_many.rb75
-rw-r--r--app/graphql/resolvers/base_resolver.rb4
-rw-r--r--app/graphql/resolvers/boards_resolver.rb18
-rw-r--r--app/graphql/resolvers/concerns/time_frame_arguments.rb30
-rw-r--r--app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb9
-rw-r--r--app/graphql/resolvers/error_tracking/sentry_error_collection_resolver.rb21
-rw-r--r--app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb35
-rw-r--r--app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb27
-rw-r--r--app/graphql/resolvers/milestone_resolver.rb50
-rw-r--r--app/graphql/types/base_field.rb21
-rw-r--r--app/graphql/types/blob_viewers/type_enum.rb14
-rw-r--r--app/graphql/types/board_type.rb17
-rw-r--r--app/graphql/types/commit_type.rb9
-rw-r--r--app/graphql/types/error_tracking/sentry_detailed_error_type.rb71
-rw-r--r--app/graphql/types/error_tracking/sentry_error_collection_type.rb41
-rw-r--r--app/graphql/types/error_tracking/sentry_error_stack_trace_context_type.rb29
-rw-r--r--app/graphql/types/error_tracking/sentry_error_stack_trace_entry_type.rb48
-rw-r--r--app/graphql/types/error_tracking/sentry_error_stack_trace_type.rb22
-rw-r--r--app/graphql/types/error_tracking/sentry_error_tags_type.rb19
-rw-r--r--app/graphql/types/error_tracking/sentry_error_type.rb70
-rw-r--r--app/graphql/types/group_type.rb23
-rw-r--r--app/graphql/types/milestone_state_enum.rb8
-rw-r--r--app/graphql/types/milestone_type.rb17
-rw-r--r--app/graphql/types/mutation_type.rb13
-rw-r--r--app/graphql/types/notes/diff_position_type.rb4
-rw-r--r--app/graphql/types/notes/update_diff_image_position_input_type.rb29
-rw-r--r--app/graphql/types/permission_types/project.rb5
-rw-r--r--app/graphql/types/permission_types/user.rb2
-rw-r--r--app/graphql/types/project_type.rb6
-rw-r--r--app/graphql/types/query_type.rb2
-rw-r--r--app/graphql/types/snippet_type.rb8
-rw-r--r--app/graphql/types/snippets/blob_type.rb54
-rw-r--r--app/graphql/types/snippets/blob_viewer_type.rb41
-rw-r--r--app/helpers/analytics_navbar_helper.rb70
-rw-r--r--app/helpers/application_settings_helper.rb15
-rw-r--r--app/helpers/auth_helper.rb12
-rw-r--r--app/helpers/avatars_helper.rb7
-rw-r--r--app/helpers/blob_helper.rb6
-rw-r--r--app/helpers/broadcast_messages_helper.rb13
-rw-r--r--app/helpers/button_helper.rb2
-rw-r--r--app/helpers/clusters_helper.rb11
-rw-r--r--app/helpers/commits_helper.rb2
-rw-r--r--app/helpers/diff_helper.rb2
-rw-r--r--app/helpers/environments_helper.rb4
-rw-r--r--app/helpers/explore_helper.rb12
-rw-r--r--app/helpers/groups_helper.rb1
-rw-r--r--app/helpers/markup_helper.rb33
-rw-r--r--app/helpers/preferences_helper.rb4
-rw-r--r--app/helpers/projects/error_tracking_helper.rb2
-rw-r--r--app/helpers/projects_helper.rb25
-rw-r--r--app/helpers/search_helper.rb13
-rw-r--r--app/helpers/sidekiq_helper.rb2
-rw-r--r--app/helpers/sorting_helper.rb7
-rw-r--r--app/helpers/submodule_helper.rb17
-rw-r--r--app/helpers/system_note_helper.rb1
-rw-r--r--app/helpers/tab_helper.rb11
-rw-r--r--app/helpers/tree_helper.rb4
-rw-r--r--app/helpers/user_callouts_helper.rb9
-rw-r--r--app/mailers/abuse_report_mailer.rb2
-rw-r--r--app/mailers/application_mailer.rb (renamed from app/mailers/base_mailer.rb)2
-rw-r--r--app/mailers/email_rejection_mailer.rb2
-rw-r--r--app/mailers/emails/notes.rb16
-rw-r--r--app/mailers/emails/pipelines.rb8
-rw-r--r--app/mailers/notify.rb2
-rw-r--r--app/mailers/repository_check_mailer.rb2
-rw-r--r--app/models/ability.rb2
-rw-r--r--app/models/application_setting.rb6
-rw-r--r--app/models/audit_event.rb2
-rw-r--r--app/models/blob.rb21
-rw-r--r--app/models/board.rb5
-rw-r--r--app/models/ci/bridge.rb138
-rw-r--r--app/models/ci/build.rb16
-rw-r--r--app/models/ci/job_artifact.rb10
-rw-r--r--app/models/ci/pipeline.rb72
-rw-r--r--app/models/ci/pipeline_enums.rb11
-rw-r--r--app/models/ci/pipeline_schedule.rb2
-rw-r--r--app/models/ci/processable.rb48
-rw-r--r--app/models/ci/sources/pipeline.rb3
-rw-r--r--app/models/clusters/applications/elastic_stack.rb17
-rw-r--r--app/models/clusters/applications/ingress.rb5
-rw-r--r--app/models/clusters/applications/knative.rb4
-rw-r--r--app/models/clusters/applications/prometheus.rb2
-rw-r--r--app/models/clusters/applications/runner.rb2
-rw-r--r--app/models/clusters/cluster.rb7
-rw-r--r--app/models/clusters/platforms/kubernetes.rb20
-rw-r--r--app/models/commit.rb91
-rw-r--r--app/models/commit_collection.rb21
-rw-r--r--app/models/commit_range.rb4
-rw-r--r--app/models/commit_status.rb16
-rw-r--r--app/models/commit_status_enums.rb8
-rw-r--r--app/models/concerns/analytics/cycle_analytics/stage.rb8
-rw-r--r--app/models/concerns/atomic_internal_id.rb4
-rw-r--r--app/models/concerns/bulk_insert_safe.rb44
-rw-r--r--app/models/concerns/cached_commit.rb17
-rw-r--r--app/models/concerns/ci/pipeline_delegator.rb2
-rw-r--r--app/models/concerns/delete_with_limit.rb11
-rw-r--r--app/models/concerns/discussion_on_diff.rb2
-rw-r--r--app/models/concerns/has_ref.rb2
-rw-r--r--app/models/concerns/has_repository.rb106
-rw-r--r--app/models/concerns/issuable.rb30
-rw-r--r--app/models/concerns/loaded_in_group_list.rb2
-rw-r--r--app/models/concerns/mentionable.rb17
-rw-r--r--app/models/concerns/milestoneable.rb6
-rw-r--r--app/models/concerns/mirror_authentication.rb2
-rw-r--r--app/models/concerns/project_features_compatibility.rb4
-rw-r--r--app/models/concerns/prometheus_adapter.rb9
-rw-r--r--app/models/concerns/reactive_caching.rb21
-rw-r--r--app/models/concerns/referable.rb8
-rw-r--r--app/models/concerns/relative_positioning.rb3
-rw-r--r--app/models/concerns/resolvable_discussion.rb2
-rw-r--r--app/models/concerns/sortable.rb14
-rw-r--r--app/models/concerns/x509_serial_number_attribute.rb45
-rw-r--r--app/models/container_expiration_policy.rb2
-rw-r--r--app/models/container_repository.rb6
-rw-r--r--app/models/deploy_token.rb38
-rw-r--r--app/models/deployment.rb15
-rw-r--r--app/models/deployment_cluster.rb6
-rw-r--r--app/models/diff_note.rb2
-rw-r--r--app/models/environment.rb50
-rw-r--r--app/models/epic.rb2
-rw-r--r--app/models/error_tracking/project_error_tracking_setting.rb10
-rw-r--r--app/models/event.rb9
-rw-r--r--app/models/group.rb4
-rw-r--r--app/models/group_deploy_token.rb23
-rw-r--r--app/models/group_group_link.rb4
-rw-r--r--app/models/hooks/web_hook_log.rb6
-rw-r--r--app/models/incident_management/project_incident_management_setting.rb34
-rw-r--r--app/models/internal_id.rb2
-rw-r--r--app/models/issue.rb27
-rw-r--r--app/models/issue_assignee.rb2
-rw-r--r--app/models/issue_milestone.rb6
-rw-r--r--app/models/key.rb3
-rw-r--r--app/models/label.rb18
-rw-r--r--app/models/label_link.rb1
-rw-r--r--app/models/lfs_objects_project.rb2
-rw-r--r--app/models/member.rb2
-rw-r--r--app/models/merge_request.rb60
-rw-r--r--app/models/merge_request/pipelines.rb2
-rw-r--r--app/models/merge_request_assignee.rb2
-rw-r--r--app/models/merge_request_context_commit.rb35
-rw-r--r--app/models/merge_request_context_commit_diff_file.rb17
-rw-r--r--app/models/merge_request_diff.rb4
-rw-r--r--app/models/merge_request_diff_commit.rb18
-rw-r--r--app/models/merge_request_diff_file.rb1
-rw-r--r--app/models/merge_request_milestone.rb6
-rw-r--r--app/models/milestone.rb11
-rw-r--r--app/models/namespace.rb5
-rw-r--r--app/models/note.rb9
-rw-r--r--app/models/notification_setting.rb7
-rw-r--r--app/models/pages_domain.rb15
-rw-r--r--app/models/performance_monitoring/prometheus_dashboard.rb31
-rw-r--r--app/models/performance_monitoring/prometheus_metric.rb25
-rw-r--r--app/models/performance_monitoring/prometheus_panel.rb24
-rw-r--r--app/models/performance_monitoring/prometheus_panel_group.rb22
-rw-r--r--app/models/personal_snippet.rb4
-rw-r--r--app/models/pool_repository.rb4
-rw-r--r--app/models/project.rb175
-rw-r--r--app/models/project_ci_cd_setting.rb9
-rw-r--r--app/models/project_deploy_token.rb4
-rw-r--r--app/models/project_group_link.rb4
-rw-r--r--app/models/project_services/alerts_service.rb78
-rw-r--r--app/models/project_services/alerts_service_data.rb14
-rw-r--r--app/models/project_services/chat_message/base_message.rb2
-rw-r--r--app/models/project_services/chat_message/merge_message.rb2
-rw-r--r--app/models/project_services/chat_notification_service.rb2
-rw-r--r--app/models/project_services/emails_on_push_service.rb7
-rw-r--r--app/models/project_services/flowdock_service.rb2
-rw-r--r--app/models/project_services/hipchat_service.rb2
-rw-r--r--app/models/project_services/jira_service.rb20
-rw-r--r--app/models/project_services/pipelines_email_service.rb2
-rw-r--r--app/models/project_services/prometheus_service.rb2
-rw-r--r--app/models/project_services/youtrack_service.rb4
-rw-r--r--app/models/project_setting.rb11
-rw-r--r--app/models/project_snippet.rb4
-rw-r--r--app/models/project_wiki.rb2
-rw-r--r--app/models/prometheus_alert.rb81
-rw-r--r--app/models/prometheus_metric.rb3
-rw-r--r--app/models/prometheus_metric_enums.rb10
-rw-r--r--app/models/protected_branch.rb7
-rw-r--r--app/models/release.rb9
-rw-r--r--app/models/repository.rb151
-rw-r--r--app/models/sentry_issue.rb17
-rw-r--r--app/models/serverless/domain_cluster.rb16
-rw-r--r--app/models/service.rb1
-rw-r--r--app/models/snippet.rb60
-rw-r--r--app/models/snippet_repository.rb13
-rw-r--r--app/models/spam_log.rb4
-rw-r--r--app/models/storage/hashed.rb (renamed from app/models/storage/hashed_project.rb)20
-rw-r--r--app/models/system_note_metadata.rb2
-rw-r--r--app/models/todo.rb2
-rw-r--r--app/models/uploads/base.rb2
-rw-r--r--app/models/user.rb65
-rw-r--r--app/models/user_bot_type_enums.rb12
-rw-r--r--app/models/user_callout.rb3
-rw-r--r--app/models/user_preference.rb6
-rw-r--r--app/models/wiki_page.rb18
-rw-r--r--app/models/x509_certificate.rb32
-rw-r--r--app/models/x509_commit_signature.rb44
-rw-r--r--app/models/x509_issuer.rb18
-rw-r--r--app/policies/base_policy.rb3
-rw-r--r--app/policies/concerns/policy_actor.rb4
-rw-r--r--app/policies/error_tracking/base_policy.rb (renamed from app/policies/error_tracking/detailed_error_policy.rb)2
-rw-r--r--app/policies/global_policy.rb4
-rw-r--r--app/policies/group_policy.rb1
-rw-r--r--app/policies/personal_snippet_policy.rb15
-rw-r--r--app/policies/project_policy.rb19
-rw-r--r--app/policies/project_snippet_policy.rb37
-rw-r--r--app/presenters/blob_presenter.rb6
-rw-r--r--app/presenters/ci/pipeline_presenter.rb20
-rw-r--r--app/presenters/commit_status_presenter.rb8
-rw-r--r--app/presenters/milestone_presenter.rb15
-rw-r--r--app/presenters/project_presenter.rb4
-rw-r--r--app/presenters/projects/prometheus/alert_presenter.rb110
-rw-r--r--app/presenters/release_presenter.rb6
-rw-r--r--app/presenters/sentry_error_presenter.rb (renamed from app/presenters/sentry_detailed_error_presenter.rb)14
-rw-r--r--app/presenters/snippet_blob_presenter.rb41
-rw-r--r--app/serializers/README.md2
-rw-r--r--app/serializers/build_artifact_entity.rb2
-rw-r--r--app/serializers/build_details_entity.rb8
-rw-r--r--app/serializers/cluster_basic_entity.rb10
-rw-r--r--app/serializers/concerns/user_status_tooltip.rb2
-rw-r--r--app/serializers/container_repositories_serializer.rb1
-rw-r--r--app/serializers/deployment_cluster_entity.rb20
-rw-r--r--app/serializers/deployment_entity.rb6
-rw-r--r--app/serializers/diffs_entity.rb4
-rw-r--r--app/serializers/merge_request_diff_entity.rb8
-rw-r--r--app/serializers/merge_request_widget_entity.rb28
-rw-r--r--app/serializers/pipeline_details_entity.rb7
-rw-r--r--app/serializers/pipeline_entity.rb8
-rw-r--r--app/serializers/pipeline_serializer.rb7
-rw-r--r--app/serializers/projects/serverless/service_entity.rb88
-rw-r--r--app/serializers/test_reports_comparer_entity.rb1
-rw-r--r--app/serializers/test_suite_comparer_entity.rb15
-rw-r--r--app/serializers/variable_entity.rb1
-rw-r--r--app/services/akismet_service.rb73
-rw-r--r--app/services/audit_event_service.rb2
-rw-r--r--app/services/boards/issues/list_service.rb22
-rw-r--r--app/services/boards/list_service.rb27
-rw-r--r--app/services/ci/create_cross_project_pipeline_service.rb84
-rw-r--r--app/services/ci/create_job_artifacts_service.rb52
-rw-r--r--app/services/ci/create_pipeline_service.rb2
-rw-r--r--app/services/ci/pipeline_bridge_status_service.rb13
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service.rb4
-rw-r--r--app/services/ci/pipeline_processing/legacy_processing_service.rb46
-rw-r--r--app/services/ci/process_build_service.rb8
-rw-r--r--app/services/ci/process_pipeline_service.rb17
-rw-r--r--app/services/ci/retry_build_service.rb9
-rw-r--r--app/services/ci/retry_pipeline_service.rb2
-rw-r--r--app/services/ci/stop_environments_service.rb16
-rw-r--r--app/services/clusters/applications/base_service.rb6
-rw-r--r--app/services/clusters/kubernetes.rb2
-rw-r--r--app/services/clusters/kubernetes/configure_istio_ingress_service.rb108
-rw-r--r--app/services/commits/cherry_pick_service.rb19
-rw-r--r--app/services/concerns/akismet_methods.rb15
-rw-r--r--app/services/concerns/spam_check_methods.rb15
-rw-r--r--app/services/container_expiration_policy_service.rb6
-rw-r--r--app/services/deployments/link_merge_requests_service.rb9
-rw-r--r--app/services/deployments/older_deployments_drop_service.rb31
-rw-r--r--app/services/environments/auto_stop_service.rb38
-rw-r--r--app/services/error_tracking/base_service.rb31
-rw-r--r--app/services/error_tracking/issue_details_service.rb29
-rw-r--r--app/services/error_tracking/issue_latest_event_service.rb6
-rw-r--r--app/services/error_tracking/issue_update_service.rb56
-rw-r--r--app/services/error_tracking/list_issues_service.rb23
-rw-r--r--app/services/error_tracking/list_projects_service.rb12
-rw-r--r--app/services/git/base_hooks_service.rb26
-rw-r--r--app/services/git/branch_hooks_service.rb16
-rw-r--r--app/services/groups/import_export/export_service.rb6
-rw-r--r--app/services/groups/import_export/import_service.rb61
-rw-r--r--app/services/ham_service.rb30
-rw-r--r--app/services/incident_management/create_issue_service.rb126
-rw-r--r--app/services/issuable/clone/attributes_rewriter.rb40
-rw-r--r--app/services/issuable_base_service.rb12
-rw-r--r--app/services/merge_requests/add_context_service.rb116
-rw-r--r--app/services/merge_requests/create_pipeline_service.rb2
-rw-r--r--app/services/merge_requests/create_service.rb5
-rw-r--r--app/services/merge_requests/delete_non_latest_diffs_service.rb2
-rw-r--r--app/services/merge_requests/link_lfs_objects_service.rb31
-rw-r--r--app/services/merge_requests/merge_service.rb4
-rw-r--r--app/services/merge_requests/mergeability_check_service.rb20
-rw-r--r--app/services/merge_requests/migrate_external_diffs_service.rb3
-rw-r--r--app/services/merge_requests/refresh_service.rb29
-rw-r--r--app/services/metrics/dashboard/base_service.rb16
-rw-r--r--app/services/metrics/dashboard/clone_dashboard_service.rb26
-rw-r--r--app/services/metrics/dashboard/default_embed_service.rb6
-rw-r--r--app/services/metrics/dashboard/predefined_dashboard_service.rb4
-rw-r--r--app/services/metrics/dashboard/project_dashboard_service.rb4
-rw-r--r--app/services/metrics/dashboard/self_monitoring_dashboard_service.rb37
-rw-r--r--app/services/metrics/dashboard/system_dashboard_service.rb1
-rw-r--r--app/services/notes/create_service.rb4
-rw-r--r--app/services/notes/update_service.rb4
-rw-r--r--app/services/post_receive_service.rb69
-rw-r--r--app/services/projects/after_import_service.rb10
-rw-r--r--app/services/projects/alerting/notify_service.rb49
-rw-r--r--app/services/projects/container_repository/cleanup_tags_service.rb8
-rw-r--r--app/services/projects/container_repository/delete_tags_service.rb26
-rw-r--r--app/services/projects/create_service.rb1
-rw-r--r--app/services/projects/destroy_rollback_service.rb31
-rw-r--r--app/services/projects/destroy_service.rb98
-rw-r--r--app/services/projects/detect_repository_languages_service.rb2
-rw-r--r--app/services/projects/fork_service.rb12
-rw-r--r--app/services/projects/import_service.rb28
-rw-r--r--app/services/projects/lfs_pointers/lfs_download_service.rb6
-rw-r--r--app/services/projects/lsif_data_service.rb103
-rw-r--r--app/services/projects/move_access_service.rb2
-rw-r--r--app/services/projects/move_lfs_objects_projects_service.rb2
-rw-r--r--app/services/projects/operations/update_service.rb36
-rw-r--r--app/services/projects/overwrite_project_service.rb4
-rw-r--r--app/services/projects/transfer_service.rb6
-rw-r--r--app/services/projects/unlink_fork_service.rb4
-rw-r--r--app/services/repositories/base_service.rb52
-rw-r--r--app/services/repositories/destroy_rollback_service.rb19
-rw-r--r--app/services/repositories/destroy_service.rb28
-rw-r--r--app/services/repositories/shell_destroy_service.rb15
-rw-r--r--app/services/snippets/count_service.rb77
-rw-r--r--app/services/snippets/create_service.rb10
-rw-r--r--app/services/snippets/destroy_service.rb4
-rw-r--r--app/services/snippets/update_service.rb2
-rw-r--r--app/services/spam/akismet_service.rb75
-rw-r--r--app/services/spam/ham_service.rb28
-rw-r--r--app/services/spam/spam_check_service.rb68
-rw-r--r--app/services/spam_service.rb66
-rw-r--r--app/services/submit_usage_ping_service.rb6
-rw-r--r--app/services/system_note_service.rb21
-rw-r--r--app/services/system_notes/issuables_service.rb6
-rw-r--r--app/services/system_notes/merge_requests_service.rb11
-rw-r--r--app/services/user_project_access_changed_service.rb2
-rw-r--r--app/services/users/block_service.rb27
-rw-r--r--app/services/users/create_service.rb13
-rw-r--r--app/services/users/destroy_service.rb10
-rw-r--r--app/services/users/update_service.rb6
-rw-r--r--app/services/web_hook_service.rb4
-rw-r--r--app/uploaders/avatar_uploader.rb4
-rw-r--r--app/uploaders/file_uploader.rb4
-rw-r--r--app/uploaders/object_storage.rb2
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml12
-rw-r--r--app/views/admin/application_settings/_usage.html.haml6
-rw-r--r--app/views/admin/application_settings/general.html.haml2
-rw-r--r--app/views/admin/application_settings/metrics_and_profiling.html.haml3
-rw-r--r--app/views/admin/applications/_form.html.haml8
-rw-r--r--app/views/admin/applications/index.html.haml2
-rw-r--r--app/views/admin/applications/show.html.haml6
-rw-r--r--app/views/admin/broadcast_messages/_form.html.haml28
-rw-r--r--app/views/admin/dashboard/index.html.haml8
-rw-r--r--app/views/admin/groups/_group.html.haml1
-rw-r--r--app/views/admin/runners/index.html.haml2
-rw-r--r--app/views/admin/runners/show.html.haml6
-rw-r--r--app/views/admin/serverless/domains/_form.html.haml68
-rw-r--r--app/views/admin/serverless/domains/index.html.haml25
-rw-r--r--app/views/clusters/clusters/_advanced_settings.html.haml28
-rw-r--r--app/views/clusters/clusters/_cluster.html.haml2
-rw-r--r--app/views/clusters/clusters/_form.html.haml2
-rw-r--r--app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml6
-rw-r--r--app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml4
-rw-r--r--app/views/clusters/clusters/gcp/_form.html.haml6
-rw-r--r--app/views/clusters/clusters/new.html.haml9
-rw-r--r--app/views/clusters/clusters/show.html.haml4
-rw-r--r--app/views/dashboard/_activities.html.haml3
-rw-r--r--app/views/dashboard/_snippets_head.html.haml2
-rw-r--r--app/views/dashboard/projects/_projects.html.haml2
-rw-r--r--app/views/dashboard/snippets/index.html.haml4
-rw-r--r--app/views/devise/registrations/new.html.haml15
-rw-r--r--app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml3
-rw-r--r--app/views/devise/shared/_experimental_separate_sign_up_flow_omniauth_box.haml13
-rw-r--r--app/views/doorkeeper/applications/_form.html.haml6
-rw-r--r--app/views/doorkeeper/applications/show.html.haml6
-rw-r--r--app/views/explore/projects/_nav.html.haml12
-rw-r--r--app/views/explore/projects/_projects.html.haml2
-rw-r--r--app/views/explore/projects/page_out_of_bounds.html.haml21
-rw-r--r--app/views/groups/_create_chat_team.html.haml2
-rw-r--r--app/views/groups/_home_panel.html.haml2
-rw-r--r--app/views/groups/registry/repositories/index.html.haml25
-rw-r--r--app/views/groups/settings/_advanced.html.haml11
-rw-r--r--app/views/groups/settings/_permanent_deletion.html.haml9
-rw-r--r--app/views/groups/settings/_remove.html.haml5
-rw-r--r--app/views/groups/show.html.haml2
-rw-r--r--app/views/help/_shortcuts.html.haml1
-rw-r--r--app/views/help/ui.html.haml4
-rw-r--r--app/views/import/shared/_new_project_form.html.haml2
-rw-r--r--app/views/instance_statistics/cohorts/_usage_ping.html.haml10
-rw-r--r--app/views/instance_statistics/cohorts/index.html.haml2
-rw-r--r--app/views/kaminari/gitlab/_first_page.html.haml9
-rw-r--r--app/views/kaminari/gitlab/_last_page.html.haml9
-rw-r--r--app/views/kaminari/gitlab/_next_page.html.haml4
-rw-r--r--app/views/kaminari/gitlab/_page.html.haml6
-rw-r--r--app/views/kaminari/gitlab/_paginator.html.haml6
-rw-r--r--app/views/kaminari/gitlab/_prev_page.html.haml4
-rw-r--r--app/views/kaminari/gitlab/_without_count.html.haml8
-rw-r--r--app/views/layouts/_broadcast.html.haml3
-rw-r--r--app/views/layouts/_page.html.haml1
-rw-r--r--app/views/layouts/application.html.haml2
-rw-r--r--app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml15
-rw-r--r--app/views/layouts/fullscreen.html.haml2
-rw-r--r--app/views/layouts/header/_help_dropdown.html.haml4
-rw-r--r--app/views/layouts/header/_logo_with_title.html.haml4
-rw-r--r--app/views/layouts/header/_new_dropdown.haml4
-rw-r--r--app/views/layouts/nav/_dashboard.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml11
-rw-r--r--app/views/layouts/nav/sidebar/_analytics_links.html.haml16
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml25
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml51
-rw-r--r--app/views/notify/_failed_builds.html.haml2
-rw-r--r--app/views/notify/autodevops_disabled_email.text.erb2
-rw-r--r--app/views/notify/links/projects/generic_commit_statuses/_generic_commit_status.html.haml1
-rw-r--r--app/views/notify/links/projects/generic_commit_statuses/_generic_commit_status.text.erb1
-rw-r--r--app/views/notify/note_project_snippet_email.html.haml1
-rw-r--r--app/views/notify/note_project_snippet_email.text.erb1
-rw-r--r--app/views/notify/note_snippet_email.html.haml (renamed from app/views/notify/note_personal_snippet_email.html.haml)0
-rw-r--r--app/views/notify/note_snippet_email.text.erb (renamed from app/views/notify/note_personal_snippet_email.text.erb)0
-rw-r--r--app/views/notify/pipeline_failed_email.text.erb2
-rw-r--r--app/views/profiles/preferences/show.html.haml9
-rw-r--r--app/views/profiles/preferences/update.js.erb8
-rw-r--r--app/views/projects/_home_panel.html.haml11
-rw-r--r--app/views/projects/_zen.html.haml4
-rw-r--r--app/views/projects/blob/_blob.html.haml2
-rw-r--r--app/views/projects/blob/_header.html.haml12
-rw-r--r--app/views/projects/blob/_viewer_switcher.html.haml2
-rw-r--r--app/views/projects/buttons/_clone.html.haml9
-rw-r--r--app/views/projects/buttons/_download.html.haml2
-rw-r--r--app/views/projects/buttons/_dropdown.html.haml2
-rw-r--r--app/views/projects/ci/builds/_build.html.haml2
-rw-r--r--app/views/projects/commit/_signature.html.haml3
-rw-r--r--app/views/projects/commit/_signature_badge.html.haml16
-rw-r--r--app/views/projects/commit/x509/_certificate_details.html.haml17
-rw-r--r--app/views/projects/commit/x509/_signature_badge_user.html.haml19
-rw-r--r--app/views/projects/commit/x509/_unverified_signature_badge.html.haml6
-rw-r--r--app/views/projects/commit/x509/_verified_signature_badge.html.haml6
-rw-r--r--app/views/projects/compare/_form.html.haml4
-rw-r--r--app/views/projects/cycle_analytics/_overview.html.haml8
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml9
-rw-r--r--app/views/projects/empty.html.haml7
-rw-r--r--app/views/projects/graphs/charts.html.haml30
-rw-r--r--app/views/projects/hook_logs/_index.html.haml2
-rw-r--r--app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml2
-rw-r--r--app/views/projects/merge_requests/conflicts/show.html.haml2
-rw-r--r--app/views/projects/merge_requests/creations/_new_compare.html.haml6
-rw-r--r--app/views/projects/merge_requests/creations/_new_submit.html.haml3
-rw-r--r--app/views/projects/merge_requests/show.html.haml3
-rw-r--r--app/views/projects/network/show.json.erb2
-rw-r--r--app/views/projects/pipelines/_info.html.haml9
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml2
-rw-r--r--app/views/projects/pipelines/charts.html.haml9
-rw-r--r--app/views/projects/pipelines/charts/_overall.haml6
-rw-r--r--app/views/projects/pipelines/charts/_pipeline_statistics.haml14
-rw-r--r--app/views/projects/pipelines/charts/_pipeline_times.haml8
-rw-r--r--app/views/projects/pipelines/charts/_pipelines.haml37
-rw-r--r--app/views/projects/pipelines/show.html.haml3
-rw-r--r--app/views/projects/registry/repositories/index.html.haml30
-rw-r--r--app/views/projects/releases/show.html.haml4
-rw-r--r--app/views/projects/services/alerts/_help.html.haml3
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml4
-rw-r--r--app/views/projects/settings/operations/_incidents.html.haml32
-rw-r--r--app/views/projects/settings/operations/show.html.haml2
-rw-r--r--app/views/projects/show.html.haml1
-rw-r--r--app/views/projects/snippets/_actions.html.haml14
-rw-r--r--app/views/projects/snippets/edit.html.haml1
-rw-r--r--app/views/projects/snippets/index.html.haml9
-rw-r--r--app/views/projects/snippets/new.html.haml1
-rw-r--r--app/views/projects/snippets/show.html.haml6
-rw-r--r--app/views/projects/tree/_tree_content.html.haml2
-rw-r--r--app/views/projects/tree/_tree_header.html.haml27
-rw-r--r--app/views/projects/wikis/_form.html.haml10
-rw-r--r--app/views/search/_results.html.haml3
-rw-r--r--app/views/search/results/_blob.html.haml4
-rw-r--r--app/views/search/results/_snippet_blob.html.haml21
-rw-r--r--app/views/search/results/_wiki_blob.html.haml3
-rw-r--r--app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml2
-rw-r--r--app/views/shared/_broadcast_message.html.haml8
-rw-r--r--app/views/shared/_check_recovery_settings.html.haml6
-rw-r--r--app/views/shared/_mobile_clone_panel.html.haml4
-rw-r--r--app/views/shared/_ping_consent.html.haml2
-rw-r--r--app/views/shared/_service_settings.html.haml2
-rw-r--r--app/views/shared/boards/components/_board.html.haml4
-rw-r--r--app/views/shared/empty_states/_profile_tabs.html.haml3
-rw-r--r--app/views/shared/empty_states/_snippets.html.haml15
-rw-r--r--app/views/shared/hook_logs/_status_label.html.haml4
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml2
-rw-r--r--app/views/shared/members/_group.html.haml2
-rw-r--r--app/views/shared/members/_member.html.haml2
-rw-r--r--app/views/shared/milestones/_delete_button.html.haml2
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml4
-rw-r--r--app/views/shared/milestones/_tab_loading.html.haml2
-rw-r--r--app/views/shared/notifications/_custom_notifications.html.haml2
-rw-r--r--app/views/shared/notifications/_new_button.html.haml4
-rw-r--r--app/views/shared/projects/_project.html.haml7
-rw-r--r--app/views/shared/snippets/_form.html.haml52
-rw-r--r--app/views/sherlock/queries/_general.html.haml4
-rw-r--r--app/views/snippets/_actions.html.haml12
-rw-r--r--app/views/snippets/_snippets.html.haml2
-rw-r--r--app/views/snippets/_snippets_scope_menu.html.haml10
-rw-r--r--app/views/snippets/edit.html.haml1
-rw-r--r--app/views/snippets/new.html.haml1
-rw-r--r--app/views/snippets/show.html.haml6
-rw-r--r--app/views/users/_overview.html.haml6
-rw-r--r--app/views/users/show.html.haml7
-rw-r--r--app/workers/admin_email_worker.rb3
-rw-r--r--app/workers/all_queues.yml1275
-rw-r--r--app/workers/authorized_projects_worker.rb1
-rw-r--r--app/workers/auto_merge_process_worker.rb1
-rw-r--r--app/workers/build_finished_worker.rb2
-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/create_cross_project_pipeline_worker.rb18
-rw-r--r--app/workers/ci/pipeline_bridge_status_worker.rb19
-rw-r--r--app/workers/cleanup_container_repository_worker.rb7
-rw-r--r--app/workers/cluster_configure_istio_worker.rb14
-rw-r--r--app/workers/concerns/application_worker.rb1
-rw-r--r--app/workers/concerns/cronjob_queue.rb1
-rw-r--r--app/workers/concerns/security_scans_queue.rb13
-rw-r--r--app/workers/concerns/self_monitoring_project_worker.rb1
-rw-r--r--app/workers/concerns/worker_attributes.rb29
-rw-r--r--app/workers/concerns/worker_context.rb65
-rw-r--r--app/workers/container_expiration_policy_worker.rb8
-rw-r--r--app/workers/create_commit_signature_worker.rb (renamed from app/workers/create_gpg_signature_worker.rb)10
-rw-r--r--app/workers/create_evidence_worker.rb1
-rw-r--r--app/workers/deployments/forward_deployment_worker.rb14
-rw-r--r--app/workers/email_receiver_worker.rb1
-rw-r--r--app/workers/emails_on_push_worker.rb1
-rw-r--r--app/workers/environments/auto_stop_cron_worker.rb16
-rw-r--r--app/workers/error_tracking_issue_link_worker.rb83
-rw-r--r--app/workers/expire_build_artifacts_worker.rb3
-rw-r--r--app/workers/expire_pipeline_cache_worker.rb2
-rw-r--r--app/workers/gitlab/phabricator_import/base_worker.rb81
-rw-r--r--app/workers/gitlab/phabricator_import/import_tasks_worker.rb13
-rw-r--r--app/workers/gitlab_shell_worker.rb1
-rw-r--r--app/workers/gitlab_usage_ping_worker.rb3
-rw-r--r--app/workers/group_export_worker.rb4
-rw-r--r--app/workers/group_import_worker.rb15
-rw-r--r--app/workers/import_export_project_cleanup_worker.rb3
-rw-r--r--app/workers/import_issues_csv_worker.rb1
-rw-r--r--app/workers/incident_management/process_alert_worker.rb29
-rw-r--r--app/workers/invalid_gpg_signature_update_worker.rb1
-rw-r--r--app/workers/issue_due_scheduler_worker.rb4
-rw-r--r--app/workers/mail_scheduler/notification_service_worker.rb41
-rw-r--r--app/workers/merge_request_mergeability_check_worker.rb23
-rw-r--r--app/workers/merge_worker.rb1
-rw-r--r--app/workers/namespaces/prune_aggregation_schedules_worker.rb2
-rw-r--r--app/workers/new_issue_worker.rb1
-rw-r--r--app/workers/new_merge_request_worker.rb1
-rw-r--r--app/workers/new_note_worker.rb1
-rw-r--r--app/workers/new_release_worker.rb1
-rw-r--r--app/workers/pages_domain_removal_cron_worker.rb4
-rw-r--r--app/workers/pages_domain_ssl_renewal_cron_worker.rb6
-rw-r--r--app/workers/pages_domain_verification_cron_worker.rb6
-rw-r--r--app/workers/personal_access_tokens/expiring_worker.rb8
-rw-r--r--app/workers/pipeline_schedule_worker.rb4
-rw-r--r--app/workers/post_receive.rb1
-rw-r--r--app/workers/process_commit_worker.rb1
-rw-r--r--app/workers/project_export_worker.rb2
-rw-r--r--app/workers/prune_old_events_worker.rb19
-rw-r--r--app/workers/prune_web_hook_logs_worker.rb20
-rw-r--r--app/workers/reactive_caching_worker.rb2
-rw-r--r--app/workers/rebase_worker.rb1
-rw-r--r--app/workers/remote_mirror_notification_worker.rb1
-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.rb5
-rw-r--r--app/workers/repository_archive_cache_worker.rb3
-rw-r--r--app/workers/repository_check/dispatch_worker.rb3
-rw-r--r--app/workers/repository_fork_worker.rb26
-rw-r--r--app/workers/requests_profiles_worker.rb3
-rw-r--r--app/workers/schedule_migrate_external_diffs_worker.rb5
-rw-r--r--app/workers/stuck_ci_jobs_worker.rb4
-rw-r--r--app/workers/stuck_import_jobs_worker.rb4
-rw-r--r--app/workers/stuck_merge_jobs_worker.rb2
-rw-r--r--app/workers/trending_projects_worker.rb4
-rw-r--r--app/workers/update_external_pull_requests_worker.rb1
-rw-r--r--app/workers/update_merge_requests_worker.rb1
1124 files changed, 18328 insertions, 6892 deletions
diff --git a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue
new file mode 100644
index 00000000000..5e16f6f3873
--- /dev/null
+++ b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue
@@ -0,0 +1,168 @@
+<script>
+import { GlButton, GlFormGroup, GlFormInput, GlModal, GlModalDirective } from '@gitlab/ui';
+import _ from 'underscore';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import ToggleButton from '~/vue_shared/components/toggle_button.vue';
+import axios from '~/lib/utils/axios_utils';
+import { s__, __, sprintf } from '~/locale';
+import createFlash from '~/flash';
+
+export default {
+ COPY_TO_CLIPBOARD: __('Copy'),
+ RESET_KEY: __('Reset key'),
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlModal,
+ ClipboardButton,
+ ToggleButton,
+ },
+ directives: {
+ 'gl-modal': GlModalDirective,
+ },
+ props: {
+ initialAuthorizationKey: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ formPath: {
+ type: String,
+ required: true,
+ },
+ url: {
+ type: String,
+ required: true,
+ },
+ learnMoreUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ initialActivated: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ activated: this.initialActivated,
+ loadingActivated: false,
+ authorizationKey: this.initialAuthorizationKey,
+ };
+ },
+ computed: {
+ learnMoreDescription() {
+ return sprintf(
+ s__(
+ 'AlertService|%{linkStart}Learn more%{linkEnd} about configuring this endpoint to receive alerts.',
+ ),
+ {
+ linkStart: `<a href="${_.escape(
+ this.learnMoreUrl,
+ )}" target="_blank" rel="noopener noreferrer">`,
+ linkEnd: '</a>',
+ },
+ false,
+ );
+ },
+ sectionDescription() {
+ const desc = s__(
+ 'AlertService|Each alert source must be authorized using the following URL and authorization key.',
+ );
+ const learnMoreDesc = this.learnMoreDescription ? ` ${this.learnMoreDescription}` : '';
+
+ return `${desc}${learnMoreDesc}`;
+ },
+ },
+ watch: {
+ activated() {
+ this.updateIcon();
+ },
+ },
+ methods: {
+ updateIcon() {
+ return document.querySelectorAll('.js-service-active-status').forEach(icon => {
+ if (icon.dataset.value === this.activated.toString()) {
+ icon.classList.remove('d-none');
+ } else {
+ icon.classList.add('d-none');
+ }
+ });
+ },
+ resetKey() {
+ return axios
+ .put(this.formPath, { service: { token: '' } })
+ .then(res => {
+ this.authorizationKey = res.data.token;
+ })
+ .catch(() => {
+ createFlash(__('Failed to reset key. Please try again.'));
+ });
+ },
+ toggleActivated(value) {
+ this.loadingActivated = true;
+ return axios
+ .put(this.formPath, { service: { active: value } })
+ .then(() => {
+ this.activated = value;
+ this.loadingActivated = false;
+ })
+ .catch(() => {
+ createFlash(__('Update failed. Please try again.'));
+ this.loadingActivated = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <p v-html="sectionDescription"></p>
+ <gl-form-group :label="__('Active')" label-for="activated" label-class="label-bold">
+ <toggle-button
+ id="activated"
+ :disabled-input="loadingActivated"
+ :is-loading="loadingActivated"
+ :value="activated"
+ @change="toggleActivated"
+ />
+ </gl-form-group>
+ <gl-form-group :label="__('URL')" label-for="url" label-class="label-bold">
+ <div class="input-group">
+ <gl-form-input id="url" :readonly="true" :value="url" />
+ <span class="input-group-append">
+ <clipboard-button :text="url" :title="$options.COPY_TO_CLIPBOARD" />
+ </span>
+ </div>
+ </gl-form-group>
+ <gl-form-group
+ :label="__('Authorization key')"
+ label-for="authorization-key"
+ label-class="label-bold"
+ >
+ <div class="input-group">
+ <gl-form-input id="authorization-key" :readonly="true" :value="authorizationKey" />
+ <span class="input-group-append">
+ <clipboard-button :text="authorizationKey" :title="$options.COPY_TO_CLIPBOARD" />
+ </span>
+ </div>
+ <gl-button v-gl-modal.authKeyModal class="mt-2">{{ $options.RESET_KEY }}</gl-button>
+ <gl-modal
+ modal-id="authKeyModal"
+ :title="$options.RESET_KEY"
+ :ok-title="$options.RESET_KEY"
+ ok-variant="danger"
+ @ok="resetKey"
+ >
+ {{
+ __(
+ 'Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.',
+ )
+ }}
+ </gl-modal>
+ </gl-form-group>
+ </div>
+</template>
diff --git a/app/assets/javascripts/alerts_service_settings/index.js b/app/assets/javascripts/alerts_service_settings/index.js
new file mode 100644
index 00000000000..d49725c6a4d
--- /dev/null
+++ b/app/assets/javascripts/alerts_service_settings/index.js
@@ -0,0 +1,27 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import AlertsServiceForm from './components/alerts_service_form.vue';
+
+export default el => {
+ if (!el) {
+ return null;
+ }
+
+ const { activated: activatedStr, formPath, authorizationKey, url, learnMoreUrl } = el.dataset;
+ const activated = parseBoolean(activatedStr);
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(AlertsServiceForm, {
+ props: {
+ initialActivated: activated,
+ formPath,
+ learnMoreUrl,
+ initialAuthorizationKey: authorizationKey,
+ url,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index bee079c6643..4dc4ce543e9 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -24,6 +24,7 @@ const Api = {
projectMergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes',
projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions',
projectRunnersPath: '/api/:version/projects/:id/runners',
+ projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches',
mergeRequestsPath: '/api/:version/merge_requests',
groupLabelsPath: '/groups/:namespace_path/-/labels',
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
@@ -44,6 +45,8 @@ const Api = {
releasePath: '/api/:version/projects/:id/releases/:tag_name',
mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines',
adminStatisticsPath: '/api/:version/application/statistics',
+ pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id',
+ lsifPath: '/api/:version/projects/:id/commits/:commit_id/lsif/info',
group(groupId, callback) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
@@ -218,6 +221,22 @@ const Api = {
return axios.get(url, config);
},
+ projectProtectedBranches(id, query = '') {
+ const url = Api.buildUrl(Api.projectProtectedBranchesPath).replace(
+ ':id',
+ encodeURIComponent(id),
+ );
+
+ return axios
+ .get(url, {
+ params: {
+ search: query,
+ per_page: DEFAULT_PER_PAGE,
+ },
+ })
+ .then(({ data }) => data);
+ },
+
mergeRequests(params = {}) {
const url = Api.buildUrl(Api.mergeRequestsPath);
@@ -448,6 +467,22 @@ const Api = {
return axios.get(url);
},
+ pipelineSingle(id, pipelineId) {
+ const url = Api.buildUrl(this.pipelineSinglePath)
+ .replace(':id', encodeURIComponent(id))
+ .replace(':pipeline_id', encodeURIComponent(pipelineId));
+
+ return axios.get(url);
+ },
+
+ lsifData(projectPath, commitId, path) {
+ const url = Api.buildUrl(this.lsifPath)
+ .replace(':id', encodeURIComponent(projectPath))
+ .replace(':commit_id', commitId);
+
+ return axios.get(url, { params: { path } });
+ },
+
buildUrl(url) {
return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
},
diff --git a/app/assets/javascripts/behaviors/markdown/constants.js b/app/assets/javascripts/behaviors/markdown/constants.js
new file mode 100644
index 00000000000..b4545d6c6c6
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/constants.js
@@ -0,0 +1,3 @@
+// https://prosemirror.net/docs/ref/#model.ParseRule.priority
+export const DEFAULT_PARSE_RULE_PRIORITY = 50;
+export const HIGHER_PARSE_RULE_PRIORITY = 1 + DEFAULT_PARSE_RULE_PRIORITY;
diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js
index ebed8698e21..7e020139fe7 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
import { Mark } from 'tiptap';
-import _ from 'underscore';
+import { escape as esc } from 'lodash';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class InlineHTML extends Mark {
@@ -35,7 +35,7 @@ export default class InlineHTML extends Mark {
mixable: true,
open(state, mark) {
return `<${mark.attrs.tag}${
- mark.attrs.title ? ` title="${state.esc(_.escape(mark.attrs.title))}"` : ''
+ mark.attrs.title ? ` title="${state.esc(esc(mark.attrs.title))}"` : ''
}>`;
},
close(state, mark) {
diff --git a/app/assets/javascripts/behaviors/markdown/marks/math.js b/app/assets/javascripts/behaviors/markdown/marks/math.js
index e582fb18f15..04441d5d710 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/math.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/math.js
@@ -2,6 +2,7 @@
import { Mark } from 'tiptap';
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
+import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
// Transforms generated HTML back to GFM for Banzai::Filter::MathFilter
export default class MathMark extends Mark {
@@ -15,7 +16,7 @@ export default class MathMark extends Mark {
// Matches HTML generated by Banzai::Filter::MathFilter
{
tag: 'code.code.math[data-math-style=inline]',
- priority: 51,
+ priority: HIGHER_PARSE_RULE_PRIORITY,
},
// Matches HTML after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js
{
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/audio.js b/app/assets/javascripts/behaviors/markdown/nodes/audio.js
index 48ac408cf24..146349b118c 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/audio.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/audio.js
@@ -1,53 +1,9 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
+import Playable from './playable';
// 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);
+export default class Audio extends Playable {
+ constructor() {
+ super();
+ this.mediaType = 'audio';
}
}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/image.js b/app/assets/javascripts/behaviors/markdown/nodes/image.js
index e839396330e..b1983eebe15 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/image.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/image.js
@@ -3,6 +3,7 @@
import { Image as BaseImage } from 'tiptap-extensions';
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { placeholderImage } from '~/lazy_loader';
+import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
export default class Image extends BaseImage {
get schema() {
@@ -23,7 +24,7 @@ export default class Image extends BaseImage {
// Matches HTML generated by Banzai::Filter::ImageLinkFilter
{
tag: 'a.no-attachment-icon',
- priority: 51,
+ priority: HIGHER_PARSE_RULE_PRIORITY,
skip: true,
},
// Matches HTML generated by Banzai::Filter::ImageLazyLoadFilter
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js
index 25c4976a1bc..a28d7be3758 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this */
import { Node } from 'tiptap';
+import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter
export default class OrderedTaskList extends Node {
@@ -14,7 +15,7 @@ export default class OrderedTaskList extends Node {
content: '(task_list_item|list_item)+',
parseDOM: [
{
- priority: 51,
+ priority: HIGHER_PARSE_RULE_PRIORITY,
tag: 'ol.task-list',
},
],
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/playable.js b/app/assets/javascripts/behaviors/markdown/nodes/playable.js
new file mode 100644
index 00000000000..9209c69d04a
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/playable.js
@@ -0,0 +1,73 @@
+/* eslint-disable class-methods-use-this */
+/* eslint-disable @gitlab/i18n/no-non-i18n-strings */
+
+import { Node } from 'tiptap';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown';
+import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
+
+/**
+ * Abstract base class for playable media, like video and audio.
+ * Must not be instantiated directly. Subclasses must set
+ * the `mediaType` property in their constructors.
+ * @abstract
+ */
+export default class Playable extends Node {
+ constructor() {
+ super();
+ this.mediaType = '';
+ this.extraElementAttrs = {};
+ }
+
+ get name() {
+ return this.mediaType;
+ }
+
+ get schema() {
+ const attrs = {
+ src: {},
+ alt: {
+ default: null,
+ },
+ };
+
+ const parseDOM = [
+ {
+ tag: `.${this.mediaType}-container`,
+ skip: true,
+ },
+ {
+ tag: `.${this.mediaType}-container p`,
+ priority: HIGHER_PARSE_RULE_PRIORITY,
+ ignore: true,
+ },
+ {
+ tag: `${this.mediaType}[src]`,
+ getAttrs: el => ({ src: el.src, alt: el.dataset.title }),
+ },
+ ];
+
+ const toDOM = node => [
+ this.mediaType,
+ {
+ src: node.attrs.src,
+ controls: true,
+ 'data-setup': '{}',
+ 'data-title': node.attrs.alt,
+ ...this.extraElementAttrs,
+ },
+ ];
+
+ return {
+ attrs,
+ group: 'block',
+ draggable: true,
+ parseDOM,
+ toDOM,
+ };
+ }
+
+ toMarkdown(state, node) {
+ defaultMarkdownSerializer.nodes.image(state, node);
+ state.closeBlock(node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/reference.js b/app/assets/javascripts/behaviors/markdown/nodes/reference.js
index 5d6bbeca833..aa724798da6 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/reference.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/reference.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this */
import { Node } from 'tiptap';
+import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
// Transforms generated HTML back to GFM for Banzai::Filter::ReferenceFilter and subclasses
export default class Reference extends Node {
@@ -23,7 +24,7 @@ export default class Reference extends Node {
parseDOM: [
{
tag: 'a.gfm:not([data-link=true])',
- priority: 51,
+ priority: HIGHER_PARSE_RULE_PRIORITY,
getAttrs: el => ({
className: el.className,
referenceType: el.dataset.referenceType,
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js b/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js
index e7eee636402..6e3c16f0a08 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this */
import TableRow from './table_row';
+import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
const CENTER_ALIGN = 'center';
@@ -16,7 +17,7 @@ export default class TableHeaderRow extends TableRow {
parseDOM: [
{
tag: 'thead tr',
- priority: 51,
+ priority: HIGHER_PARSE_RULE_PRIORITY,
},
],
toDOM: () => ['tr', 0],
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js
index 9a2e2c03213..db9072acc3a 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js
@@ -2,6 +2,7 @@
import { Node } from 'tiptap';
import { __ } from '~/locale';
+import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
// Transforms generated HTML back to GFM for Banzai::Filter::TableOfContentsFilter
export default class TableOfContents extends Node {
@@ -16,11 +17,11 @@ export default class TableOfContents extends Node {
parseDOM: [
{
tag: 'ul.section-nav',
- priority: 51,
+ priority: HIGHER_PARSE_RULE_PRIORITY,
},
{
tag: 'p.table-of-contents',
- priority: 51,
+ priority: HIGHER_PARSE_RULE_PRIORITY,
},
],
toDOM: () => ['p', { class: 'table-of-contents' }, __('Table of Contents')],
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list.js
index ab33bc21502..35ba2eb0674 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/task_list.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this */
import { Node } from 'tiptap';
+import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter
export default class TaskList extends Node {
@@ -14,7 +15,7 @@ export default class TaskList extends Node {
content: '(task_list_item|list_item)+',
parseDOM: [
{
- priority: 51,
+ priority: HIGHER_PARSE_RULE_PRIORITY,
tag: 'ul.task-list',
},
],
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
index d0ee7333d5e..7bb56b4c406 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this */
import { Node } from 'tiptap';
+import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter
export default class TaskListItem extends Node {
@@ -20,7 +21,7 @@ export default class TaskListItem extends Node {
content: 'paragraph block*',
parseDOM: [
{
- priority: 51,
+ priority: HIGHER_PARSE_RULE_PRIORITY,
tag: 'li.task-list-item',
getAttrs: el => {
const checkbox = el.querySelector('input[type=checkbox].task-list-item-checkbox');
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/video.js b/app/assets/javascripts/behaviors/markdown/nodes/video.js
index 516f983397d..68085c2c416 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/video.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/video.js
@@ -1,54 +1,10 @@
-/* eslint-disable class-methods-use-this */
-
-import { Node } from 'tiptap';
-import { defaultMarkdownSerializer } from 'prosemirror-markdown';
+import Playable from './playable';
// Transforms generated HTML back to GFM for Banzai::Filter::VideoLinkFilter
-export default class Video extends Node {
- get name() {
- return 'video';
- }
-
- get schema() {
- return {
- attrs: {
- src: {},
- alt: {
- default: null,
- },
- },
- group: 'block',
- draggable: true,
- parseDOM: [
- {
- tag: '.video-container',
- skip: true,
- },
- {
- tag: '.video-container p',
- priority: 51,
- ignore: true,
- },
- {
- tag: 'video[src]',
- getAttrs: el => ({ src: el.getAttribute('src'), alt: el.dataset.title }),
- },
- ],
- toDOM: node => [
- 'video',
- {
- src: node.attrs.src,
- width: '400',
- controls: true,
- 'data-setup': '{}',
- 'data-title': node.attrs.alt,
- },
- ],
- };
- }
-
- toMarkdown(state, node) {
- defaultMarkdownSerializer.nodes.image(state, node);
- state.closeBlock(node);
+export default class Video extends Playable {
+ constructor() {
+ super();
+ this.mediaType = 'video';
+ this.extraElementAttrs = { width: '400' };
}
}
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
index c3e2c09f1d5..3856832de90 100644
--- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
@@ -1,4 +1,5 @@
import flash from '~/flash';
+import $ from 'jquery';
import { sprintf, __ } from '../../locale';
// Renders diagrams and flowcharts from text using Mermaid in any element with the
@@ -18,9 +19,12 @@ import { sprintf, __ } from '../../locale';
// This is an arbitrary number; Can be iterated upon when suitable.
const MAX_CHAR_LIMIT = 5000;
-export default function renderMermaid($els) {
+function renderMermaids($els) {
if (!$els.length) return;
+ // A diagram may have been truncated in search results which will cause errors, so abort the render.
+ if (document.querySelector('body').dataset.page === 'search:show') return;
+
import(/* webpackChunkName: 'mermaid' */ 'mermaid')
.then(mermaid => {
mermaid.initialize({
@@ -92,3 +96,19 @@ export default function renderMermaid($els) {
flash(`Can't load mermaid module: ${err}`);
});
}
+
+export default function renderMermaid($els) {
+ if (!$els.length) return;
+
+ const visibleMermaids = $els.filter(function filter() {
+ return $(this).closest('details').length === 0;
+ });
+
+ renderMermaids(visibleMermaids);
+
+ $els.closest('details').one('toggle', function toggle() {
+ if (this.open) {
+ renderMermaids($(this).find('.js-render-mermaid'));
+ }
+ });
+}
diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js
index 7cf18d1fd83..2fa3f4fc789 100644
--- a/app/assets/javascripts/behaviors/requires_input.js
+++ b/app/assets/javascripts/behaviors/requires_input.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import _ from 'underscore';
+import { isEmpty } from 'lodash';
import '../commons/bootstrap';
// Requires Input behavior
@@ -23,10 +23,10 @@ $.fn.requiresInput = function requiresInput() {
function requireInput() {
// Collect the input values of *all* required fields
- const values = _.map($(fieldSelector, $form), field => field.value);
+ const values = Array.from($(fieldSelector, $form)).map(field => field.value);
// Disable the button if any required fields are empty
- if (values.length && _.some(values, _.isEmpty)) {
+ if (values.length && values.some(isEmpty)) {
$button.disable();
} else {
$button.enable();
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
index 66cb9fd7672..85636f3e5d2 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
@@ -1,6 +1,9 @@
import $ from 'jquery';
import Cookies from 'js-cookie';
import Mousetrap from 'mousetrap';
+import Vue from 'vue';
+import { disableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle';
+import ShortcutsToggle from './shortcuts_toggle.vue';
import axios from '../../lib/utils/axios_utils';
import { refreshCurrentPage, visitUrl } from '../../lib/utils/url_utility';
import findAndFollowLink from '../../lib/utils/navigation_utility';
@@ -15,6 +18,15 @@ Mousetrap.stopCallback = (e, element, combo) => {
return defaultStopCallback(e, element, combo);
};
+function initToggleButton() {
+ return new Vue({
+ el: document.querySelector('.js-toggle-shortcuts'),
+ render(createElement) {
+ return createElement(ShortcutsToggle);
+ },
+ });
+}
+
export default class Shortcuts {
constructor() {
this.onToggleHelp = this.onToggleHelp.bind(this);
@@ -48,6 +60,14 @@ export default class Shortcuts {
$(this).remove();
e.preventDefault();
});
+
+ $('.js-shortcuts-modal-trigger')
+ .off('click')
+ .on('click', this.onToggleHelp);
+
+ if (shouldDisableShortcuts()) {
+ disableShortcuts();
+ }
}
onToggleHelp(e) {
@@ -104,7 +124,8 @@ export default class Shortcuts {
}
return $('.js-more-help-button').remove();
- });
+ })
+ .then(initToggleButton);
}
focusFilter(e) {
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
index 052e33b4a2b..d5d8edd5ac0 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
@@ -1,26 +1,67 @@
import Mousetrap from 'mousetrap';
-import { getLocationHash, visitUrl } from '../../lib/utils/url_utility';
+import {
+ getLocationHash,
+ updateHistory,
+ urlIsDifferent,
+ urlContainsSha,
+ getShaFromUrl,
+} from '~/lib/utils/url_utility';
+import { updateRefPortionOfTitle } from '~/repository/utils/title';
import Shortcuts from './shortcuts';
const defaults = {
skipResetBindings: false,
fileBlobPermalinkUrl: null,
+ fileBlobPermalinkUrlElement: null,
};
+function eventHasModifierKeys(event) {
+ // We ignore alt because I don't think alt clicks normally do anything special?
+ return event.ctrlKey || event.metaKey || event.shiftKey;
+}
+
export default class ShortcutsBlob extends Shortcuts {
constructor(opts) {
const options = Object.assign({}, defaults, opts);
super(options.skipResetBindings);
this.options = options;
+ this.shortcircuitPermalinkButton();
+
Mousetrap.bind('y', this.moveToFilePermalink.bind(this));
}
moveToFilePermalink() {
- if (this.options.fileBlobPermalinkUrl) {
+ const permalink = this.options.fileBlobPermalinkUrl;
+
+ if (permalink) {
const hash = getLocationHash();
const hashUrlString = hash ? `#${hash}` : '';
- visitUrl(`${this.options.fileBlobPermalinkUrl}${hashUrlString}`);
+
+ if (urlIsDifferent(permalink)) {
+ updateHistory({
+ url: `${permalink}${hashUrlString}`,
+ title: document.title,
+ });
+ }
+
+ if (urlContainsSha({ url: permalink })) {
+ updateRefPortionOfTitle(getShaFromUrl({ url: permalink }));
+ }
+ }
+ }
+
+ shortcircuitPermalinkButton() {
+ const button = this.options.fileBlobPermalinkUrlElement;
+ const handleButton = e => {
+ if (!eventHasModifierKeys(e)) {
+ e.preventDefault();
+ this.moveToFilePermalink();
+ }
+ };
+
+ if (button) {
+ button.addEventListener('click', handleButton);
}
}
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.js
new file mode 100644
index 00000000000..66aa1b752ae
--- /dev/null
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.js
@@ -0,0 +1,22 @@
+import Mousetrap from 'mousetrap';
+import 'mousetrap/plugins/pause/mousetrap-pause';
+
+const shorcutsDisabledKey = 'shortcutsDisabled';
+
+export const shouldDisableShortcuts = () => {
+ try {
+ return localStorage.getItem(shorcutsDisabledKey) === 'true';
+ } catch (e) {
+ return false;
+ }
+};
+
+export function enableShortcuts() {
+ localStorage.setItem(shorcutsDisabledKey, false);
+ Mousetrap.unpause();
+}
+
+export function disableShortcuts() {
+ localStorage.setItem(shorcutsDisabledKey, true);
+ Mousetrap.pause();
+}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue
new file mode 100644
index 00000000000..a53b1b06be9
--- /dev/null
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue
@@ -0,0 +1,60 @@
+<script>
+import { GlToggle, GlSprintf } from '@gitlab/ui';
+import AccessorUtilities from '~/lib/utils/accessor';
+import { disableShortcuts, enableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle';
+
+export default {
+ components: {
+ GlSprintf,
+ GlToggle,
+ },
+ data() {
+ return {
+ localStorageUsable: AccessorUtilities.isLocalStorageAccessSafe(),
+ shortcutsEnabled: !shouldDisableShortcuts(),
+ };
+ },
+ methods: {
+ onChange(value) {
+ this.shortcutsEnabled = value;
+ if (value) {
+ enableShortcuts();
+ } else {
+ disableShortcuts();
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="localStorageUsable" class="d-inline-flex align-items-center js-toggle-shortcuts">
+ <gl-toggle
+ v-model="shortcutsEnabled"
+ aria-describedby="shortcutsToggle"
+ class="prepend-left-10 mb-0"
+ label-position="right"
+ @change="onChange"
+ >
+ <template #labelOn>
+ <gl-sprintf
+ :message="__('%{screenreaderOnlyStart}Keyboard shorcuts%{screenreaderOnlyEnd} Enabled')"
+ >
+ <template #screenreaderOnly="{ content }">
+ <span class="sr-only">{{ content }}</span>
+ </template>
+ </gl-sprintf>
+ </template>
+ <template #labelOff>
+ <gl-sprintf
+ :message="__('%{screenreaderOnlyStart}Keyboard shorcuts%{screenreaderOnlyEnd} Disabled')"
+ >
+ <template #screenreaderOnly="{ content }">
+ <span class="sr-only">{{ content }}</span>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-toggle>
+ <div id="shortcutsToggle" class="sr-only">{{ __('Enable or disable keyboard shortcuts') }}</div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue
new file mode 100644
index 00000000000..2639a099093
--- /dev/null
+++ b/app/assets/javascripts/blob/components/blob_content.vue
@@ -0,0 +1,51 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers';
+import BlobContentError from './blob_content_error.vue';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ BlobContentError,
+ },
+ props: {
+ content: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ loading: {
+ type: Boolean,
+ default: true,
+ required: false,
+ },
+ activeViewer: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ viewer() {
+ switch (this.activeViewer.type) {
+ case 'rich':
+ return RichViewer;
+ default:
+ return SimpleViewer;
+ }
+ },
+ viewerError() {
+ return this.activeViewer.renderError;
+ },
+ },
+};
+</script>
+<template>
+ <div class="blob-viewer" :data-type="activeViewer.type">
+ <gl-loading-icon v-if="loading" size="md" color="dark" class="my-4 mx-auto" />
+
+ <template v-else>
+ <blob-content-error v-if="viewerError" :viewer-error="viewerError" />
+ <component :is="viewer" v-else ref="contentViewer" :content="content" />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/blob/components/blob_content_error.vue b/app/assets/javascripts/blob/components/blob_content_error.vue
new file mode 100644
index 00000000000..0f1af0a962d
--- /dev/null
+++ b/app/assets/javascripts/blob/components/blob_content_error.vue
@@ -0,0 +1,15 @@
+<script>
+export default {
+ props: {
+ viewerError: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="file-content code">
+ <div class="text-center py-4" v-html="viewerError"></div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/blob/components/blob_embeddable.vue b/app/assets/javascripts/blob/components/blob_embeddable.vue
new file mode 100644
index 00000000000..26bd0208309
--- /dev/null
+++ b/app/assets/javascripts/blob/components/blob_embeddable.vue
@@ -0,0 +1,41 @@
+<script>
+import { GlFormInputGroup, GlButton, GlIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlFormInputGroup,
+ GlButton,
+ GlIcon,
+ },
+ props: {
+ url: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ optionValues: [
+ // eslint-disable-next-line no-useless-escape
+ { name: __('Embed'), value: `<script src='${this.url}.js'><\/script>` },
+ { name: __('Share'), value: this.url },
+ ],
+ };
+ },
+};
+</script>
+<template>
+ <gl-form-input-group
+ id="embeddable-text"
+ :predefined-options="optionValues"
+ readonly
+ select-on-click
+ >
+ <template #append>
+ <gl-button new-style data-clipboard-target="#embeddable-text">
+ <gl-icon name="copy-to-clipboard" :title="__('Copy')" />
+ </gl-button>
+ </template>
+ </gl-form-input-group>
+</template>
diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue
new file mode 100644
index 00000000000..b7d9600ec40
--- /dev/null
+++ b/app/assets/javascripts/blob/components/blob_header.vue
@@ -0,0 +1,82 @@
+<script>
+import ViewerSwitcher from './blob_header_viewer_switcher.vue';
+import DefaultActions from './blob_header_default_actions.vue';
+import BlobFilepath from './blob_header_filepath.vue';
+import { SIMPLE_BLOB_VIEWER } from './constants';
+
+export default {
+ components: {
+ ViewerSwitcher,
+ DefaultActions,
+ BlobFilepath,
+ },
+ props: {
+ blob: {
+ type: Object,
+ required: true,
+ },
+ hideDefaultActions: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ hideViewerSwitcher: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ activeViewerType: {
+ type: String,
+ required: false,
+ default: SIMPLE_BLOB_VIEWER,
+ },
+ },
+ data() {
+ return {
+ viewer: this.hideViewerSwitcher ? null : this.activeViewerType,
+ };
+ },
+ computed: {
+ showViewerSwitcher() {
+ return !this.hideViewerSwitcher && Boolean(this.blob.simpleViewer && this.blob.richViewer);
+ },
+ showDefaultActions() {
+ return !this.hideDefaultActions;
+ },
+ },
+ watch: {
+ viewer(newVal, oldVal) {
+ if (!this.hideViewerSwitcher && newVal !== oldVal) {
+ this.$emit('viewer-changed', newVal);
+ }
+ },
+ },
+ methods: {
+ proxyCopyRequest() {
+ this.$emit('copy');
+ },
+ },
+};
+</script>
+<template>
+ <div class="js-file-title file-title-flex-parent">
+ <blob-filepath :blob="blob">
+ <template #filepathPrepend>
+ <slot name="prepend"></slot>
+ </template>
+ </blob-filepath>
+
+ <div class="file-actions d-none d-sm-block">
+ <viewer-switcher v-if="showViewerSwitcher" v-model="viewer" />
+
+ <slot name="actions"></slot>
+
+ <default-actions
+ v-if="showDefaultActions"
+ :raw-path="blob.rawPath"
+ :active-viewer="viewer"
+ @copy="proxyCopyRequest"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/blob/components/blob_header_default_actions.vue b/app/assets/javascripts/blob/components/blob_header_default_actions.vue
new file mode 100644
index 00000000000..6b38b871606
--- /dev/null
+++ b/app/assets/javascripts/blob/components/blob_header_default_actions.vue
@@ -0,0 +1,74 @@
+<script>
+import { GlButton, GlButtonGroup, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import {
+ BTN_COPY_CONTENTS_TITLE,
+ BTN_DOWNLOAD_TITLE,
+ BTN_RAW_TITLE,
+ RICH_BLOB_VIEWER,
+ SIMPLE_BLOB_VIEWER,
+} from './constants';
+
+export default {
+ components: {
+ GlIcon,
+ GlButtonGroup,
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ rawPath: {
+ type: String,
+ required: true,
+ },
+ activeViewer: {
+ type: String,
+ default: SIMPLE_BLOB_VIEWER,
+ required: false,
+ },
+ },
+ computed: {
+ downloadUrl() {
+ return `${this.rawPath}?inline=false`;
+ },
+ copyDisabled() {
+ return this.activeViewer === RICH_BLOB_VIEWER;
+ },
+ },
+ BTN_COPY_CONTENTS_TITLE,
+ BTN_DOWNLOAD_TITLE,
+ BTN_RAW_TITLE,
+};
+</script>
+<template>
+ <gl-button-group>
+ <gl-button
+ v-gl-tooltip.hover
+ :aria-label="$options.BTN_COPY_CONTENTS_TITLE"
+ :title="$options.BTN_COPY_CONTENTS_TITLE"
+ :disabled="copyDisabled"
+ data-clipboard-target="#blob-code-content"
+ >
+ <gl-icon name="copy-to-clipboard" :size="14" />
+ </gl-button>
+ <gl-button
+ v-gl-tooltip.hover
+ :aria-label="$options.BTN_RAW_TITLE"
+ :title="$options.BTN_RAW_TITLE"
+ :href="rawPath"
+ target="_blank"
+ >
+ <gl-icon name="doc-code" :size="14" />
+ </gl-button>
+ <gl-button
+ v-gl-tooltip.hover
+ :aria-label="$options.BTN_DOWNLOAD_TITLE"
+ :title="$options.BTN_DOWNLOAD_TITLE"
+ :href="downloadUrl"
+ target="_blank"
+ >
+ <gl-icon name="download" :size="14" />
+ </gl-button>
+ </gl-button-group>
+</template>
diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue
new file mode 100644
index 00000000000..6c6a22e2b36
--- /dev/null
+++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue
@@ -0,0 +1,47 @@
+<script>
+import FileIcon from '~/vue_shared/components/file_icon.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+
+export default {
+ components: {
+ FileIcon,
+ ClipboardButton,
+ },
+ props: {
+ blob: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ blobSize() {
+ return numberToHumanSize(this.blob.size);
+ },
+ gfmCopyText() {
+ return `\`${this.blob.path}\``;
+ },
+ },
+};
+</script>
+<template>
+ <div class="file-header-content d-flex align-items-center lh-100">
+ <slot name="filepathPrepend"></slot>
+
+ <file-icon :file-name="blob.path" :size="18" aria-hidden="true" css-classes="mr-2" />
+ <strong
+ v-if="blob.name"
+ class="file-title-name qa-file-title-name mr-1 js-blob-header-filepath"
+ >{{ blob.name }}</strong
+ >
+
+ <small class="mr-2">{{ blobSize }}</small>
+
+ <clipboard-button
+ :text="blob.path"
+ :gfm="gfmCopyText"
+ :title="__('Copy file path')"
+ css-class="btn-clipboard btn-transparent lh-100 position-static"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue
new file mode 100644
index 00000000000..689fa7638f0
--- /dev/null
+++ b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlButton, GlButtonGroup, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import {
+ RICH_BLOB_VIEWER,
+ RICH_BLOB_VIEWER_TITLE,
+ SIMPLE_BLOB_VIEWER,
+ SIMPLE_BLOB_VIEWER_TITLE,
+} from './constants';
+
+export default {
+ components: {
+ GlIcon,
+ GlButtonGroup,
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ value: {
+ type: String,
+ default: SIMPLE_BLOB_VIEWER,
+ required: false,
+ },
+ },
+ computed: {
+ isSimpleViewer() {
+ return this.value === SIMPLE_BLOB_VIEWER;
+ },
+ isRichViewer() {
+ return this.value === RICH_BLOB_VIEWER;
+ },
+ },
+ methods: {
+ switchToViewer(viewer) {
+ if (viewer !== this.value) {
+ this.$emit('input', viewer);
+ }
+ },
+ },
+ SIMPLE_BLOB_VIEWER,
+ RICH_BLOB_VIEWER,
+ SIMPLE_BLOB_VIEWER_TITLE,
+ RICH_BLOB_VIEWER_TITLE,
+};
+</script>
+<template>
+ <gl-button-group class="js-blob-viewer-switcher ml-2">
+ <gl-button
+ v-gl-tooltip.hover
+ :aria-label="$options.SIMPLE_BLOB_VIEWER_TITLE"
+ :title="$options.SIMPLE_BLOB_VIEWER_TITLE"
+ :selected="isSimpleViewer"
+ :class="{ active: isSimpleViewer }"
+ @click="switchToViewer($options.SIMPLE_BLOB_VIEWER)"
+ >
+ <gl-icon name="code" :size="14" />
+ </gl-button>
+ <gl-button
+ v-gl-tooltip.hover
+ :aria-label="$options.RICH_BLOB_VIEWER_TITLE"
+ :title="$options.RICH_BLOB_VIEWER_TITLE"
+ :selected="isRichViewer"
+ :class="{ active: isRichViewer }"
+ @click="switchToViewer($options.RICH_BLOB_VIEWER)"
+ >
+ <gl-icon name="document" :size="14" />
+ </gl-button>
+ </gl-button-group>
+</template>
diff --git a/app/assets/javascripts/blob/components/constants.js b/app/assets/javascripts/blob/components/constants.js
new file mode 100644
index 00000000000..d3fed9e51e9
--- /dev/null
+++ b/app/assets/javascripts/blob/components/constants.js
@@ -0,0 +1,11 @@
+import { __ } from '~/locale';
+
+export const BTN_COPY_CONTENTS_TITLE = __('Copy file contents');
+export const BTN_RAW_TITLE = __('Open raw');
+export const BTN_DOWNLOAD_TITLE = __('Download');
+
+export const SIMPLE_BLOB_VIEWER = 'simple';
+export const SIMPLE_BLOB_VIEWER_TITLE = __('Display source');
+
+export const RICH_BLOB_VIEWER = 'rich';
+export const RICH_BLOB_VIEWER_TITLE = __('Display rendered file');
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js
index 2df7a84ead0..0fb02ca5965 100644
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ b/app/assets/javascripts/blob/file_template_mediator.js
@@ -117,11 +117,7 @@ export default class FileTemplateMediator {
selector.hide();
}
});
-
- if (this.editor.getValue() !== '') {
- this.setTypeSelectorToggleText(item.name);
- }
-
+ this.setTypeSelectorToggleText(item.name);
this.cacheToggleText();
}
diff --git a/app/assets/javascripts/blob/notebook/index.js b/app/assets/javascripts/blob/notebook/index.js
index 071022a9a75..35634d63e4a 100644
--- a/app/assets/javascripts/blob/notebook/index.js
+++ b/app/assets/javascripts/blob/notebook/index.js
@@ -75,10 +75,10 @@ export default () => {
class="text-center"
v-if="error">
<span v-if="loadError">
- An error occurred whilst loading the file. Please try again later.
+ An error occurred while loading the file. Please try again later.
</span>
<span v-else>
- An error occurred whilst parsing the file.
+ An error occurred while parsing the file.
</span>
</p>
</div>
diff --git a/app/assets/javascripts/blob/pdf/index.js b/app/assets/javascripts/blob/pdf/index.js
index 7d5f487c4ba..19778d07983 100644
--- a/app/assets/javascripts/blob/pdf/index.js
+++ b/app/assets/javascripts/blob/pdf/index.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import pdfLab from '../../pdf/index.vue';
+import { GlLoadingIcon } from '@gitlab/ui';
export default () => {
const el = document.getElementById('js-pdf-viewer');
@@ -8,6 +9,7 @@ export default () => {
el,
components: {
pdfLab,
+ GlLoadingIcon,
},
data() {
return {
@@ -32,11 +34,7 @@ export default () => {
<div
class="text-center loading"
v-if="loading && !error">
- <i
- class="fa fa-spinner fa-spin"
- aria-hidden="true"
- aria-label="PDF loading">
- </i>
+ <gl-loading-icon class="mt-5" size="lg"/>
</div>
<pdf-lab
v-if="!loadError"
@@ -47,10 +45,10 @@ export default () => {
class="text-center"
v-if="error">
<span v-if="loadError">
- An error occurred whilst loading the file. Please try again later.
+ An error occurred while loading the file. Please try again later.
</span>
<span v-else>
- An error occurred whilst decoding the file.
+ An error occurred while decoding the file.
</span>
</p>
</div>
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index ee889e0f7e0..4a64d9e04f2 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -181,6 +181,8 @@ export default {
boardsStore.startMoving(list, issue);
+ this.$root.$emit('bv::hide::tooltip');
+
sortableStart();
},
onAdd: e => {
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue
index 7f7510545c6..bdaed17fd09 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.vue
+++ b/app/assets/javascripts/boards/components/issue_card_inner.vue
@@ -162,6 +162,14 @@ export default {
<div class="d-flex board-card-header" dir="auto">
<h4 class="board-card-title append-bottom-0 prepend-top-0">
<icon
+ v-if="issue.blocked"
+ v-gl-tooltip
+ name="issue-block"
+ :title="__('Blocked issue')"
+ class="issue-blocked-icon append-right-4"
+ :aria-label="__('Blocked issue')"
+ />
+ <icon
v-if="issue.confidential"
v-gl-tooltip
name="eye-slash"
@@ -233,7 +241,7 @@ export default {
:key="assignee.id"
:link-href="assigneeUrl(assignee)"
:img-alt="avatarUrlTitle(assignee)"
- :img-src="assignee.avatar"
+ :img-src="assignee.avatar || assignee.avatar_url"
:img-size="24"
class="js-no-trigger"
tooltip-placement="bottom"
diff --git a/app/assets/javascripts/boards/components/issue_count.vue b/app/assets/javascripts/boards/components/issue_count.vue
index c50a3c1c0d3..d55f7151d7e 100644
--- a/app/assets/javascripts/boards/components/issue_count.vue
+++ b/app/assets/javascripts/boards/components/issue_count.vue
@@ -25,7 +25,7 @@ export default {
</script>
<template>
- <div class="issue-count">
+ <div class="issue-count text-nowrap">
<span class="js-issue-size" :class="{ 'text-danger': issuesExceedMax }">
{{ issuesSize }}
</span>
diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js
index 68ea28e68d9..f77f131c71a 100644
--- a/app/assets/javascripts/boards/mixins/sortable_default_options.js
+++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js
@@ -26,6 +26,7 @@ export function getBoardSortableDefaultOptions(obj) {
scrollSpeed: 20,
onStart: sortableStart,
onEnd: sortableEnd,
+ fallbackTolerance: 1,
});
Object.keys(obj).forEach(key => {
diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js
index 1cee9e5725a..044d96a9aec 100644
--- a/app/assets/javascripts/boards/models/issue.js
+++ b/app/assets/javascripts/boards/models/issue.js
@@ -37,6 +37,7 @@ class ListIssue {
this.project_id = obj.project_id;
this.timeEstimate = obj.time_estimate;
this.assignableLabelsEndpoint = obj.assignable_labels_endpoint;
+ this.blocked = obj.blocked;
if (obj.project) {
this.project = new IssueProject(obj.project);
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index b232fea0882..ff50b8ed7d1 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -83,27 +83,7 @@ class List {
}
save() {
- const entity = this.label || this.assignee || this.milestone;
- let entityType = '';
- if (this.label) {
- entityType = 'label_id';
- } else if (this.assignee) {
- entityType = 'assignee_id';
- } else if (IS_EE && this.milestone) {
- entityType = 'milestone_id';
- }
-
- return boardsStore
- .createList(entity.id, entityType)
- .then(res => res.data)
- .then(data => {
- this.id = data.id;
- this.type = data.list_type;
- this.position = data.position;
- this.label = data.label;
-
- return this.getIssues();
- });
+ return boardsStore.saveList(this);
}
destroy() {
@@ -181,50 +161,7 @@ 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);
- }
- }
+ boardsStore.addMultipleListIssues(this, issues, listFrom, newIndex);
}
addIssue(issue, listFrom, newIndex) {
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index 8b737d1dab0..e5ce8b70a4f 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-shadow */
+/* eslint-disable no-shadow, no-param-reassign */
/* global List */
import $ from 'jquery';
@@ -131,6 +131,53 @@ const boardsStore = {
listFrom.update();
},
+ addMultipleListIssues(list, issues, listFrom, newIndex) {
+ let moveBeforeId = null;
+ let moveAfterId = null;
+
+ const listHasIssues = issues.every(issue => list.findIssue(issue.id));
+
+ if (!listHasIssues) {
+ if (newIndex !== undefined) {
+ if (list.issues[newIndex - 1]) {
+ moveBeforeId = list.issues[newIndex - 1].id;
+ }
+
+ if (list.issues[newIndex]) {
+ moveAfterId = list.issues[newIndex].id;
+ }
+
+ list.issues.splice(newIndex, 0, ...issues);
+ } else {
+ list.issues.push(...issues);
+ }
+
+ if (list.label) {
+ issues.forEach(issue => issue.addLabel(list.label));
+ }
+
+ if (list.assignee) {
+ if (listFrom && listFrom.type === 'assignee') {
+ issues.forEach(issue => issue.removeAssignee(listFrom.assignee));
+ }
+ issues.forEach(issue => issue.addAssignee(list.assignee));
+ }
+
+ if (IS_EE && list.milestone) {
+ if (listFrom && listFrom.type === 'milestone') {
+ issues.forEach(issue => issue.removeMilestone(listFrom.milestone));
+ }
+ issues.forEach(issue => issue.addMilestone(list.milestone));
+ }
+
+ if (listFrom) {
+ list.issuesSize += issues.length;
+
+ list.updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId);
+ }
+ }
+ },
+
startMoving(list, issue) {
Object.assign(this.moving, { list, issue });
},
@@ -408,6 +455,29 @@ const boardsStore = {
return axios.delete(`${this.state.endpoints.listsEndpoint}/${id}`);
},
+ saveList(list) {
+ const entity = list.label || list.assignee || list.milestone;
+ let entityType = '';
+ if (list.label) {
+ entityType = 'label_id';
+ } else if (list.assignee) {
+ entityType = 'assignee_id';
+ } else if (IS_EE && list.milestone) {
+ entityType = 'milestone_id';
+ }
+
+ return this.createList(entity.id, entityType)
+ .then(res => res.data)
+ .then(data => {
+ list.id = data.id;
+ list.type = data.list_type;
+ list.position = data.position;
+ list.label = data.label;
+
+ return list.getIssues();
+ });
+ },
+
getIssuesForList(id, filter = {}) {
const data = { id };
Object.keys(filter).forEach(key => {
diff --git a/app/assets/javascripts/broadcast_notification.js b/app/assets/javascripts/broadcast_notification.js
new file mode 100644
index 00000000000..b124502506a
--- /dev/null
+++ b/app/assets/javascripts/broadcast_notification.js
@@ -0,0 +1,21 @@
+import Cookies from 'js-cookie';
+
+const handleOnDismiss = ({ currentTarget }) => {
+ currentTarget.removeEventListener('click', handleOnDismiss);
+ const {
+ dataset: { id },
+ } = currentTarget;
+
+ Cookies.set(`hide_broadcast_notification_message_${id}`, true);
+
+ const notification = document.querySelector(`.js-broadcast-notification-${id}`);
+ notification.parentNode.removeChild(notification);
+};
+
+export default () => {
+ const dismissButton = document.querySelector('.js-dismiss-current-broadcast-notification');
+
+ if (dismissButton) {
+ dismissButton.addEventListener('click', handleOnDismiss);
+ }
+};
diff --git a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js
index 0bba2a2e160..da33e092086 100644
--- a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js
+++ b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js
@@ -1,4 +1,4 @@
-import _ from 'underscore';
+import { escape as esc } from 'lodash';
import axios from '../lib/utils/axios_utils';
import { s__ } from '../locale';
import Flash from '../flash';
@@ -10,7 +10,7 @@ function generateErrorBoxContent(errors) {
const errorList = [].concat(errors).map(
errorString => `
<li>
- ${_.escape(errorString)}
+ ${esc(errorString)}
</li>
`,
);
diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue
index 7db9898396b..f8bf778b9e7 100644
--- a/app/assets/javascripts/clusters/components/application_row.vue
+++ b/app/assets/javascripts/clusters/components/application_row.vue
@@ -307,7 +307,7 @@ export default {
<a
v-if="titleLink"
:href="titleLink"
- target="blank"
+ target="_blank"
rel="noopener noreferrer"
class="js-cluster-application-title"
>{{ title }}</a
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
index 704515cf70c..fe2ad562ad5 100644
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -129,9 +129,6 @@ export default {
crossplaneInstalled() {
return this.applications.crossplane.status === APPLICATION_STATUS.INSTALLED;
},
- enableClusterApplicationElasticStack() {
- return gon.features && gon.features.enableClusterApplicationElasticStack;
- },
ingressModSecurityDescription() {
const escapedUrl = _.escape(this.ingressModSecurityHelpPath);
@@ -655,7 +652,6 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
</div>
</application-row>
<application-row
- v-if="enableClusterApplicationElasticStack"
id="elastic_stack"
:logo-url="elasticStackLogo"
:title="applications.elastic_stack.title"
diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js
index 26456fb28db..939c396e1b9 100644
--- a/app/assets/javascripts/clusters/stores/clusters_store.js
+++ b/app/assets/javascripts/clusters/stores/clusters_store.js
@@ -257,6 +257,7 @@ export default class ClusterStore {
name: environment.name,
project: environment.project,
environmentPath: environment.environment_path,
+ logsPath: environment.logs_path,
lastDeployment: environment.last_deployment,
rolloutStatus: {
status: environment.rollout_status ? environment.rollout_status.status : null,
diff --git a/app/assets/javascripts/code_navigation/components/app.vue b/app/assets/javascripts/code_navigation/components/app.vue
new file mode 100644
index 00000000000..0e5f1f0485d
--- /dev/null
+++ b/app/assets/javascripts/code_navigation/components/app.vue
@@ -0,0 +1,43 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import Popover from './popover.vue';
+
+export default {
+ components: {
+ Popover,
+ },
+ computed: {
+ ...mapState(['currentDefinition', 'currentDefinitionPosition']),
+ },
+ mounted() {
+ this.blobViewer = document.querySelector('.blob-viewer');
+
+ this.addGlobalEventListeners();
+ this.fetchData();
+ },
+ beforeDestroy() {
+ this.removeGlobalEventListeners();
+ },
+ methods: {
+ ...mapActions(['fetchData', 'showDefinition']),
+ addGlobalEventListeners() {
+ if (this.blobViewer) {
+ this.blobViewer.addEventListener('click', this.showDefinition);
+ }
+ },
+ removeGlobalEventListeners() {
+ if (this.blobViewer) {
+ this.blobViewer.removeEventListener('click', this.showDefinition);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <popover
+ v-if="currentDefinition"
+ :position="currentDefinitionPosition"
+ :data="currentDefinition"
+ />
+</template>
diff --git a/app/assets/javascripts/code_navigation/components/popover.vue b/app/assets/javascripts/code_navigation/components/popover.vue
new file mode 100644
index 00000000000..d5bbe430fcd
--- /dev/null
+++ b/app/assets/javascripts/code_navigation/components/popover.vue
@@ -0,0 +1,76 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlButton,
+ },
+ props: {
+ position: {
+ type: Object,
+ required: true,
+ },
+ data: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ offsetLeft: 0,
+ };
+ },
+ computed: {
+ positionStyles() {
+ return {
+ left: `${this.position.x - this.offsetLeft}px`,
+ top: `${this.position.y + this.position.height}px`,
+ };
+ },
+ },
+ watch: {
+ position: {
+ handler() {
+ this.$nextTick(() => this.updateOffsetLeft());
+ },
+ deep: true,
+ immediate: true,
+ },
+ },
+ methods: {
+ updateOffsetLeft() {
+ this.offsetLeft = Math.max(
+ 0,
+ this.$el.offsetLeft + this.$el.offsetWidth - window.innerWidth + 20,
+ );
+ },
+ },
+ colorScheme: gon?.user_color_scheme,
+};
+</script>
+
+<template>
+ <div
+ :style="positionStyles"
+ class="popover code-navigation-popover popover-font-size-normal gl-popover bs-popover-bottom show"
+ >
+ <div :style="{ left: `${offsetLeft}px` }" class="arrow"></div>
+ <div v-for="(hover, index) in data.hover" :key="index" class="border-bottom">
+ <pre
+ v-if="hover.language"
+ ref="code-output"
+ :class="$options.colorScheme"
+ class="border-0 bg-transparent m-0 code highlight"
+ v-html="hover.value"
+ ></pre>
+ <p v-else ref="doc-output" class="p-3 m-0">
+ {{ hover.value }}
+ </p>
+ </div>
+ <div v-if="data.definition_url" class="popover-body">
+ <gl-button :href="data.definition_url" target="_blank" class="w-100" variant="default">
+ {{ __('Go to definition') }}
+ </gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/code_navigation/index.js b/app/assets/javascripts/code_navigation/index.js
new file mode 100644
index 00000000000..2222c986dfe
--- /dev/null
+++ b/app/assets/javascripts/code_navigation/index.js
@@ -0,0 +1,20 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import store from './store';
+import App from './components/app.vue';
+
+Vue.use(Vuex);
+
+export default () => {
+ const el = document.getElementById('js-code-navigation');
+
+ store.dispatch('setInitialData', el.dataset);
+
+ return new Vue({
+ el,
+ store,
+ render(h) {
+ return h(App);
+ },
+ });
+};
diff --git a/app/assets/javascripts/code_navigation/store/actions.js b/app/assets/javascripts/code_navigation/store/actions.js
new file mode 100644
index 00000000000..2c52074e362
--- /dev/null
+++ b/app/assets/javascripts/code_navigation/store/actions.js
@@ -0,0 +1,59 @@
+import api from '~/api';
+import * as types from './mutation_types';
+import { getCurrentHoverElement, setCurrentHoverElement, addInteractionClass } from '../utils';
+
+export default {
+ setInitialData({ commit }, data) {
+ commit(types.SET_INITIAL_DATA, data);
+ },
+ requestDataError({ commit }) {
+ commit(types.REQUEST_DATA_ERROR);
+ },
+ fetchData({ commit, dispatch, state }) {
+ commit(types.REQUEST_DATA);
+
+ api
+ .lsifData(state.projectPath, state.commitId, state.blobPath)
+ .then(({ data }) => {
+ const normalizedData = data.reduce((acc, d) => {
+ if (d.hover) {
+ acc[`${d.start_line}:${d.start_char}`] = d;
+ addInteractionClass(d);
+ }
+ return acc;
+ }, {});
+
+ commit(types.REQUEST_DATA_SUCCESS, normalizedData);
+ })
+ .catch(() => dispatch('requestDataError'));
+ },
+ showDefinition({ commit, state }, { target: el }) {
+ let definition;
+ let position;
+
+ if (!state.data) return;
+
+ const isCurrentElementPopoverOpen = el.classList.contains('hll');
+
+ if (getCurrentHoverElement()) {
+ getCurrentHoverElement().classList.remove('hll');
+ }
+
+ if (el.classList.contains('js-code-navigation') && !isCurrentElementPopoverOpen) {
+ const { lineIndex, charIndex } = el.dataset;
+
+ position = {
+ x: el.offsetLeft,
+ y: el.offsetTop,
+ height: el.offsetHeight,
+ };
+ definition = state.data[`${lineIndex}:${charIndex}`];
+
+ el.classList.add('hll');
+
+ setCurrentHoverElement(el);
+ }
+
+ commit(types.SET_CURRENT_DEFINITION, { definition, position });
+ },
+};
diff --git a/app/assets/javascripts/code_navigation/store/index.js b/app/assets/javascripts/code_navigation/store/index.js
new file mode 100644
index 00000000000..fe48f3ac7f5
--- /dev/null
+++ b/app/assets/javascripts/code_navigation/store/index.js
@@ -0,0 +1,10 @@
+import Vuex from 'vuex';
+import createState from './state';
+import actions from './actions';
+import mutations from './mutations';
+
+export default new Vuex.Store({
+ actions,
+ mutations,
+ state: createState(),
+});
diff --git a/app/assets/javascripts/code_navigation/store/mutation_types.js b/app/assets/javascripts/code_navigation/store/mutation_types.js
new file mode 100644
index 00000000000..29a2897a6fd
--- /dev/null
+++ b/app/assets/javascripts/code_navigation/store/mutation_types.js
@@ -0,0 +1,5 @@
+export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
+export const REQUEST_DATA = 'REQUEST_DATA';
+export const REQUEST_DATA_SUCCESS = 'REQUEST_DATA_SUCCESS';
+export const REQUEST_DATA_ERROR = 'REQUEST_DATA_ERROR';
+export const SET_CURRENT_DEFINITION = 'SET_CURRENT_DEFINITION';
diff --git a/app/assets/javascripts/code_navigation/store/mutations.js b/app/assets/javascripts/code_navigation/store/mutations.js
new file mode 100644
index 00000000000..bb833a5adbc
--- /dev/null
+++ b/app/assets/javascripts/code_navigation/store/mutations.js
@@ -0,0 +1,23 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_INITIAL_DATA](state, { projectPath, commitId, blobPath }) {
+ state.projectPath = projectPath;
+ state.commitId = commitId;
+ state.blobPath = blobPath;
+ },
+ [types.REQUEST_DATA](state) {
+ state.loading = true;
+ },
+ [types.REQUEST_DATA_SUCCESS](state, data) {
+ state.loading = false;
+ state.data = data;
+ },
+ [types.REQUEST_DATA_ERROR](state) {
+ state.loading = false;
+ },
+ [types.SET_CURRENT_DEFINITION](state, { definition, position }) {
+ state.currentDefinition = definition;
+ state.currentDefinitionPosition = position;
+ },
+};
diff --git a/app/assets/javascripts/code_navigation/store/state.js b/app/assets/javascripts/code_navigation/store/state.js
new file mode 100644
index 00000000000..a7b3b289db4
--- /dev/null
+++ b/app/assets/javascripts/code_navigation/store/state.js
@@ -0,0 +1,9 @@
+export default () => ({
+ projectPath: null,
+ commitId: null,
+ blobPath: null,
+ loading: false,
+ data: null,
+ currentDefinition: null,
+ currentDefinitionPosition: null,
+});
diff --git a/app/assets/javascripts/code_navigation/utils/index.js b/app/assets/javascripts/code_navigation/utils/index.js
new file mode 100644
index 00000000000..2dee0de6501
--- /dev/null
+++ b/app/assets/javascripts/code_navigation/utils/index.js
@@ -0,0 +1,20 @@
+export const cachedData = new Map();
+
+export const getCurrentHoverElement = () => cachedData.get('current');
+export const setCurrentHoverElement = el => cachedData.set('current', el);
+
+export const addInteractionClass = d => {
+ let charCount = 0;
+ const line = document.getElementById(`LC${d.start_line + 1}`);
+ const el = [...line.childNodes].find(({ textContent }) => {
+ if (charCount === d.start_char) return true;
+ charCount += textContent.length;
+ return false;
+ });
+
+ if (el) {
+ el.setAttribute('data-char-index', d.start_char);
+ el.setAttribute('data-line-index', d.start_line);
+ el.classList.add('cursor-pointer', 'code-navigation', 'js-code-navigation');
+ }
+};
diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js
index 2f268419bff..25640f71af2 100644
--- a/app/assets/javascripts/commons/jquery.js
+++ b/app/assets/javascripts/commons/jquery.js
@@ -4,6 +4,6 @@ import 'jquery';
import 'jquery-ujs';
import 'vendor/jquery.endless-scroll';
import 'jquery.caret'; // must be imported before at.js
-import 'at.js';
+import '@gitlab/at.js';
import 'vendor/jquery.scrollTo';
import 'jquery.waitforimages';
diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js
index dd300b8a307..5e04b0573d2 100644
--- a/app/assets/javascripts/commons/polyfills.js
+++ b/app/assets/javascripts/commons/polyfills.js
@@ -1,25 +1,3 @@
-// ECMAScript polyfills
-import 'core-js/es/array/fill';
-import 'core-js/es/array/find';
-import 'core-js/es/array/find-index';
-import 'core-js/es/array/from';
-import 'core-js/es/array/includes';
-import 'core-js/es/number/is-integer';
-import 'core-js/es/object/assign';
-import 'core-js/es/object/values';
-import 'core-js/es/object/entries';
-import 'core-js/es/promise';
-import 'core-js/es/promise/finally';
-import 'core-js/es/string/code-point-at';
-import 'core-js/es/string/from-code-point';
-import 'core-js/es/string/includes';
-import 'core-js/es/string/starts-with';
-import 'core-js/es/string/ends-with';
-import 'core-js/es/symbol';
-import 'core-js/es/map';
-import 'core-js/es/weak-map';
-import 'core-js/modules/web.url';
-
// Browser polyfills
import 'formdata-polyfill';
import './polyfills/custom_event';
diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue
index fb7000ee9ed..8dbf0a68c43 100644
--- a/app/assets/javascripts/contributors/components/contributors.vue
+++ b/app/assets/javascripts/contributors/components/contributors.vue
@@ -1,5 +1,5 @@
<script>
-import _ from 'underscore';
+import { debounce, uniq } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
@@ -7,11 +7,13 @@ import { __ } from '~/locale';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import { getDatesInRange } from '~/lib/utils/datetime_utility';
import { xAxisLabelFormatter, dateFormatter } from '../utils';
+import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
export default {
components: {
GlAreaChart,
GlLoadingIcon,
+ ResizableChartContainer,
},
props: {
endpoint: {
@@ -118,7 +120,7 @@ export default {
return this.xAxisRange[this.xAxisRange.length - 1];
},
charts() {
- return _.uniq(this.individualCharts);
+ return uniq(this.individualCharts);
},
},
mounted() {
@@ -169,7 +171,7 @@ export default {
});
})
.catch(() => {});
- this.masterChart.on('datazoom', _.debounce(this.setIndividualChartsZoom, 200));
+ this.masterChart.on('datazoom', debounce(this.setIndividualChartsZoom, 200));
},
onIndividualChartCreated(chart) {
this.individualCharts.push(chart);
@@ -201,25 +203,35 @@ export default {
<div v-else-if="showChart" class="contributors-charts">
<h4>{{ __('Commits to') }} {{ branch }}</h4>
<span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span>
- <div>
+ <resizable-chart-container>
<gl-area-chart
+ slot-scope="{ width }"
+ :width="width"
:data="masterChartData"
:option="masterChartOptions"
:height="masterChartHeight"
@created="onMasterChartCreated"
/>
- </div>
+ </resizable-chart-container>
<div class="row">
- <div v-for="contributor in individualChartsData" :key="contributor.name" class="col-6">
+ <div
+ v-for="(contributor, index) in individualChartsData"
+ :key="index"
+ class="col-lg-6 col-12"
+ >
<h4>{{ contributor.name }}</h4>
<p>{{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }})</p>
- <gl-area-chart
- :data="contributor.dates"
- :option="individualChartOptions"
- :height="individualChartHeight"
- @created="onIndividualChartCreated"
- />
+ <resizable-chart-container>
+ <gl-area-chart
+ slot-scope="{ width }"
+ :width="width"
+ :data="contributor.dates"
+ :option="individualChartOptions"
+ :height="individualChartHeight"
+ @created="onIndividualChartCreated"
+ />
+ </resizable-chart-container>
</div>
</div>
</div>
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 3d389cf3db5..59c5586edcd 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
@@ -306,9 +306,9 @@ export default {
</script>
<template>
<form name="eks-cluster-configuration-form">
- <h2>
+ <h4>
{{ s__('ClusterIntegration|Enter the details for your Amazon EKS Kubernetes cluster') }}
- </h2>
+ </h4>
<div class="mb-3" v-html="kubernetesIntegrationHelpText"></div>
<div class="form-group">
<label class="label-bold" for="eks-cluster-name">{{
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue
index 49a5d4657af..0cfe47dafaf 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue
@@ -83,7 +83,7 @@ export default {
</script>
<template>
<form name="service-credentials-form">
- <h2>{{ s__('ClusterIntegration|Authenticate with Amazon Web Services') }}</h2>
+ <h4>{{ s__('ClusterIntegration|Authenticate with Amazon Web Services') }}</h4>
<p>
{{
s__(
diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue
index a9d9f0224e3..d6deda25752 100644
--- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue
@@ -16,9 +16,6 @@ export default {
]),
...mapState({ items: 'machineTypes' }),
...mapGetters(['hasZone', 'hasMachineType']),
- allDropdownsSelected() {
- return this.projectHasBillingEnabled && this.hasZone && this.hasMachineType;
- },
isDisabled() {
return (
this.isLoading ||
@@ -65,22 +62,10 @@ export default {
.catch(this.fetchFailureHandler);
}
},
- selectedMachineType() {
- this.enableSubmit();
- },
},
methods: {
...mapActions(['fetchMachineTypes']),
...mapActions({ setItem: 'setMachineType' }),
- enableSubmit() {
- if (this.allDropdownsSelected) {
- const submitButtonEl = document.querySelector('.js-gke-cluster-creation-submit');
-
- if (submitButtonEl) {
- submitButtonEl.removeAttribute('disabled');
- }
- }
- },
},
};
</script>
diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_submit_button.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_submit_button.vue
new file mode 100644
index 00000000000..a7e08a5e97f
--- /dev/null
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_submit_button.vue
@@ -0,0 +1,18 @@
+<script>
+import { mapGetters } from 'vuex';
+
+export default {
+ computed: {
+ ...mapGetters(['hasValidData']),
+ },
+};
+</script>
+<template>
+ <button
+ type="submit"
+ :disabled="!hasValidData"
+ class="js-gke-cluster-creation-submit btn btn-success"
+ >
+ {{ s__('ClusterIntegration|Create Kubernetes cluster') }}
+ </button>
+</template>
diff --git a/app/assets/javascripts/create_cluster/gke_cluster/index.js b/app/assets/javascripts/create_cluster/gke_cluster/index.js
index 729b9404b64..5a64eb09cad 100644
--- a/app/assets/javascripts/create_cluster/gke_cluster/index.js
+++ b/app/assets/javascripts/create_cluster/gke_cluster/index.js
@@ -4,6 +4,10 @@ import Flash from '~/flash';
import GkeProjectIdDropdown from './components/gke_project_id_dropdown.vue';
import GkeZoneDropdown from './components/gke_zone_dropdown.vue';
import GkeMachineTypeDropdown from './components/gke_machine_type_dropdown.vue';
+import GkeSubmitButton from './components/gke_submit_button.vue';
+
+import store from './store';
+
import * as CONSTANTS from './constants';
const mountComponent = (entryPoint, component, componentName, extraProps = {}) => {
@@ -14,6 +18,7 @@ const mountComponent = (entryPoint, component, componentName, extraProps = {}) =
return new Vue({
el,
+ store,
components: {
[componentName]: component,
},
@@ -50,6 +55,10 @@ const mountGkeMachineTypeDropdown = () => {
);
};
+const mountGkeSubmitButton = () => {
+ mountComponent('.js-gke-cluster-creation-submit-container', GkeSubmitButton, 'gke-submit-button');
+};
+
const gkeDropdownErrorHandler = () => {
Flash(CONSTANTS.GCP_API_ERROR);
};
@@ -72,6 +81,7 @@ const initializeGapiClient = () => {
mountGkeProjectIdDropdown();
mountGkeZoneDropdown();
mountGkeMachineTypeDropdown();
+ mountGkeSubmitButton();
})
.catch(gkeDropdownErrorHandler);
};
diff --git a/app/assets/javascripts/create_cluster/gke_cluster/store/getters.js b/app/assets/javascripts/create_cluster/gke_cluster/store/getters.js
index f9e2e2f74fb..4d4cd223832 100644
--- a/app/assets/javascripts/create_cluster/gke_cluster/store/getters.js
+++ b/app/assets/javascripts/create_cluster/gke_cluster/store/getters.js
@@ -1,3 +1,5 @@
export const hasProject = state => Boolean(state.selectedProject.projectId);
export const hasZone = state => Boolean(state.selectedZone);
export const hasMachineType = state => Boolean(state.selectedMachineType);
+export const hasValidData = (state, getters) =>
+ Boolean(state.projectHasBillingEnabled) && getters.hasZone && getters.hasMachineType;
diff --git a/app/assets/javascripts/cycle_analytics/components/banner.vue b/app/assets/javascripts/cycle_analytics/components/banner.vue
index ae8c430dcd6..0db9d2dbcf9 100644
--- a/app/assets/javascripts/cycle_analytics/components/banner.vue
+++ b/app/assets/javascripts/cycle_analytics/components/banner.vue
@@ -27,7 +27,7 @@ export default {
<template>
<div class="landing content-block">
<button
- :aria-label="__('Dismiss Cycle Analytics introduction box')"
+ :aria-label="__('Dismiss Value Stream Analytics introduction box')"
class="js-ca-dismiss-button dismiss-button"
type="button"
@click="dismissOverviewDialog"
@@ -36,10 +36,10 @@ export default {
</button>
<div class="svg-container" v-html="iconCycleAnalyticsSplash"></div>
<div class="inner-content">
- <h4>{{ __('Introducing Cycle Analytics') }}</h4>
+ <h4>{{ __('Introducing Value Stream Analytics') }}</h4>
<p>
{{
- __(`Cycle Analytics gives an overview
+ __(`Value Stream Analytics gives an overview
of how much time it takes to go from idea to production in your project.`)
}}
</p>
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
index 1074ce0e744..6d2b11e39d3 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
@@ -1,7 +1,7 @@
import $ from 'jquery';
import Vue from 'vue';
import Cookies from 'js-cookie';
-import { GlEmptyState } from '@gitlab/ui';
+import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import filterMixins from 'ee_else_ce/analytics/cycle_analytics/mixins/filter_mixins';
import Flash from '../flash';
import { __ } from '~/locale';
@@ -28,6 +28,7 @@ export default () => {
name: 'CycleAnalytics',
components: {
GlEmptyState,
+ GlLoadingIcon,
banner,
'stage-issue-component': stageComponent,
'stage-plan-component': stageComponent,
@@ -71,7 +72,7 @@ export default () => {
},
created() {
// Conditional check placed here to prevent this method from being called on the
- // new Cycle Analytics page (i.e. the new page will be initialized blank and only
+ // new Value Stream Analytics page (i.e. the new page will be initialized blank and only
// after a group is selected the cycle analyitcs data will be fetched). Once the
// old (current) page has been removed this entire created method as well as the
// variable itself can be completely removed.
@@ -81,7 +82,7 @@ export default () => {
methods: {
handleError() {
this.store.setErrorState(true);
- return new Flash(__('There was an error while fetching cycle analytics data.'));
+ return new Flash(__('There was an error while fetching value stream analytics data.'));
},
initDropdown() {
const $dropdown = $('.js-ca-dropdown');
diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
index 092c69a01d3..fa5f8ea4005 100644
--- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
+++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
@@ -24,9 +24,9 @@ const JumpToDiscussion = Vue.extend({
computed: {
buttonText() {
if (this.discussionId) {
- return __('Jump to next unresolved discussion');
+ return __('Jump to next unresolved thread');
} else {
- return __('Jump to first unresolved discussion');
+ return __('Jump to first unresolved thread');
}
},
allResolved() {
diff --git a/app/assets/javascripts/diff_notes/models/discussion.js b/app/assets/javascripts/diff_notes/models/discussion.js
index daf61e5d467..97296a40d6e 100644
--- a/app/assets/javascripts/diff_notes/models/discussion.js
+++ b/app/assets/javascripts/diff_notes/models/discussion.js
@@ -1,4 +1,4 @@
-/* eslint-disable camelcase, guard-for-in, no-restricted-syntax */
+/* eslint-disable guard-for-in, no-restricted-syntax */
/* global NoteModel */
import $ from 'jquery';
@@ -40,13 +40,13 @@ class DiscussionModel {
return true;
}
- resolveAllNotes(resolved_by) {
+ resolveAllNotes(resolvedBy) {
for (const noteId in this.notes) {
const note = this.notes[noteId];
if (!note.resolved) {
note.resolved = true;
- note.resolved_by = resolved_by;
+ note.resolved_by = resolvedBy;
}
}
}
diff --git a/app/assets/javascripts/diff_notes/stores/comments.js b/app/assets/javascripts/diff_notes/stores/comments.js
index 69a972f644d..9bde18c4edf 100644
--- a/app/assets/javascripts/diff_notes/stores/comments.js
+++ b/app/assets/javascripts/diff_notes/stores/comments.js
@@ -1,4 +1,4 @@
-/* eslint-disable camelcase, no-restricted-syntax, guard-for-in */
+/* eslint-disable no-restricted-syntax, guard-for-in */
/* global DiscussionModel */
import Vue from 'vue';
@@ -26,11 +26,11 @@ window.CommentsStore = {
discussion.createNote(noteObj);
},
- update(discussionId, noteId, resolved, resolved_by) {
+ update(discussionId, noteId, resolved, resolvedBy) {
const discussion = this.state[discussionId];
const note = discussion.getNote(noteId);
note.resolved = resolved;
- note.resolved_by = resolved_by;
+ note.resolved_by = resolvedBy;
},
delete(discussionId, noteId) {
const discussion = this.state[discussionId];
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 463d1427805..f9d3d31e152 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -354,7 +354,7 @@ export default {
<template>
<div v-show="shouldShow">
- <div v-if="isLoading" class="loading"><gl-loading-icon /></div>
+ <div v-if="isLoading" class="loading"><gl-loading-icon size="lg" /></div>
<div v-else id="diffs" :class="{ active: shouldShow }" class="diffs tab-pane">
<compare-versions
:merge-request-diffs="mergeRequestDiffs"
@@ -374,7 +374,7 @@ export default {
<div
:data-can-create-note="getNoteableData.current_user.can_create_note"
- class="files d-flex prepend-top-default"
+ class="files d-flex"
>
<div
v-show="showTreeList"
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index 24542126b07..3a2146147cc 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -1,7 +1,6 @@
<script>
-/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { mapActions, mapGetters, mapState } from 'vuex';
-import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui';
+import { GlTooltipDirective, GlLink, GlButton, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
import { polyfillSticky } from '~/lib/utils/sticky';
import Icon from '~/vue_shared/components/icon.vue';
@@ -16,6 +15,7 @@ export default {
Icon,
GlLink,
GlButton,
+ GlSprintf,
SettingsDropdown,
DiffStats,
},
@@ -63,9 +63,6 @@ export default {
showDropdowns() {
return !this.commit && this.mergeRequestDiffs.length;
},
- fileTreeIcon() {
- return this.showTreeList ? 'collapse-left' : 'expand-left';
- },
toggleFileBrowserTitle() {
return this.showTreeList ? __('Hide file browser') : __('Show file browser');
},
@@ -91,7 +88,7 @@ export default {
</script>
<template>
- <div class="mr-version-controls border-top border-bottom">
+ <div class="mr-version-controls border-top">
<div
class="mr-version-menus-container content-block"
:class="{
@@ -108,25 +105,31 @@ export default {
:title="toggleFileBrowserTitle"
@click="toggleShowTreeList"
>
- <icon :name="fileTreeIcon" />
+ <icon name="file-tree" />
</button>
- <div v-if="showDropdowns" class="d-flex align-items-center compare-versions-container">
- Changes between
- <compare-versions-dropdown
- :other-versions="mergeRequestDiffs"
- :merge-request-version="mergeRequestDiff"
- :show-commit-count="true"
- class="mr-version-dropdown"
- />
- and
- <compare-versions-dropdown
- :other-versions="comparableDiffs"
- :base-version-path="baseVersionPath"
- :start-version="startVersion"
- :target-branch="targetBranch"
- class="mr-version-compare-dropdown"
- />
- </div>
+ <gl-sprintf
+ v-if="showDropdowns"
+ class="d-flex align-items-center compare-versions-container"
+ :message="s__('MergeRequest|Compare %{source} and %{target}')"
+ >
+ <template #source>
+ <compare-versions-dropdown
+ :other-versions="mergeRequestDiffs"
+ :merge-request-version="mergeRequestDiff"
+ :show-commit-count="true"
+ class="mr-version-dropdown"
+ />
+ </template>
+ <template #target>
+ <compare-versions-dropdown
+ :other-versions="comparableDiffs"
+ :base-version-path="baseVersionPath"
+ :start-version="startVersion"
+ :target-branch="targetBranch"
+ class="mr-version-compare-dropdown"
+ />
+ </template>
+ </gl-sprintf>
<div v-else-if="commit">
{{ __('Viewing commit') }}
<gl-link :href="commit.commit_url" class="monospace">{{ commit.short_id }}</gl-link>
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 5d27c6eb865..731c53a7339 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -210,6 +210,9 @@ export default {
:text="diffFile.file_path"
:gfm="gfmCopyText"
css-class="btn-default btn-transparent btn-clipboard"
+ data-track-event="click_copy_file_button"
+ data-track-label="diff_copy_file_path_button"
+ data-track-property="diff_copy_file"
/>
<small v-if="isModeChanged" ref="fileMode" class="mr-1">
@@ -221,7 +224,7 @@ export default {
<div
v-if="!diffFile.submodule && addMergeRequestButtons"
- class="file-actions d-none d-sm-block"
+ class="file-actions d-none d-sm-flex align-items-center flex-wrap"
>
<diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" />
<div class="btn-group" role="group">
@@ -233,6 +236,9 @@ export default {
:class="{ active: diffHasExpandedDiscussions(diffFile) }"
class="js-btn-vue-toggle-comments btn"
data-qa-selector="toggle_comments_button"
+ data-track-event="click_toggle_comments_button"
+ data-track-label="diff_toggle_comments_button"
+ data-track-property="diff_toggle_comments"
type="button"
@click="toggleFileDiscussionWrappers(diffFile)"
>
@@ -245,6 +251,9 @@ export default {
:can-current-user-fork="canCurrentUserFork"
:edit-path="diffFile.edit_path"
:can-modify-blob="diffFile.can_modify_blob"
+ data-track-event="click_toggle_edit_button"
+ data-track-label="diff_toggle_edit_button"
+ data-track-property="diff_toggle_edit"
@showForkMessage="showForkMessage"
/>
</template>
@@ -263,6 +272,9 @@ export default {
v-gl-tooltip.hover
:title="expandDiffToFullFileTitle"
class="expand-file"
+ data-track-event="click_toggle_view_full_button"
+ data-track-label="diff_toggle_view_full_button"
+ data-track-property="diff_toggle_view_full"
@click="toggleFullDiff(diffFile.file_path)"
>
<gl-loading-icon v-if="diffFile.isLoadingFullFile" color="dark" inline />
@@ -273,8 +285,11 @@ export default {
ref="viewButton"
v-gl-tooltip.hover
:href="diffFile.view_path"
- target="blank"
+ target="_blank"
class="view-file"
+ data-track-event="click_toggle_view_sha_button"
+ data-track-label="diff_toggle_view_sha_button"
+ data-track-property="diff_toggle_view_sha"
:title="viewFileButtonText"
>
<icon name="doc-text" />
@@ -288,6 +303,9 @@ export default {
:title="`View on ${diffFile.formatted_external_url}`"
target="_blank"
rel="noopener noreferrer"
+ data-track-event="click_toggle_external_button"
+ data-track-label="diff_toggle_external_button"
+ data-track-property="diff_toggle_external"
class="btn btn-file-option"
>
<icon name="external-link" />
diff --git a/app/assets/javascripts/diffs/components/diff_file_row.vue b/app/assets/javascripts/diffs/components/diff_file_row.vue
new file mode 100644
index 00000000000..15e63a1c9ca
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_file_row.vue
@@ -0,0 +1,40 @@
+<script>
+/**
+ * This component is an iterative step towards refactoring and simplifying `vue_shared/components/file_row.vue`
+ * https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23720
+ */
+import FileRow from '~/vue_shared/components/file_row.vue';
+import FileRowStats from './file_row_stats.vue';
+import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue';
+
+export default {
+ name: 'DiffFileRow',
+ components: {
+ FileRow,
+ FileRowStats,
+ ChangedFileIcon,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ hideFileStats: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ showFileRowStats() {
+ return !this.hideFileStats && this.file.type === 'blob';
+ },
+ },
+};
+</script>
+
+<template>
+ <file-row :file="file" v-bind="$attrs" v-on="$listeners">
+ <file-row-stats v-if="showFileRowStats" :file="file" class="mr-1" />
+ <changed-file-icon :file="file" :size="16" />
+ </file-row>
+</template>
diff --git a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
deleted file mode 100644
index 34aa15856d2..00000000000
--- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
+++ /dev/null
@@ -1,147 +0,0 @@
-<script>
-import { mapState, mapGetters, mapActions } from 'vuex';
-import Icon from '~/vue_shared/components/icon.vue';
-import DiffGutterAvatars from './diff_gutter_avatars.vue';
-import { LINE_POSITION_RIGHT } from '../constants';
-
-export default {
- components: {
- DiffGutterAvatars,
- Icon,
- },
- props: {
- line: {
- type: Object,
- required: true,
- },
- fileHash: {
- type: String,
- required: true,
- },
- contextLinesPath: {
- type: String,
- required: true,
- },
- lineNumber: {
- type: Number,
- required: false,
- default: 0,
- },
- linePosition: {
- type: String,
- required: false,
- default: '',
- },
- showCommentButton: {
- type: Boolean,
- required: false,
- default: false,
- },
- isBottom: {
- type: Boolean,
- required: false,
- default: false,
- },
- isMatchLine: {
- type: Boolean,
- required: false,
- default: false,
- },
- isMetaLine: {
- type: Boolean,
- required: false,
- default: false,
- },
- isContextLine: {
- type: Boolean,
- required: false,
- default: false,
- },
- isHover: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- computed: {
- ...mapState({
- diffViewType: state => state.diffs.diffViewType,
- diffFiles: state => state.diffs.diffFiles,
- }),
- ...mapGetters(['isLoggedIn']),
- lineCode() {
- return (
- this.line.line_code ||
- (this.line.left && this.line.left.line_code) ||
- (this.line.right && this.line.right.line_code)
- );
- },
- lineHref() {
- return `#${this.line.line_code || ''}`;
- },
- shouldShowCommentButton() {
- return (
- this.isHover &&
- !this.isMatchLine &&
- !this.isContextLine &&
- !this.isMetaLine &&
- !this.hasDiscussions
- );
- },
- hasDiscussions() {
- return this.line.discussions && this.line.discussions.length > 0;
- },
- shouldShowAvatarsOnGutter() {
- if (!this.line.type && this.linePosition === LINE_POSITION_RIGHT) {
- return false;
- }
- return this.showCommentButton && this.hasDiscussions;
- },
- shouldRenderCommentButton() {
- return this.isLoggedIn && this.showCommentButton;
- },
- },
- methods: {
- ...mapActions('diffs', [
- 'loadMoreLines',
- 'showCommentForm',
- 'setHighlightedRow',
- 'toggleLineDiscussions',
- 'toggleLineDiscussionWrappers',
- ]),
- handleCommentButton() {
- this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash });
- },
- },
-};
-</script>
-
-<template>
- <div>
- <button
- v-if="shouldRenderCommentButton"
- v-show="shouldShowCommentButton"
- type="button"
- class="add-diff-note js-add-diff-note-button qa-diff-comment"
- title="Add a comment to this line"
- @click="handleCommentButton"
- >
- <icon :size="12" name="comment" />
- </button>
- <a
- v-if="lineNumber"
- :data-linenumber="lineNumber"
- :href="lineHref"
- @click="setHighlightedRow(lineCode)"
- >
- </a>
- <diff-gutter-avatars
- v-if="shouldShowAvatarsOnGutter"
- :discussions="line.discussions"
- :discussions-expanded="line.discussionsExpanded"
- @toggleLineDiscussions="
- toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded })
- "
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue
index 2e5855380af..9d362ceb429 100644
--- a/app/assets/javascripts/diffs/components/diff_stats.vue
+++ b/app/assets/javascripts/diffs/components/diff_stats.vue
@@ -1,6 +1,7 @@
<script>
import Icon from '~/vue_shared/components/icon.vue';
import { n__ } from '~/locale';
+import { isNumber } from 'underscore';
export default {
components: { Icon },
@@ -21,11 +22,14 @@ export default {
},
computed: {
filesText() {
- return n__('File', 'Files', this.diffFilesLength);
+ return n__('file', 'files', this.diffFilesLength);
},
isCompareVersionsHeader() {
return Boolean(this.diffFilesLength);
},
+ hasDiffFiles() {
+ return isNumber(this.diffFilesLength) && this.diffFilesLength >= 0;
+ },
},
};
</script>
@@ -38,15 +42,23 @@ export default {
'd-inline-flex': !isCompareVersionsHeader,
}"
>
- <div v-if="diffFilesLength !== null" class="diff-stats-group">
+ <div v-if="hasDiffFiles" class="diff-stats-group">
<icon name="doc-code" class="diff-stats-icon text-secondary" />
- <strong>{{ diffFilesLength }} {{ filesText }}</strong>
+ <span class="text-secondary bold">{{ diffFilesLength }} {{ filesText }}</span>
</div>
- <div class="diff-stats-group cgreen">
- <icon name="file-addition" class="diff-stats-icon" /> <strong>{{ addedLines }}</strong>
+ <div
+ class="diff-stats-group cgreen d-flex align-items-center"
+ :class="{ bold: isCompareVersionsHeader }"
+ >
+ <span>+</span>
+ <span class="js-file-addition-line">{{ addedLines }}</span>
</div>
- <div class="diff-stats-group cred">
- <icon name="file-deletion" class="diff-stats-icon" /> <strong>{{ removedLines }}</strong>
+ <div
+ class="diff-stats-group cred d-flex align-items-center"
+ :class="{ bold: isCompareVersionsHeader }"
+ >
+ <span>-</span>
+ <span class="js-file-deletion-line">{{ removedLines }}</span>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue
index 0f3e9208d21..9544fbe9fc5 100644
--- a/app/assets/javascripts/diffs/components/diff_table_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue
@@ -1,21 +1,24 @@
<script>
import { mapGetters, mapActions } from 'vuex';
-import DiffLineGutterContent from './diff_line_gutter_content.vue';
+import { GlIcon } from '@gitlab/ui';
+import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils';
+import DiffGutterAvatars from './diff_gutter_avatars.vue';
import {
MATCH_LINE_TYPE,
CONTEXT_LINE_TYPE,
+ LINE_POSITION_RIGHT,
EMPTY_CELL_TYPE,
OLD_LINE_TYPE,
OLD_NO_NEW_LINE_TYPE,
NEW_NO_NEW_LINE_TYPE,
LINE_HOVER_CLASS_NAME,
LINE_UNFOLD_CLASS_NAME,
- INLINE_DIFF_VIEW_TYPE,
} from '../constants';
export default {
components: {
- DiffLineGutterContent,
+ DiffGutterAvatars,
+ GlIcon,
},
props: {
line: {
@@ -33,12 +36,6 @@ export default {
isHighlighted: {
type: Boolean,
required: true,
- default: false,
- },
- diffViewType: {
- type: String,
- required: false,
- default: INLINE_DIFF_VIEW_TYPE,
},
showCommentButton: {
type: Boolean,
@@ -73,6 +70,38 @@ export default {
},
computed: {
...mapGetters(['isLoggedIn']),
+ lineCode() {
+ return (
+ this.line.line_code ||
+ (this.line.left && this.line.left.line_code) ||
+ (this.line.right && this.line.right.line_code)
+ );
+ },
+ lineHref() {
+ return `#${this.line.line_code || ''}`;
+ },
+ shouldShowCommentButton() {
+ return (
+ this.isHover &&
+ !this.isMatchLine &&
+ !this.isContextLine &&
+ !this.isMetaLine &&
+ !this.hasDiscussions
+ );
+ },
+ hasDiscussions() {
+ return this.line.discussions && this.line.discussions.length > 0;
+ },
+ shouldShowAvatarsOnGutter() {
+ if (!this.line.type && this.linePosition === LINE_POSITION_RIGHT) {
+ return false;
+ }
+ return this.showCommentButton && this.hasDiscussions;
+ },
+ shouldRenderCommentButton() {
+ const isDiffHead = parseBoolean(getParameterByName('diff_head'));
+ return !isDiffHead && this.isLoggedIn && this.showCommentButton;
+ },
isMatchLine() {
return this.line.type === MATCH_LINE_TYPE;
},
@@ -107,24 +136,45 @@ export default {
return this.lineType === OLD_LINE_TYPE ? this.line.old_line : this.line.new_line;
},
},
- methods: mapActions('diffs', ['setHighlightedRow']),
+ methods: {
+ ...mapActions('diffs', ['showCommentForm', 'setHighlightedRow', 'toggleLineDiscussions']),
+ handleCommentButton() {
+ this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash });
+ },
+ },
};
</script>
<template>
- <td :class="classNameMap">
- <diff-line-gutter-content
- :line="line"
- :file-hash="fileHash"
- :context-lines-path="contextLinesPath"
- :line-position="linePosition"
- :line-number="lineNumber"
- :show-comment-button="showCommentButton"
- :is-hover="isHover"
- :is-bottom="isBottom"
- :is-match-line="isMatchLine"
- :is-context-line="isContentLine"
- :is-meta-line="isMetaLine"
- />
+ <td ref="td" :class="classNameMap">
+ <div>
+ <button
+ v-if="shouldRenderCommentButton"
+ v-show="shouldShowCommentButton"
+ ref="addDiffNoteButton"
+ type="button"
+ class="add-diff-note js-add-diff-note-button qa-diff-comment"
+ title="Add a comment to this line"
+ @click="handleCommentButton"
+ >
+ <gl-icon :size="12" name="comment" />
+ </button>
+ <a
+ v-if="lineNumber"
+ ref="lineNumberRef"
+ :data-linenumber="lineNumber"
+ :href="lineHref"
+ @click="setHighlightedRow(lineCode)"
+ >
+ </a>
+ <diff-gutter-avatars
+ v-if="shouldShowAvatarsOnGutter"
+ :discussions="line.discussions"
+ :discussions-expanded="line.discussionsExpanded"
+ @toggleLineDiscussions="
+ toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded })
+ "
+ />
+ </div>
</td>
</template>
diff --git a/app/assets/javascripts/diffs/components/settings_dropdown.vue b/app/assets/javascripts/diffs/components/settings_dropdown.vue
index 0129763161a..08e991c4791 100644
--- a/app/assets/javascripts/diffs/components/settings_dropdown.vue
+++ b/app/assets/javascripts/diffs/components/settings_dropdown.vue
@@ -31,7 +31,7 @@ export default {
data-toggle="dropdown"
data-display="static"
>
- <icon name="settings" /> <icon name="arrow-down" />
+ <icon name="settings" /> <icon name="chevron-down" />
</button>
<div class="dropdown-menu dropdown-menu-right p-2 pt-3 pb-3">
<div>
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index 30be2e68e76..eca9091f92f 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -3,8 +3,8 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import { GlTooltipDirective } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
-import FileRow from '~/vue_shared/components/file_row.vue';
-import FileRowStats from './file_row_stats.vue';
+import FileTree from '~/vue_shared/components/file_tree.vue';
+import DiffFileRow from './diff_file_row.vue';
export default {
directives: {
@@ -12,7 +12,7 @@ export default {
},
components: {
Icon,
- FileRow,
+ FileTree,
},
props: {
hideFileStats: {
@@ -48,9 +48,6 @@ export default {
return acc;
}, []);
},
- fileRowExtraComponent() {
- return this.hideFileStats ? null : FileRowStats;
- },
},
methods: {
...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']),
@@ -58,9 +55,10 @@ export default {
this.search = '';
},
},
- searchPlaceholder: sprintf(s__('MergeRequest|Filter files or search with %{modifier_key}+p'), {
- modifier_key: /Mac/i.test(navigator.userAgent) ? 'cmd' : 'ctrl',
+ searchPlaceholder: sprintf(s__('MergeRequest|Search files (%{modifier_key}P)'), {
+ modifier_key: /Mac/i.test(navigator.userAgent) ? '⌘' : 'Ctrl+',
}),
+ DiffFileRow,
};
</script>
@@ -91,14 +89,13 @@ export default {
</div>
<div :class="{ 'pt-0 tree-list-blobs': !renderTreeList }" class="tree-list-scroll">
<template v-if="filteredTreeList.length">
- <file-row
+ <file-tree
v-for="file in filteredTreeList"
:key="file.key"
:file="file"
:level="0"
- :hide-extra-on-tree="true"
- :extra-component="fileRowExtraComponent"
- :show-changed-icon="true"
+ :hide-file-stats="hideFileStats"
+ :file-row-component="$options.DiffFileRow"
@toggleTreeOpen="toggleTreeOpen"
@clickFile="scrollToFile"
/>
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index b920e041135..bd85105ccb4 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -111,15 +111,22 @@ export const fetchDiffFilesBatch = ({ commit, state }) => {
commit(types.SET_BATCH_LOADING, true);
commit(types.SET_RETRIEVING_BATCHES, true);
- const getBatch = page =>
+ const getBatch = (page = 1) =>
axios
.get(state.endpointBatch, {
- params: { ...urlParams, page },
+ params: {
+ ...urlParams,
+ page,
+ },
})
.then(({ data: { pagination, diff_files } }) => {
commit(types.SET_DIFF_DATA_BATCH, { diff_files });
commit(types.SET_BATCH_LOADING, false);
- if (!pagination.next_page) commit(types.SET_RETRIEVING_BATCHES, false);
+
+ if (!pagination.next_page) {
+ commit(types.SET_RETRIEVING_BATCHES, false);
+ }
+
return pagination.next_page;
})
.then(nextPage => nextPage && getBatch(nextPage))
@@ -132,6 +139,11 @@ export const fetchDiffFilesBatch = ({ commit, state }) => {
export const fetchDiffFilesMeta = ({ commit, state }) => {
const worker = new TreeWorker();
+ const urlParams = {};
+
+ if (state.useSingleDiffStyle) {
+ urlParams.view = state.diffViewType;
+ }
commit(types.SET_LOADING, true);
@@ -142,16 +154,17 @@ export const fetchDiffFilesMeta = ({ commit, state }) => {
});
return axios
- .get(state.endpointMetadata)
+ .get(mergeUrlParams(urlParams, state.endpointMetadata))
.then(({ data }) => {
const strippedData = { ...data };
+
delete strippedData.diff_files;
commit(types.SET_LOADING, false);
commit(types.SET_MERGE_REQUEST_DIFFS, data.merge_request_diffs || []);
commit(types.SET_DIFF_DATA, strippedData);
- prepareDiffData(data);
- worker.postMessage(data.diff_files);
+ worker.postMessage(prepareDiffData(data, state.diffFiles));
+
return data;
})
.catch(() => worker.terminate());
@@ -226,7 +239,7 @@ export const startRenderDiffsQueue = ({ state, commit }) => {
const nextFile = state.diffFiles.find(
file =>
!file.renderIt &&
- (file.viewer && (!file.viewer.collapsed || !file.viewer.name === diffViewerModes.text)),
+ (file.viewer && (!file.viewer.collapsed || file.viewer.name !== diffViewerModes.text)),
);
if (nextFile) {
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 1505be1a0b2..c26411af5d7 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -148,8 +148,8 @@ export default {
},
[types.ADD_COLLAPSED_DIFFS](state, { file, data }) {
- prepareDiffData(data);
- const [newFileData] = data.diff_files.filter(f => f.file_hash === file.file_hash);
+ const files = prepareDiffData(data);
+ const [newFileData] = files.filter(f => f.file_hash === file.file_hash);
const selectedFile = state.diffFiles.find(f => f.file_hash === file.file_hash);
Object.assign(selectedFile, { ...newFileData });
},
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index b379f1fabef..80972d2aeb8 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -217,30 +217,19 @@ function diffFileUniqueId(file) {
return `${file.content_sha}-${file.file_hash}`;
}
-function combineDiffFilesWithPriorFiles(files, prior = []) {
- files.forEach(file => {
- const id = diffFileUniqueId(file);
- const oldMatch = prior.find(oldFile => diffFileUniqueId(oldFile) === id);
-
- if (oldMatch) {
- const missingInline = !file.highlighted_diff_lines;
- const missingParallel = !file.parallel_diff_lines;
-
- if (missingInline) {
- Object.assign(file, {
- highlighted_diff_lines: oldMatch.highlighted_diff_lines,
- });
- }
+function mergeTwoFiles(target, source) {
+ const originalInline = target.highlighted_diff_lines;
+ const originalParallel = target.parallel_diff_lines;
+ const missingInline = !originalInline.length;
+ const missingParallel = !originalParallel.length;
- if (missingParallel) {
- Object.assign(file, {
- parallel_diff_lines: oldMatch.parallel_diff_lines,
- });
- }
- }
- });
-
- return files;
+ return {
+ ...target,
+ highlighted_diff_lines: missingInline ? source.highlighted_diff_lines : originalInline,
+ parallel_diff_lines: missingParallel ? source.parallel_diff_lines : originalParallel,
+ renderIt: source.renderIt,
+ collapsed: source.collapsed,
+ };
}
function ensureBasicDiffFileLines(file) {
@@ -260,13 +249,16 @@ function cleanRichText(text) {
}
function prepareLine(line) {
- return Object.assign(line, {
- rich_text: cleanRichText(line.rich_text),
- discussionsExpanded: true,
- discussions: [],
- hasForm: false,
- text: undefined,
- });
+ if (!line.alreadyPrepared) {
+ Object.assign(line, {
+ rich_text: cleanRichText(line.rich_text),
+ discussionsExpanded: true,
+ discussions: [],
+ hasForm: false,
+ text: undefined,
+ alreadyPrepared: true,
+ });
+ }
}
function prepareDiffFileLines(file) {
@@ -288,11 +280,11 @@ function prepareDiffFileLines(file) {
parallelLinesCount += 1;
prepareLine(line.right);
}
+ });
- Object.assign(file, {
- inlineLinesCount: inlineLines.length,
- parallelLinesCount,
- });
+ Object.assign(file, {
+ inlineLinesCount: inlineLines.length,
+ parallelLinesCount,
});
return file;
@@ -318,11 +310,26 @@ function finalizeDiffFile(file) {
return file;
}
-export function prepareDiffData(diffData, priorFiles) {
- return combineDiffFilesWithPriorFiles(diffData.diff_files, priorFiles)
+function deduplicateFilesList(files) {
+ const dedupedFiles = files.reduce((newList, file) => {
+ const id = diffFileUniqueId(file);
+
+ return {
+ ...newList,
+ [id]: newList[id] ? mergeTwoFiles(newList[id], file) : file,
+ };
+ }, {});
+
+ return Object.values(dedupedFiles);
+}
+
+export function prepareDiffData(diff, priorFiles = []) {
+ const cleanedFiles = (diff.diff_files || [])
.map(ensureBasicDiffFileLines)
.map(prepareDiffFileLines)
.map(finalizeDiffFile);
+
+ return deduplicateFilesList([...priorFiles, ...cleanedFiles]);
}
export function getDiffPositionByLineCode(diffFiles, useSingleDiffStyle) {
diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js
index b973316b3b9..218bf41cd58 100644
--- a/app/assets/javascripts/due_date_select.js
+++ b/app/assets/javascripts/due_date_select.js
@@ -1,3 +1,4 @@
+/* eslint-disable max-classes-per-file */
import $ from 'jquery';
import Pikaday from 'pikaday';
import dateFormat from 'dateformat';
diff --git a/app/assets/javascripts/editor/editor_lite.js b/app/assets/javascripts/editor/editor_lite.js
new file mode 100644
index 00000000000..8711f6e65af
--- /dev/null
+++ b/app/assets/javascripts/editor/editor_lite.js
@@ -0,0 +1,68 @@
+import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor';
+import whiteTheme from '~/ide/lib/themes/white';
+import { defaultEditorOptions } from '~/ide/lib/editor_options';
+import { clearDomElement } from './utils';
+
+export default class Editor {
+ constructor(options = {}) {
+ this.editorEl = null;
+ this.blobContent = '';
+ this.blobPath = '';
+ this.instance = null;
+ this.model = null;
+ this.options = {
+ ...defaultEditorOptions,
+ ...options,
+ };
+
+ Editor.setupMonacoTheme();
+ }
+
+ static setupMonacoTheme() {
+ monacoEditor.defineTheme('white', whiteTheme);
+ monacoEditor.setTheme('white');
+ }
+
+ createInstance({ el = undefined, blobPath = '', blobContent = '' } = {}) {
+ if (!el) return;
+ this.editorEl = el;
+ this.blobContent = blobContent;
+ this.blobPath = blobPath;
+
+ clearDomElement(this.editorEl);
+
+ this.model = monacoEditor.createModel(
+ this.blobContent,
+ undefined,
+ new Uri('gitlab', false, this.blobPath),
+ );
+
+ monacoEditor.onDidCreateEditor(this.renderEditor.bind(this));
+
+ this.instance = monacoEditor.create(this.editorEl, this.options);
+ this.instance.setModel(this.model);
+ }
+
+ dispose() {
+ return this.instance && this.instance.dispose();
+ }
+
+ renderEditor() {
+ delete this.editorEl.dataset.editorLoading;
+ }
+
+ updateModelLanguage(path) {
+ if (path === this.blobPath) return;
+ this.blobPath = path;
+ const ext = `.${path.split('.').pop()}`;
+ const language = monacoLanguages
+ .getLanguages()
+ .find(lang => lang.extensions.indexOf(ext) !== -1);
+ const id = language ? language.id : 'plaintext';
+ monacoEditor.setModelLanguage(this.model, id);
+ }
+
+ getValue() {
+ return this.model.getValue();
+ }
+}
diff --git a/app/assets/javascripts/editor/utils.js b/app/assets/javascripts/editor/utils.js
new file mode 100644
index 00000000000..d8b6396b671
--- /dev/null
+++ b/app/assets/javascripts/editor/utils.js
@@ -0,0 +1,11 @@
+export const clearDomElement = el => {
+ if (!el || !el.firstChild) return;
+
+ while (el.firstChild) {
+ el.removeChild(el.firstChild);
+ }
+};
+
+export default () => ({
+ clearDomElement,
+});
diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue
index cdf62259479..0a978ab5869 100644
--- a/app/assets/javascripts/environments/components/container.vue
+++ b/app/assets/javascripts/environments/components/container.vue
@@ -41,7 +41,7 @@ export default {
<div class="environments-container">
<gl-loading-icon
v-if="isLoading"
- :size="3"
+ size="md"
class="prepend-top-default"
label="Loading environments"
/>
diff --git a/app/assets/javascripts/environments/components/enable_review_app_button.vue b/app/assets/javascripts/environments/components/enable_review_app_button.vue
new file mode 100644
index 00000000000..2f9e9cb628f
--- /dev/null
+++ b/app/assets/javascripts/environments/components/enable_review_app_button.vue
@@ -0,0 +1,107 @@
+<script>
+import { GlButton, GlModal, GlModalDirective, GlLink, GlSprintf } from '@gitlab/ui';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ GlLink,
+ GlModal,
+ GlSprintf,
+ ModalCopyButton,
+ },
+ directives: {
+ 'gl-modal': GlModalDirective,
+ },
+ instructionText: {
+ step1: s__(
+ 'EnableReviewApp|%{stepStart}Step 1%{stepEnd}. Ensure you have Kubernetes set up and have a base domain for your %{linkStart}cluster%{linkEnd}.',
+ ),
+ step2: s__('EnableReviewApp|%{stepStart}Step 2%{stepEnd}. Copy the following snippet:'),
+ step3: s__(
+ `EnableReviewApp|%{stepStart}Step 3%{stepEnd}. Add it to the project %{linkStart}gitlab-ci.yml%{linkEnd} file.`,
+ ),
+ },
+ modalInfo: {
+ closeText: s__('EnableReviewApp|Close'),
+ copyToClipboardText: s__('EnableReviewApp|Copy snippet text'),
+ copyString: `deploy_review
+ stage: deploy
+ script:
+ - echo "Deploy a review app"
+ environment:
+ name: review/$CI_COMMIT_REF_NAME
+ url: https://$CI_ENVIRONMENT_SLUG.example.com
+ only: branches
+ except: master`,
+ id: 'enable-review-app-info',
+ title: s__('ReviewApp|Enable Review App'),
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-button
+ v-gl-modal="$options.modalInfo.id"
+ variant="info"
+ category="secondary"
+ type="button"
+ class="js-enable-review-app-button"
+ >
+ {{ s__('Environments|Enable review app') }}
+ </gl-button>
+ <gl-modal
+ :modal-id="$options.modalInfo.id"
+ :title="$options.modalInfo.title"
+ size="lg"
+ class="text-2 ws-normal"
+ ok-only
+ ok-variant="light"
+ :ok-title="$options.modalInfo.closeText"
+ >
+ <p>
+ <gl-sprintf :message="$options.instructionText.step1">
+ <template #step="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ <template #link="{ content }">
+ <gl-link
+ href="https://docs.gitlab.com/ee/user/project/clusters/add_remove_clusters.html"
+ target="_blank"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </p>
+ <div>
+ <p>
+ <gl-sprintf :message="$options.instructionText.step2">
+ <template #step="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <div class="flex align-items-start">
+ <pre class="w-100"> {{ $options.modalInfo.copyString }} </pre>
+ <modal-copy-button
+ :title="$options.modalInfo.copyToClipboardText"
+ :text="$options.modalInfo.copyString"
+ :modal-id="$options.modalInfo.id"
+ css-classes="border-0"
+ />
+ </div>
+ </div>
+ <p>
+ <gl-sprintf :message="$options.instructionText.step3">
+ <template #step="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ <template #link="{ content }">
+ <gl-link href="blob/master/.gitlab-ci.yml" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index 50c667e6966..07b8d20fde0 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -1,19 +1,23 @@
<script>
+import { GlButton } from '@gitlab/ui';
import envrionmentsAppMixin from 'ee_else_ce/environments/mixins/environments_app_mixin';
-import Flash from '../../flash';
-import { s__ } from '../../locale';
+import Flash from '~/flash';
+import { s__ } from '~/locale';
import emptyState from './empty_state.vue';
import eventHub from '../event_hub';
import environmentsMixin from '../mixins/environments_mixin';
-import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
+import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin';
+import EnableReviewAppButton from './enable_review_app_button.vue';
import StopEnvironmentModal from './stop_environment_modal.vue';
import ConfirmRollbackModal from './confirm_rollback_modal.vue';
export default {
components: {
+ ConfirmRollbackModal,
emptyState,
+ EnableReviewAppButton,
+ GlButton,
StopEnvironmentModal,
- ConfirmRollbackModal,
},
mixins: [CIPaginationMixin, environmentsMixin, envrionmentsAppMixin],
@@ -96,10 +100,16 @@ export default {
<div class="top-area">
<tabs :tabs="tabs" scope="environments" @onChangeTab="onChangeTab" />
- <div v-if="canCreateEnvironment && !isLoading" class="nav-controls">
- <a :href="newEnvironmentPath" class="btn btn-success">
+ <div class="nav-controls">
+ <enable-review-app-button v-if="state.reviewAppDetails.can_setup_review_app" class="mr-2" />
+ <gl-button
+ v-if="canCreateEnvironment && !isLoading"
+ :href="newEnvironmentPath"
+ category="primary"
+ variant="success"
+ >
{{ s__('Environments|New environment') }}
- </a>
+ </gl-button>
</div>
</div>
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index 30299ccc7bc..3f316643784 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -162,15 +162,14 @@ export default {
:is-loading="model.isLoadingDeployBoard"
:is-empty="model.isEmptyDeployBoard"
:has-legacy-app-label="model.hasLegacyAppLabel"
- :project-path="model.project_path"
- :environment-name="model.name"
+ :logs-path="model.logs_path"
/>
</div>
</div>
<template v-if="shouldRenderFolderContent(model)">
<div v-if="model.isLoadingFolderContent" :key="`loading-item-${i}`">
- <gl-loading-icon :size="2" class="prepend-top-16" />
+ <gl-loading-icon size="md" class="prepend-top-16" />
</div>
<template v-else>
diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js
index 34374e306a4..1c5884b541c 100644
--- a/app/assets/javascripts/environments/mixins/environments_mixin.js
+++ b/app/assets/javascripts/environments/mixins/environments_mixin.js
@@ -52,6 +52,7 @@ export default {
this.store.storeAvailableCount(resp.data.available_count);
this.store.storeStoppedCount(resp.data.stopped_count);
this.store.storeEnvironments(resp.data.environments);
+ this.store.setReviewAppDetails(resp.data.review_app);
this.store.setPagination(resp.headers);
}
},
diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js
index 81c257acd53..6b7c1ff627d 100644
--- a/app/assets/javascripts/environments/stores/environments_store.js
+++ b/app/assets/javascripts/environments/stores/environments_store.js
@@ -14,6 +14,7 @@ export default class EnvironmentsStore {
this.state.stoppedCounter = 0;
this.state.availableCounter = 0;
this.state.paginationInformation = {};
+ this.state.reviewAppDetails = {};
return this;
}
@@ -104,6 +105,11 @@ export default class EnvironmentsStore {
return paginationInformation;
}
+ setReviewAppDetails(details = {}) {
+ this.state.reviewAppDetails = details;
+ return details;
+ }
+
/**
* Stores the number of available environments.
*
diff --git a/app/assets/javascripts/error_tracking/components/constants.js b/app/assets/javascripts/error_tracking/components/constants.js
new file mode 100644
index 00000000000..60b217443de
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/components/constants.js
@@ -0,0 +1,21 @@
+export const severityLevel = {
+ FATAL: 'fatal',
+ ERROR: 'error',
+ WARNING: 'warning',
+ INFO: 'info',
+ DEBUG: 'debug',
+};
+
+export const severityLevelVariant = {
+ [severityLevel.FATAL]: 'danger',
+ [severityLevel.ERROR]: 'dark',
+ [severityLevel.WARNING]: 'warning',
+ [severityLevel.INFO]: 'info',
+ [severityLevel.DEBUG]: 'light',
+};
+
+export const errorStatus = {
+ IGNORED: 'ignored',
+ RESOLVED: 'resolved',
+ UNRESOLVED: 'unresolved',
+};
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue
index 819d501cba6..7abe3be3e99 100644
--- a/app/assets/javascripts/error_tracking/components/error_details.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details.vue
@@ -2,7 +2,15 @@
import { mapActions, mapGetters, mapState } from 'vuex';
import dateFormat from 'dateformat';
import createFlash from '~/flash';
-import { GlButton, GlFormInput, GlLink, GlLoadingIcon, GlBadge } from '@gitlab/ui';
+import {
+ GlButton,
+ GlFormInput,
+ GlLink,
+ GlLoadingIcon,
+ GlBadge,
+ GlAlert,
+ GlSprintf,
+} from '@gitlab/ui';
import { __, sprintf, n__ } from '~/locale';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
@@ -11,6 +19,7 @@ import Stacktrace from './stacktrace.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { trackClickErrorLinkToSentryOptions } from '../utils';
+import { severityLevel, severityLevelVariant, errorStatus } from './constants';
import query from '../queries/details.query.graphql';
@@ -25,16 +34,14 @@ export default {
Icon,
Stacktrace,
GlBadge,
+ GlAlert,
+ GlSprintf,
},
directives: {
TrackEvent: TrackEventDirective,
},
mixins: [timeagoMixin],
props: {
- listPath: {
- type: String,
- required: true,
- },
issueUpdatePath: {
type: String,
required: true,
@@ -47,10 +54,6 @@ export default {
type: String,
required: true,
},
- issueDetailsPath: {
- type: String,
- required: true,
- },
issueStackTracePath: {
type: String,
required: true,
@@ -65,7 +68,7 @@ export default {
},
},
apollo: {
- GQLerror: {
+ error: {
query,
variables() {
return {
@@ -74,57 +77,54 @@ export default {
};
},
pollInterval: 2000,
- update: data => data.project.sentryDetailedError,
+ update: data => data.project.sentryErrors.detailedError,
error: () => createFlash(__('Failed to load error details from Sentry.')),
result(res) {
- if (res.data.project?.sentryDetailedError) {
- this.$apollo.queries.GQLerror.stopPolling();
+ if (res.data.project?.sentryErrors?.detailedError) {
+ this.$apollo.queries.error.stopPolling();
+ this.setStatus(this.error.status);
}
},
},
},
data() {
return {
- GQLerror: null,
+ error: null,
issueCreationInProgress: false,
+ isAlertVisible: false,
+ closedIssueId: null,
};
},
computed: {
...mapState('details', [
- 'error',
- 'loading',
'loadingStacktrace',
'stacktraceData',
'updatingResolveStatus',
'updatingIgnoreStatus',
+ 'errorStatus',
]),
...mapGetters('details', ['stacktrace']),
reported() {
return sprintf(
__('Reported %{timeAgo} by %{reportedBy}'),
{
- reportedBy: `<strong>${this.GQLerror.culprit}</strong>`,
+ reportedBy: `<strong>${this.error.culprit}</strong>`,
timeAgo: this.timeFormatted(this.stacktraceData.date_received),
},
false,
);
},
firstReleaseLink() {
- return `${this.error.external_base_url}/releases/${this.GQLerror.firstReleaseShortVersion}`;
+ return `${this.error.externalBaseUrl}/releases/${this.error.firstReleaseShortVersion}`;
},
lastReleaseLink() {
- return `${this.error.external_base_url}releases/${this.GQLerror.lastReleaseShortVersion}`;
- },
- showDetails() {
- return Boolean(
- !this.loading && !this.$apollo.queries.GQLerror.loading && this.error && this.GQLerror,
- );
+ return `${this.error.externalBaseUrl}/releases/${this.error.lastReleaseShortVersion}`;
},
showStacktrace() {
- return Boolean(!this.loadingStacktrace && this.stacktrace && this.stacktrace.length);
+ return Boolean(this.stacktrace?.length);
},
issueTitle() {
- return this.GQLerror.title;
+ return this.error.title;
},
issueDescription() {
return sprintf(
@@ -133,13 +133,13 @@ export default {
),
{
description: '# Error Details:\n',
- errorUrl: `${this.GQLerror.externalUrl}\n`,
- firstSeen: `\n${this.GQLerror.firstSeen}\n`,
- lastSeen: `${this.GQLerror.lastSeen}\n`,
- countLabel: n__('- Event', '- Events', this.GQLerror.count),
- count: `${this.GQLerror.count}\n`,
- userCountLabel: n__('- User', '- Users', this.GQLerror.userCount),
- userCount: `${this.GQLerror.userCount}\n`,
+ errorUrl: `${this.error.externalUrl}\n`,
+ firstSeen: `\n${this.error.firstSeen}\n`,
+ lastSeen: `${this.error.lastSeen}\n`,
+ countLabel: n__('- Event', '- Events', this.error.count),
+ count: `${this.error.count}\n`,
+ userCountLabel: n__('- User', '- Users', this.error.userCount),
+ userCount: `${this.error.userCount}\n`,
},
false,
);
@@ -147,20 +147,50 @@ export default {
errorLevel() {
return sprintf(__('level: %{level}'), { level: this.error.tags.level });
},
+ errorSeverityVariant() {
+ return (
+ severityLevelVariant[this.error.tags.level] || severityLevelVariant[severityLevel.ERROR]
+ );
+ },
+ ignoreBtnLabel() {
+ return this.errorStatus !== errorStatus.IGNORED ? __('Ignore') : __('Undo ignore');
+ },
+ resolveBtnLabel() {
+ return this.errorStatus !== errorStatus.RESOLVED ? __('Resolve') : __('Unresolve');
+ },
},
mounted() {
- this.startPollingDetails(this.issueDetailsPath);
this.startPollingStacktrace(this.issueStackTracePath);
},
methods: {
- ...mapActions('details', ['startPollingDetails', 'startPollingStacktrace', 'updateStatus']),
+ ...mapActions('details', [
+ 'startPollingStacktrace',
+ 'updateStatus',
+ 'setStatus',
+ 'updateResolveStatus',
+ 'updateIgnoreStatus',
+ ]),
trackClickErrorLinkToSentryOptions,
createIssue() {
this.issueCreationInProgress = true;
this.$refs.sentryIssueForm.submit();
},
- updateIssueStatus(status) {
- this.updateStatus({ endpoint: this.issueUpdatePath, redirectUrl: this.listPath, status });
+ onIgnoreStatusUpdate() {
+ const status =
+ this.errorStatus === errorStatus.IGNORED ? errorStatus.UNRESOLVED : errorStatus.IGNORED;
+ this.updateIgnoreStatus({ endpoint: this.issueUpdatePath, status });
+ },
+ onResolveStatusUpdate() {
+ const status =
+ this.errorStatus === errorStatus.RESOLVED ? errorStatus.UNRESOLVED : errorStatus.RESOLVED;
+
+ // eslint-disable-next-line promise/catch-or-return
+ this.updateResolveStatus({ endpoint: this.issueUpdatePath, status }).then(res => {
+ this.closedIssueId = res.closed_issue_iid;
+ if (this.closedIssueId) {
+ this.isAlertVisible = true;
+ }
+ });
},
formatDate(date) {
return `${this.timeFormatted(date)} (${dateFormat(date, 'UTC:yyyy-mm-dd h:MM:ssTT Z')})`;
@@ -171,29 +201,43 @@ export default {
<template>
<div>
- <div v-if="$apollo.queries.GQLerror.loading || loading" class="py-3">
+ <div v-if="$apollo.queries.error.loading" class="py-3">
<gl-loading-icon :size="3" />
</div>
- <div v-else-if="showDetails" class="error-details">
+ <div v-else-if="error" class="error-details">
+ <gl-alert v-if="isAlertVisible" @dismiss="isAlertVisible = false">
+ <gl-sprintf
+ :message="
+ __('The associated issue #%{issueId} has been closed as the error is now resolved.')
+ "
+ >
+ <template #issueId>
+ <span>{{ closedIssueId }}</span>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+
<div class="top-area align-items-center justify-content-between py-3">
<span v-if="!loadingStacktrace && stacktrace" v-html="reported"></span>
- <div class="d-inline-flex">
+ <div class="d-inline-flex ml-lg-auto">
<loading-button
- :label="__('Ignore')"
+ :label="ignoreBtnLabel"
:loading="updatingIgnoreStatus"
- @click="updateIssueStatus('ignored')"
+ data-qa-selector="update_ignore_status_button"
+ @click="onIgnoreStatusUpdate"
/>
<loading-button
class="btn-outline-info ml-2"
- :label="__('Resolve')"
+ :label="resolveBtnLabel"
:loading="updatingResolveStatus"
- @click="updateIssueStatus('resolved')"
+ data-qa-selector="update_resolve_status_button"
+ @click="onResolveStatusUpdate"
/>
<gl-button
- v-if="error.gitlab_issue"
+ v-if="error.gitlabIssuePath"
class="ml-2"
data-qa-selector="view_issue_button"
- :href="error.gitlab_issue"
+ :href="error.gitlabIssuePath"
variant="success"
>
{{ __('View issue') }}
@@ -207,13 +251,13 @@ export default {
<gl-form-input class="hidden" name="issue[title]" :value="issueTitle" />
<input name="issue[description]" :value="issueDescription" type="hidden" />
<gl-form-input
- :value="GQLerror.sentryId"
+ :value="error.sentryId"
class="hidden"
name="issue[sentry_issue_attributes][sentry_issue_identifier]"
/>
<gl-form-input :value="csrfToken" class="hidden" name="authenticity_token" />
<loading-button
- v-if="!error.gitlab_issue"
+ v-if="!error.gitlabIssuePath"
class="btn-success"
:label="__('Create issue')"
:loading="issueCreationInProgress"
@@ -224,65 +268,67 @@ export default {
</div>
</div>
<div>
- <tooltip-on-truncate :title="GQLerror.title" truncate-target="child" placement="top">
- <h2 class="text-truncate">{{ GQLerror.title }}</h2>
+ <tooltip-on-truncate :title="error.title" truncate-target="child" placement="top">
+ <h2 class="text-truncate">{{ error.title }}</h2>
</tooltip-on-truncate>
<template v-if="error.tags">
- <gl-badge v-if="error.tags.level" variant="danger" class="rounded-pill mr-2"
- >{{ errorLevel }}
+ <gl-badge
+ v-if="error.tags.level"
+ :variant="errorSeverityVariant"
+ class="rounded-pill mr-2"
+ >
+ {{ errorLevel }}
</gl-badge>
<gl-badge v-if="error.tags.logger" variant="light" class="rounded-pill"
>{{ error.tags.logger }}
</gl-badge>
</template>
<ul>
- <li v-if="GQLerror.gitlabCommit">
+ <li v-if="error.gitlabCommit">
<strong class="bold">{{ __('GitLab commit') }}:</strong>
- <gl-link :href="GQLerror.gitlabCommitPath">
- <span>{{ GQLerror.gitlabCommit.substr(0, 10) }}</span>
+ <gl-link :href="error.gitlabCommitPath">
+ <span>{{ error.gitlabCommit.substr(0, 10) }}</span>
</gl-link>
</li>
- <li v-if="error.gitlab_issue">
+ <li v-if="error.gitlabIssuePath">
<strong class="bold">{{ __('GitLab Issue') }}:</strong>
- <gl-link :href="error.gitlab_issue">
- <span>{{ error.gitlab_issue }}</span>
+ <gl-link :href="error.gitlabIssuePath">
+ <span>{{ error.gitlabIssuePath }}</span>
</gl-link>
</li>
<li>
<strong class="bold">{{ __('Sentry event') }}:</strong>
<gl-link
- v-track-event="trackClickErrorLinkToSentryOptions(GQLerror.externalUrl)"
+ v-track-event="trackClickErrorLinkToSentryOptions(error.externalUrl)"
class="d-inline-flex align-items-center"
- :href="GQLerror.externalUrl"
+ :href="error.externalUrl"
target="_blank"
>
- <span class="text-truncate">{{ GQLerror.externalUrl }}</span>
+ <span class="text-truncate">{{ error.externalUrl }}</span>
<icon name="external-link" class="ml-1 flex-shrink-0" />
</gl-link>
</li>
- <li v-if="GQLerror.firstReleaseShortVersion">
+ <li v-if="error.firstReleaseShortVersion">
<strong class="bold">{{ __('First seen') }}:</strong>
- {{ formatDate(GQLerror.firstSeen) }}
+ {{ formatDate(error.firstSeen) }}
<gl-link :href="firstReleaseLink" target="_blank">
- <span>
- {{ __('Release') }}: {{ GQLerror.firstReleaseShortVersion.substr(0, 10) }}
- </span>
+ <span>{{ __('Release') }}: {{ error.firstReleaseShortVersion.substr(0, 10) }}</span>
</gl-link>
</li>
- <li v-if="GQLerror.lastReleaseShortVersion">
+ <li v-if="error.lastReleaseShortVersion">
<strong class="bold">{{ __('Last seen') }}:</strong>
- {{ formatDate(GQLerror.lastSeen) }}
+ {{ formatDate(error.lastSeen) }}
<gl-link :href="lastReleaseLink" target="_blank">
- <span>{{ __('Release') }}: {{ GQLerror.lastReleaseShortVersion.substr(0, 10) }}</span>
+ <span>{{ __('Release') }}: {{ error.lastReleaseShortVersion.substr(0, 10) }}</span>
</gl-link>
</li>
<li>
<strong class="bold">{{ __('Events') }}:</strong>
- <span>{{ GQLerror.count }}</span>
+ <span>{{ error.count }}</span>
</li>
<li>
<strong class="bold">{{ __('Users') }}:</strong>
- <span>{{ GQLerror.userCount }}</span>
+ <span>{{ error.userCount }}</span>
</li>
</ul>
@@ -290,7 +336,7 @@ export default {
<gl-loading-icon :size="3" />
</div>
- <template v-if="showStacktrace">
+ <template v-else-if="showStacktrace">
<h3 class="my-4">{{ __('Stack trace') }}</h3>
<stacktrace :entries="stacktrace" />
</template>
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 3280ff48129..70f257180c6 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -13,53 +13,53 @@ import {
GlDropdownDivider,
GlTooltipDirective,
GlPagination,
+ GlButtonGroup,
} from '@gitlab/ui';
import AccessorUtils from '~/lib/utils/accessor';
import Icon from '~/vue_shared/components/icon.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { __ } from '~/locale';
-import _ from 'underscore';
+import { isEmpty } from 'lodash';
+
+export const tableDataClass = 'table-col d-flex d-sm-table-cell align-items-center';
export default {
FIRST_PAGE: 1,
PREV_PAGE: 1,
NEXT_PAGE: 2,
+ statusButtons: [
+ { status: 'ignored', icon: 'eye-slash', title: __('Ignore') },
+ { status: 'resolved', icon: 'check-circle', title: __('Resolve') },
+ ],
fields: [
{
key: 'error',
label: __('Error'),
thClass: 'w-60p',
- tdClass: 'table-col d-flex d-sm-table-cell px-3',
+ tdClass: `${tableDataClass} px-3`,
},
{
key: 'events',
label: __('Events'),
thClass: 'text-right',
- tdClass: 'table-col d-flex d-sm-table-cell',
+ tdClass: `${tableDataClass}`,
},
{
key: 'users',
label: __('Users'),
thClass: 'text-right',
- tdClass: 'table-col d-flex d-sm-table-cell',
+ tdClass: `${tableDataClass}`,
},
{
key: 'lastSeen',
label: __('Last seen'),
- thClass: '',
- tdClass: 'table-col d-flex d-sm-table-cell',
- },
- {
- key: 'ignore',
- label: '',
- thClass: 'w-3rem',
- tdClass: 'table-col d-flex pl-0 d-sm-table-cell',
+ thClass: 'w-15p',
+ tdClass: `${tableDataClass}`,
},
{
- key: 'resolved',
+ key: 'status',
label: '',
- thClass: 'w-3rem',
- tdClass: 'table-col d-flex pl-0 d-sm-table-cell',
+ tdClass: `${tableDataClass} text-right`,
},
{
key: 'details',
@@ -86,6 +86,7 @@ export default {
Icon,
GlPagination,
TimeAgo,
+ GlButtonGroup,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -138,7 +139,7 @@ export default {
'cursor',
]),
paginationRequired() {
- return !_.isEmpty(this.pagination);
+ return !isEmpty(this.pagination);
},
},
watch: {
@@ -167,6 +168,7 @@ export default {
'setIndexPath',
'fetchPaginatedResults',
'updateStatus',
+ 'removeIgnoredResolvedErrors',
]),
setSearchText(text) {
this.errorSearchQuery = text;
@@ -195,9 +197,9 @@ export default {
updateIssueStatus(errorId, status) {
this.updateStatus({
endpoint: this.getIssueUpdatePath(errorId),
- redirectUrl: this.listPath,
status,
});
+ this.removeIgnoredResolvedErrors(errorId);
},
},
};
@@ -234,7 +236,6 @@ export default {
</gl-dropdown>
<div class="filtered-search-input-container flex-fill">
<gl-form-input
- v-model="errorSearchQuery"
class="pl-2 filtered-search"
:disabled="loading"
:placeholder="__('Search or filter results…')"
@@ -297,17 +298,17 @@ export default {
stacked="sm"
tbody-tr-class="table-row mb-4"
>
- <template v-slot:head(error)>
+ <template #head(error)>
<div class="d-none d-sm-block">{{ __('Open errors') }}</div>
</template>
- <template v-slot:head(events)="data">
+ <template #head(events)="data">
<div class="text-sm-right">{{ data.label }}</div>
</template>
- <template v-slot:head(users)="data">
+ <template #head(users)="data">
<div class="text-sm-right">{{ data.label }}</div>
</template>
- <template v-slot:error="errors">
+ <template #cell(error)="errors">
<div class="d-flex flex-column">
<gl-link class="d-flex mw-100 text-dark" :href="getDetailsLink(errors.item.id)">
<strong class="text-truncate">{{ errors.item.title.trim() }}</strong>
@@ -317,40 +318,34 @@ export default {
</span>
</div>
</template>
- <template v-slot:events="errors">
+ <template #cell(events)="errors">
<div class="text-right">{{ errors.item.count }}</div>
</template>
- <template v-slot:users="errors">
+ <template #cell(users)="errors">
<div class="text-right">{{ errors.item.userCount }}</div>
</template>
- <template v-slot:lastSeen="errors">
+ <template #cell(lastSeen)="errors">
<div class="text-md-left text-right">
<time-ago :time="errors.item.lastSeen" class="text-secondary" />
</div>
</template>
- <template v-slot:ignore="errors">
- <gl-button
- ref="ignoreError"
- v-gl-tooltip.hover
- :title="__('Ignore')"
- @click="updateIssueStatus(errors.item.id, 'ignored')"
- >
- <gl-icon name="eye-slash" :size="12" />
- </gl-button>
- </template>
- <template v-slot:resolved="errors">
- <gl-button
- ref="resolveError"
- v-gl-tooltip
- :title="__('Resolve')"
- @click="updateIssueStatus(errors.item.id, 'resolved')"
- >
- <gl-icon name="check-circle" :size="12" />
- </gl-button>
+ <template #cell(status)="errors">
+ <gl-button-group>
+ <gl-button
+ v-for="button in $options.statusButtons"
+ :key="button.status"
+ :ref="button.title.toLowerCase() + 'Error'"
+ v-gl-tooltip.hover
+ :title="button.title"
+ @click="updateIssueStatus(errors.item.id, button.status)"
+ >
+ <gl-icon :name="button.icon" :size="12" />
+ </gl-button>
+ </gl-button-group>
</template>
- <template v-slot:details="errors">
+ <template #cell(details)="errors">
<gl-button
:href="getDetailsLink(errors.item.id)"
variant="outline-info"
@@ -359,7 +354,7 @@ export default {
{{ __('More details') }}
</gl-button>
</template>
- <template v-slot:empty>
+ <template #empty>
{{ __('No errors to display.') }}
<gl-link class="js-try-again" @click="restartPolling">
{{ __('Check again') }}
diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
index 4e63e167260..8db0b1c5da0 100644
--- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
+++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
@@ -1,5 +1,5 @@
<script>
-import _ from 'underscore';
+import { escape as esc } from 'lodash';
import { GlTooltip } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -62,7 +62,7 @@ export default {
? sprintf(
__(`%{spanStart}in%{spanEnd} %{errorFn}`),
{
- errorFn: `<strong>${_.escape(this.errorFn)}</strong>`,
+ errorFn: `<strong>${esc(this.errorFn)}</strong>`,
spanStart: `<span class="text-tertiary">`,
spanEnd: `</span>`,
},
diff --git a/app/assets/javascripts/error_tracking/details.js b/app/assets/javascripts/error_tracking/details.js
index c18298dec4f..55ab362f805 100644
--- a/app/assets/javascripts/error_tracking/details.js
+++ b/app/assets/javascripts/error_tracking/details.js
@@ -8,37 +8,35 @@ import csrf from '~/lib/utils/csrf';
Vue.use(VueApollo);
export default () => {
+ const selector = '#js-error_details';
+
+ const domEl = document.querySelector(selector);
+ const {
+ issueId,
+ projectPath,
+ issueUpdatePath,
+ issueStackTracePath,
+ projectIssuesPath,
+ } = domEl.dataset;
+
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
// eslint-disable-next-line no-new
new Vue({
- el: '#js-error_details',
+ el: selector,
apolloProvider,
components: {
ErrorDetails,
},
store,
render(createElement) {
- const domEl = document.querySelector(this.$options.el);
- const {
- issueId,
- projectPath,
- listPath,
- issueUpdatePath,
- issueDetailsPath,
- issueStackTracePath,
- projectIssuesPath,
- } = domEl.dataset;
-
return createElement('error-details', {
props: {
issueId,
projectPath,
- listPath,
issueUpdatePath,
- issueDetailsPath,
issueStackTracePath,
projectIssuesPath,
csrfToken: csrf.token,
diff --git a/app/assets/javascripts/error_tracking/list.js b/app/assets/javascripts/error_tracking/list.js
index 8f3700249da..cb656a9ef13 100644
--- a/app/assets/javascripts/error_tracking/list.js
+++ b/app/assets/javascripts/error_tracking/list.js
@@ -4,27 +4,29 @@ import store from './store';
import ErrorTrackingList from './components/error_tracking_list.vue';
export default () => {
+ const selector = '#js-error_tracking';
+
+ const domEl = document.querySelector(selector);
+ const {
+ indexPath,
+ enableErrorTrackingLink,
+ illustrationPath,
+ projectPath,
+ listPath,
+ } = domEl.dataset;
+ let { errorTrackingEnabled, userCanEnableErrorTracking } = domEl.dataset;
+
+ errorTrackingEnabled = parseBoolean(errorTrackingEnabled);
+ userCanEnableErrorTracking = parseBoolean(userCanEnableErrorTracking);
+
// eslint-disable-next-line no-new
new Vue({
- el: '#js-error_tracking',
+ el: selector,
components: {
ErrorTrackingList,
},
store,
render(createElement) {
- const domEl = document.querySelector(this.$options.el);
- const {
- indexPath,
- enableErrorTrackingLink,
- illustrationPath,
- projectPath,
- listPath,
- } = domEl.dataset;
- let { errorTrackingEnabled, userCanEnableErrorTracking } = domEl.dataset;
-
- errorTrackingEnabled = parseBoolean(errorTrackingEnabled);
- userCanEnableErrorTracking = parseBoolean(userCanEnableErrorTracking);
-
return createElement('error-tracking-list', {
props: {
indexPath,
diff --git a/app/assets/javascripts/error_tracking/queries/details.query.graphql b/app/assets/javascripts/error_tracking/queries/details.query.graphql
index 625ce3030d9..fa579c94257 100644
--- a/app/assets/javascripts/error_tracking/queries/details.query.graphql
+++ b/app/assets/javascripts/error_tracking/queries/details.query.graphql
@@ -1,20 +1,29 @@
query errorDetails($fullPath: ID!, $errorId: ID!) {
project(fullPath: $fullPath) {
- sentryDetailedError(id: $errorId) {
- id
- sentryId
- title
- userCount
- count
- firstSeen
- lastSeen
- message
- culprit
- externalUrl
- firstReleaseShortVersion
- lastReleaseShortVersion
- gitlabCommit
- gitlabCommitPath
+ sentryErrors {
+ detailedError(id: $errorId) {
+ id
+ sentryId
+ title
+ userCount
+ count
+ status
+ firstSeen
+ lastSeen
+ message
+ culprit
+ tags {
+ level
+ logger
+ }
+ externalUrl
+ externalBaseUrl
+ firstReleaseShortVersion
+ lastReleaseShortVersion
+ gitlabCommit
+ gitlabCommitPath
+ gitlabIssuePath
+ }
}
}
}
diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/actions.js
index bb8b039b5df..8f6f404ef8a 100644
--- a/app/assets/javascripts/error_tracking/store/actions.js
+++ b/app/assets/javascripts/error_tracking/store/actions.js
@@ -4,16 +4,35 @@ import createFlash from '~/flash';
import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-export function updateStatus({ commit }, { endpoint, redirectUrl, status }) {
- const type =
- status === 'resolved' ? types.SET_UPDATING_RESOLVE_STATUS : types.SET_UPDATING_IGNORE_STATUS;
- commit(type, true);
+export const setStatus = ({ commit }, status) => {
+ commit(types.SET_ERROR_STATUS, status.toLowerCase());
+};
- return service
+export const updateStatus = ({ commit }, { endpoint, redirectUrl, status }) =>
+ service
.updateErrorStatus(endpoint, status)
- .then(() => visitUrl(redirectUrl))
- .catch(() => createFlash(__('Failed to update issue status')))
- .finally(() => commit(type, false));
-}
+ .then(resp => {
+ commit(types.SET_ERROR_STATUS, status);
+ if (redirectUrl) visitUrl(redirectUrl);
+
+ return resp.data.result;
+ })
+ .catch(() => createFlash(__('Failed to update issue status')));
+
+export const updateResolveStatus = ({ commit, dispatch }, params) => {
+ commit(types.SET_UPDATING_RESOLVE_STATUS, true);
+
+ return dispatch('updateStatus', params).finally(() => {
+ commit(types.SET_UPDATING_RESOLVE_STATUS, false);
+ });
+};
+
+export const updateIgnoreStatus = ({ commit, dispatch }, params) => {
+ commit(types.SET_UPDATING_IGNORE_STATUS, true);
+
+ return dispatch('updateStatus', params).finally(() => {
+ commit(types.SET_UPDATING_IGNORE_STATUS, false);
+ });
+};
export default () => {};
diff --git a/app/assets/javascripts/error_tracking/store/details/actions.js b/app/assets/javascripts/error_tracking/store/details/actions.js
index 0390bca7175..5914a79f092 100644
--- a/app/assets/javascripts/error_tracking/store/details/actions.js
+++ b/app/assets/javascripts/error_tracking/store/details/actions.js
@@ -5,37 +5,11 @@ import Poll from '~/lib/utils/poll';
import { __ } from '~/locale';
let stackTracePoll;
-let detailPoll;
const stopPolling = poll => {
if (poll) poll.stop();
};
-export function startPollingDetails({ commit }, endpoint) {
- detailPoll = new Poll({
- resource: service,
- method: 'getSentryData',
- data: { endpoint },
- successCallback: ({ data }) => {
- if (!data) {
- detailPoll.restart();
- return;
- }
-
- commit(types.SET_ERROR, data.error);
- commit(types.SET_LOADING, false);
-
- stopPolling(detailPoll);
- },
- errorCallback: () => {
- commit(types.SET_LOADING, false);
- createFlash(__('Failed to load error details from Sentry.'));
- },
- });
-
- detailPoll.makeRequest();
-}
-
export function startPollingStacktrace({ commit }, endpoint) {
stackTracePoll = new Poll({
resource: service,
@@ -43,7 +17,6 @@ export function startPollingStacktrace({ commit }, endpoint) {
data: { endpoint },
successCallback: ({ data }) => {
if (!data) {
- stackTracePoll.restart();
return;
}
commit(types.SET_STACKTRACE_DATA, data.error);
diff --git a/app/assets/javascripts/error_tracking/store/details/mutation_types.js b/app/assets/javascripts/error_tracking/store/details/mutation_types.js
index a2592253a2d..0dd49e727e6 100644
--- a/app/assets/javascripts/error_tracking/store/details/mutation_types.js
+++ b/app/assets/javascripts/error_tracking/store/details/mutation_types.js
@@ -1,4 +1,2 @@
-export const SET_ERROR = 'SET_ERRORS';
-export const SET_LOADING = 'SET_LOADING';
export const SET_LOADING_STACKTRACE = 'SET_LOADING_STACKTRACE';
export const SET_STACKTRACE_DATA = 'SET_STACKTRACE_DATA';
diff --git a/app/assets/javascripts/error_tracking/store/details/mutations.js b/app/assets/javascripts/error_tracking/store/details/mutations.js
index 6f4720444e0..b2bde96c6a9 100644
--- a/app/assets/javascripts/error_tracking/store/details/mutations.js
+++ b/app/assets/javascripts/error_tracking/store/details/mutations.js
@@ -1,12 +1,6 @@
import * as types from './mutation_types';
export default {
- [types.SET_ERROR](state, data) {
- state.error = data;
- },
- [types.SET_LOADING](state, loading) {
- state.loading = loading;
- },
[types.SET_LOADING_STACKTRACE](state, data) {
state.loadingStacktrace = data;
},
diff --git a/app/assets/javascripts/error_tracking/store/details/state.js b/app/assets/javascripts/error_tracking/store/details/state.js
index 52b0297607d..4a6bafe3114 100644
--- a/app/assets/javascripts/error_tracking/store/details/state.js
+++ b/app/assets/javascripts/error_tracking/store/details/state.js
@@ -1,8 +1,7 @@
export default () => ({
- error: {},
stacktraceData: {},
- loading: true,
loadingStacktrace: true,
updatingResolveStatus: false,
updatingIgnoreStatus: false,
+ errorStatus: '',
});
diff --git a/app/assets/javascripts/error_tracking/store/list/actions.js b/app/assets/javascripts/error_tracking/store/list/actions.js
index d96ac7f524e..6f8573c0f4d 100644
--- a/app/assets/javascripts/error_tracking/store/list/actions.js
+++ b/app/assets/javascripts/error_tracking/store/list/actions.js
@@ -100,4 +100,8 @@ export const fetchPaginatedResults = ({ commit, dispatch }, cursor) => {
dispatch('startPolling');
};
+export const removeIgnoredResolvedErrors = ({ commit }, error) => {
+ commit(types.REMOVE_IGNORED_RESOLVED_ERRORS, error);
+};
+
export default () => {};
diff --git a/app/assets/javascripts/error_tracking/store/list/mutation_types.js b/app/assets/javascripts/error_tracking/store/list/mutation_types.js
index c3468b7eabd..23495cbf01d 100644
--- a/app/assets/javascripts/error_tracking/store/list/mutation_types.js
+++ b/app/assets/javascripts/error_tracking/store/list/mutation_types.js
@@ -9,3 +9,4 @@ export const SET_ENDPOINT = 'SET_ENDPOINT';
export const SET_SORT_FIELD = 'SET_SORT_FIELD';
export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY';
export const SET_CURSOR = 'SET_CURSOR';
+export const REMOVE_IGNORED_RESOLVED_ERRORS = 'REMOVE_IGNORED_RESOLVED_ERRORS';
diff --git a/app/assets/javascripts/error_tracking/store/list/mutations.js b/app/assets/javascripts/error_tracking/store/list/mutations.js
index dd5cde0576a..38d156263fb 100644
--- a/app/assets/javascripts/error_tracking/store/list/mutations.js
+++ b/app/assets/javascripts/error_tracking/store/list/mutations.js
@@ -59,4 +59,7 @@ export default {
[types.SET_ENDPOINT](state, endpoint) {
state.endpoint = endpoint;
},
+ [types.REMOVE_IGNORED_RESOLVED_ERRORS](state, error) {
+ state.errors = state.errors.filter(err => err.id !== error);
+ },
};
diff --git a/app/assets/javascripts/error_tracking/store/mutation_types.js b/app/assets/javascripts/error_tracking/store/mutation_types.js
index 30aebacbedd..a7ac6ab2e60 100644
--- a/app/assets/javascripts/error_tracking/store/mutation_types.js
+++ b/app/assets/javascripts/error_tracking/store/mutation_types.js
@@ -1,2 +1,3 @@
export const SET_UPDATING_RESOLVE_STATUS = 'SET_UPDATING_RESOLVE_STATUS';
export const SET_UPDATING_IGNORE_STATUS = 'SET_UPDATING_IGNORE_STATUS';
+export const SET_ERROR_STATUS = 'SET_ERROR_STATUS';
diff --git a/app/assets/javascripts/error_tracking/store/mutations.js b/app/assets/javascripts/error_tracking/store/mutations.js
index c7a7e46df40..8f2d9bcbe85 100644
--- a/app/assets/javascripts/error_tracking/store/mutations.js
+++ b/app/assets/javascripts/error_tracking/store/mutations.js
@@ -7,4 +7,7 @@ export default {
[types.SET_UPDATING_RESOLVE_STATUS](state, updating) {
state.updatingResolveStatus = updating;
},
+ [types.SET_ERROR_STATUS](state, status) {
+ state.errorStatus = status;
+ },
};
diff --git a/app/assets/javascripts/error_tracking_settings/store/getters.js b/app/assets/javascripts/error_tracking_settings/store/getters.js
index d77e5f15469..e27fe9c079e 100644
--- a/app/assets/javascripts/error_tracking_settings/store/getters.js
+++ b/app/assets/javascripts/error_tracking_settings/store/getters.js
@@ -1,4 +1,4 @@
-import _ from 'underscore';
+import { isMatch } from 'lodash';
import { __, s__, sprintf } from '~/locale';
import { getDisplayName } from '../utils';
@@ -7,7 +7,7 @@ export const hasProjects = state => Boolean(state.projects) && state.projects.le
export const isProjectInvalid = (state, getters) =>
Boolean(state.selectedProject) &&
getters.hasProjects &&
- !state.projects.some(project => _.isMatch(state.selectedProject, project));
+ !state.projects.some(project => isMatch(state.selectedProject, project));
export const dropdownLabel = (state, getters) => {
if (state.selectedProject !== null) {
diff --git a/app/assets/javascripts/error_tracking_settings/store/mutations.js b/app/assets/javascripts/error_tracking_settings/store/mutations.js
index 133f25264b9..e1986eb694b 100644
--- a/app/assets/javascripts/error_tracking_settings/store/mutations.js
+++ b/app/assets/javascripts/error_tracking_settings/store/mutations.js
@@ -1,4 +1,4 @@
-import _ from 'underscore';
+import { pick } from 'lodash';
import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import * as types from './mutation_types';
import { projectKeys } from '../utils';
@@ -12,7 +12,7 @@ export default {
.map(convertObjectPropsToCamelCase)
// The `pick` strips out extra properties returned from Sentry.
// Such properties could be problematic later, e.g. when checking whether `projects` contains `selectedProject`
- .map(project => _.pick(project, projectKeys));
+ .map(project => pick(project, projectKeys));
},
[types.RESET_CONNECT](state) {
state.connectSuccessful = false;
@@ -29,10 +29,7 @@ export default {
state.operationsSettingsEndpoint = operationsSettingsEndpoint;
if (project) {
- state.selectedProject = _.pick(
- convertObjectPropsToCamelCase(JSON.parse(project)),
- projectKeys,
- );
+ state.selectedProject = pick(convertObjectPropsToCamelCase(JSON.parse(project)), projectKeys);
}
},
[types.UPDATE_API_HOST](state, apiHost) {
diff --git a/app/assets/javascripts/error_tracking_settings/utils.js b/app/assets/javascripts/error_tracking_settings/utils.js
index 6613e04ee0e..450e8728121 100644
--- a/app/assets/javascripts/error_tracking_settings/utils.js
+++ b/app/assets/javascripts/error_tracking_settings/utils.js
@@ -13,6 +13,6 @@ export const transformFrontendSettings = ({ apiHost, enabled, token, selectedPro
return { api_host: apiHost || null, enabled, token: token || null, project };
};
-export const getDisplayName = project => `${project.organizationName} | ${project.name}`;
+export const getDisplayName = project => `${project.organizationName} | ${project.slug}`;
export default () => {};
diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
index fa2609a3176..e2909333d74 100644
--- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
+++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
@@ -59,21 +59,25 @@ export default {
</script>
<template>
<div>
- <div v-if="!isLocalStorageAvailable" class="dropdown-info-note">
+ <div v-if="!isLocalStorageAvailable" ref="localStorageNote" class="dropdown-info-note">
{{ __('This feature requires local storage to be enabled') }}
</div>
<ul v-else-if="hasItems">
- <li v-for="(item, index) in processedItems" :key="`processed-items-${index}`">
+ <li
+ v-for="(item, index) in processedItems"
+ ref="dropdownItem"
+ :key="`processed-items-${index}`"
+ >
<button
type="button"
- class="filtered-search-history-dropdown-item"
+ class="filtered-search-history-dropdown-item js-dropdown-button"
@click="onItemActivated(item.text)"
>
<span>
<span
v-for="(token, tokenIndex) in item.tokens"
:key="`dropdown-token-${tokenIndex}`"
- class="filtered-search-history-dropdown-token"
+ class="filtered-search-history-dropdown-token js-dropdown-token"
>
<span class="name">{{ token.prefix }}</span>
<span class="name">{{ token.operator }}</span>
@@ -88,6 +92,7 @@ export default {
<li class="divider"></li>
<li>
<button
+ ref="clearButton"
type="button"
class="filtered-search-history-clear-button"
@click="onRequestClearRecentSearches($event)"
@@ -96,6 +101,8 @@ export default {
</button>
</li>
</ul>
- <div v-else class="dropdown-info-note">{{ __("You don't have any recent searches") }}</div>
+ <div v-else ref="dropdownNote" class="dropdown-info-note">
+ {{ __("You don't have any recent searches") }}
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/filtered_search/dropdown_operator.js b/app/assets/javascripts/filtered_search/dropdown_operator.js
index bd4fda29609..d9794e326f8 100644
--- a/app/assets/javascripts/filtered_search/dropdown_operator.js
+++ b/app/assets/javascripts/filtered_search/dropdown_operator.js
@@ -45,13 +45,13 @@ export default class DropdownOperator extends FilteredSearchDropdown {
tag: 'equal',
type: 'string',
title: '=',
- help: __('Is'),
+ help: __('is'),
},
{
tag: 'not-equal',
type: 'string',
title: '!=',
- help: __('Is not'),
+ help: __('is not'),
},
];
this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
index 72565c2ca13..2b6e1f25dc6 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
@@ -12,7 +12,7 @@ export default class FilteredSearchDropdown {
this.filter = filter;
this.dropdown = dropdown;
this.loadingTemplate = `<div class="filter-dropdown-loading">
- <i class="fa fa-spinner fa-spin"></i>
+ <span class="spinner"></span>
</div>`;
this.bindEvents();
}
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index 2c3320b5e79..4d62ec6e385 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -1,10 +1,17 @@
import _ from 'underscore';
import { spriteIcon } from './lib/utils/common_utils';
+const FLASH_TYPES = {
+ ALERT: 'alert',
+ NOTICE: 'notice',
+ SUCCESS: 'success',
+ WARNING: 'warning',
+};
+
const hideFlash = (flashEl, fadeTransition = true) => {
if (fadeTransition) {
Object.assign(flashEl.style, {
- transition: 'opacity .3s',
+ transition: 'opacity 0.15s',
opacity: '0',
});
}
@@ -59,7 +66,7 @@ const removeFlashClickListener = (flashEl, fadeTransition) => {
* additional action or link on banner next to message
*
* @param {String} message Flash message text
- * @param {String} type Type of Flash, it can be `notice` or `alert` (default)
+ * @param {String} type Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default)
* @param {Object} parent Reference to parent element under which Flash needs to appear
* @param {Object} actonConfig Map of config to show action on banner
* @param {String} href URL to which action config should point to (default: '#')
@@ -69,7 +76,7 @@ const removeFlashClickListener = (flashEl, fadeTransition) => {
*/
const createFlash = function createFlash(
message,
- type = 'alert',
+ type = FLASH_TYPES.ALERT,
parent = document,
actionConfig = null,
fadeTransition = true,
@@ -102,5 +109,12 @@ const createFlash = function createFlash(
return flashContainer;
};
-export { createFlash as default, createFlashEl, createAction, hideFlash, removeFlashClickListener };
+export {
+ createFlash as default,
+ createFlashEl,
+ createAction,
+ hideFlash,
+ removeFlashClickListener,
+ FLASH_TYPES,
+};
window.Flash = createFlash;
diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
index 92c3bcb5012..6188d41ae96 100644
--- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
+++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
@@ -54,8 +54,8 @@ export default {
<template>
<li class="frequent-items-list-item-container">
<a :href="webUrl" class="clearfix">
- <div class="frequent-items-item-avatar-container">
- <img v-if="hasAvatar" :src="avatarUrl" class="avatar rect-avatar s32" />
+ <div class="frequent-items-item-avatar-container avatar-container rect-avatar s32">
+ <img v-if="hasAvatar" :src="avatarUrl" class="avatar s32" />
<identicon
v-else
:entity-id="itemId"
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index de69daf5c22..fa2e3f94f87 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
-import 'at.js';
+import '@gitlab/at.js';
import _ from 'underscore';
+import SidebarMediator from '~/sidebar/sidebar_mediator';
import glRegexp from './lib/utils/regexp';
import AjaxCache from './lib/utils/ajax_cache';
import { spriteIcon } from './lib/utils/common_utils';
@@ -53,8 +54,8 @@ export const defaultAutocompleteConfig = {
};
class GfmAutoComplete {
- constructor(dataSources) {
- this.dataSources = dataSources || {};
+ constructor(dataSources = {}) {
+ this.dataSources = dataSources;
this.cachedData = {};
this.isLoadingData = {};
}
@@ -199,6 +200,16 @@ class GfmAutoComplete {
}
setupMembers($input) {
+ const fetchData = this.fetchData.bind(this);
+ const MEMBER_COMMAND = {
+ ASSIGN: '/assign',
+ UNASSIGN: '/unassign',
+ REASSIGN: '/reassign',
+ CC: '/cc',
+ };
+ let assignees = [];
+ let command = '';
+
// Team Members
$input.atwho({
at: '@',
@@ -225,6 +236,48 @@ class GfmAutoComplete {
callbacks: {
...this.getDefaultCallbacks(),
beforeSave: membersBeforeSave,
+ matcher(flag, subtext) {
+ const subtextNodes = subtext
+ .split(/\n+/g)
+ .pop()
+ .split(GfmAutoComplete.regexSubtext);
+
+ // Check if @ is followed by '/assign', '/reassign', '/unassign' or '/cc' commands.
+ command = subtextNodes.find(node => {
+ if (Object.values(MEMBER_COMMAND).includes(node)) {
+ return node;
+ }
+ return null;
+ });
+
+ // Cache assignees list for easier filtering later
+ assignees = SidebarMediator.singleton?.store?.assignees?.map(
+ assignee => `${assignee.username} ${assignee.name}`,
+ );
+
+ const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
+ return match && match.length ? match[1] : null;
+ },
+ filter(query, data, searchKey) {
+ if (GfmAutoComplete.isLoading(data)) {
+ fetchData(this.$inputor, this.at);
+ return data;
+ }
+
+ if (data === GfmAutoComplete.defaultLoadingData) {
+ return $.fn.atwho.default.callbacks.filter(query, data, searchKey);
+ }
+
+ if (command === MEMBER_COMMAND.ASSIGN) {
+ // Only include members which are not assigned to Issuable currently
+ return data.filter(member => !assignees.includes(member.search));
+ } else if (command === MEMBER_COMMAND.UNASSIGN) {
+ // Only include members which are assigned to Issuable currently
+ return data.filter(member => assignees.includes(member.search));
+ }
+
+ return data;
+ },
},
});
}
@@ -666,7 +719,7 @@ GfmAutoComplete.Milestones = {
};
GfmAutoComplete.Loading = {
template:
- '<li style="pointer-events: none;"><i class="fa fa-spinner fa-spin"></i> Loading...</li>',
+ '<li style="pointer-events: none;"><span class="spinner align-text-bottom mr-1"></span>Loading...</li>',
};
export default GfmAutoComplete;
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index 65d05887453..918276ce329 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, one-var, 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 */
+/* eslint-disable max-classes-per-file, one-var, consistent-return */
import $ from 'jquery';
import _ from 'underscore';
@@ -32,121 +32,124 @@ const FILTER_INPUT = '.dropdown-input .dropdown-input-field:not(.dropdown-no-fil
const NO_FILTER_INPUT = '.dropdown-input .dropdown-input-field.dropdown-no-filter';
-function GitLabDropdownInput(input, options) {
- const _this = this;
- this.input = input;
- this.options = options;
- this.fieldName = this.options.fieldName || 'field-name';
- const $inputContainer = this.input.parent();
- const $clearButton = $inputContainer.find('.js-dropdown-input-clear');
- $clearButton.on('click', e => {
- // Clear click
- e.preventDefault();
- e.stopPropagation();
- return this.input
- .val('')
- .trigger('input')
- .focus();
- });
-
- this.input
- .on('keydown', e => {
- const keyCode = e.which;
- if (keyCode === 13 && !options.elIsInput) {
- e.preventDefault();
- }
- })
- .on('input', e => {
- let val = e.currentTarget.value || _this.options.inputFieldName;
- val = val
- .split(' ')
- .join('-') // replaces space with dash
- .replace(/[^a-zA-Z0-9 -]/g, '')
- .toLowerCase() // replace non alphanumeric
- .replace(/(-)\1+/g, '-'); // replace repeated dashes
- _this.cb(_this.options.fieldName, val, {}, true);
- _this.input
- .closest('.dropdown')
- .find('.dropdown-toggle-text')
- .text(val);
+class GitLabDropdownInput {
+ constructor(input, options) {
+ this.input = input;
+ this.options = options;
+ this.fieldName = this.options.fieldName || 'field-name';
+ const $inputContainer = this.input.parent();
+ const $clearButton = $inputContainer.find('.js-dropdown-input-clear');
+ $clearButton.on('click', e => {
+ // Clear click
+ e.preventDefault();
+ e.stopPropagation();
+ return this.input
+ .val('')
+ .trigger('input')
+ .focus();
});
-}
-GitLabDropdownInput.prototype.onInput = function(cb) {
- this.cb = cb;
-};
+ this.input
+ .on('keydown', e => {
+ const keyCode = e.which;
+ if (keyCode === 13 && !options.elIsInput) {
+ e.preventDefault();
+ }
+ })
+ .on('input', e => {
+ let val = e.currentTarget.value || this.options.inputFieldName;
+ val = val
+ .split(' ')
+ .join('-') // replaces space with dash
+ .replace(/[^a-zA-Z0-9 -]/g, '')
+ .toLowerCase() // replace non alphanumeric
+ .replace(/(-)\1+/g, '-'); // replace repeated dashes
+ this.cb(this.options.fieldName, val, {}, true);
+ this.input
+ .closest('.dropdown')
+ .find('.dropdown-toggle-text')
+ .text(val);
+ });
+ }
-function GitLabDropdownFilter(input, options) {
- let ref, timeout;
- this.input = input;
- this.options = options;
- this.filterInputBlur = (ref = this.options.filterInputBlur) != null ? ref : true;
- const $inputContainer = this.input.parent();
- const $clearButton = $inputContainer.find('.js-dropdown-input-clear');
- $clearButton.on('click', e => {
- // Clear click
- e.preventDefault();
- e.stopPropagation();
- return this.input
- .val('')
- .trigger('input')
- .focus();
- });
- // Key events
- timeout = '';
- this.input
- .on('keydown', e => {
- const keyCode = e.which;
- if (keyCode === 13 && !options.elIsInput) {
- e.preventDefault();
- }
- })
- .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());
- }
- });
+ onInput(cb) {
+ this.cb = cb;
+ }
}
-GitLabDropdownFilter.prototype.shouldBlur = function(keyCode) {
- return BLUR_KEYCODES.indexOf(keyCode) !== -1;
-};
+class GitLabDropdownFilter {
+ constructor(input, options) {
+ let ref, timeout;
+ this.input = input;
+ this.options = options;
+ // eslint-disable-next-line no-cond-assign
+ this.filterInputBlur = (ref = this.options.filterInputBlur) != null ? ref : true;
+ const $inputContainer = this.input.parent();
+ const $clearButton = $inputContainer.find('.js-dropdown-input-clear');
+ $clearButton.on('click', e => {
+ // Clear click
+ e.preventDefault();
+ e.stopPropagation();
+ return this.input
+ .val('')
+ .trigger('input')
+ .focus();
+ });
+ // Key events
+ timeout = '';
+ this.input
+ .on('keydown', e => {
+ const keyCode = e.which;
+ if (keyCode === 13 && !options.elIsInput) {
+ e.preventDefault();
+ }
+ })
+ .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);
+ // eslint-disable-next-line no-return-assign
+ 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));
+ }
+ return this.filter(this.input.val());
+ });
+ }
-GitLabDropdownFilter.prototype.filter = function(search_text) {
- let elements, group, key, results, tmp;
- if (this.options.onFilter) {
- this.options.onFilter(search_text);
- }
- const data = this.options.data();
- if (data != null && !this.options.filterByText) {
- results = data;
- if (search_text !== '') {
- // When data is an array of objects therefore [object Array] e.g.
- // [
- // { prop: 'foo' },
- // { prop: 'baz' }
- // ]
- if (_.isArray(data)) {
- results = fuzzaldrinPlus.filter(data, search_text, {
- key: this.options.keys,
- });
- } else {
+ static shouldBlur(keyCode) {
+ return BLUR_KEYCODES.indexOf(keyCode) !== -1;
+ }
+
+ filter(searchText) {
+ let group, results, tmp;
+ if (this.options.onFilter) {
+ this.options.onFilter(searchText);
+ }
+ const data = this.options.data();
+ if (data != null && !this.options.filterByText) {
+ results = data;
+ if (searchText !== '') {
+ // When data is an array of objects therefore [object Array] e.g.
+ // [
+ // { prop: 'foo' },
+ // { prop: 'baz' }
+ // ]
+ if (_.isArray(data)) {
+ results = fuzzaldrinPlus.filter(data, searchText, {
+ key: this.options.keys,
+ });
+ }
// If data is grouped therefore an [object Object]. e.g.
// {
// groupName1: [
@@ -158,33 +161,32 @@ GitLabDropdownFilter.prototype.filter = function(search_text) {
// { prop: 'def' }
// ]
// }
- if (isObject(data)) {
+ else if (isObject(data)) {
results = {};
- for (key in data) {
+ Object.keys(data).forEach(key => {
group = data[key];
- tmp = fuzzaldrinPlus.filter(group, search_text, {
+ tmp = fuzzaldrinPlus.filter(group, searchText, {
key: this.options.keys,
});
if (tmp.length) {
results[key] = tmp.map(item => item);
}
- }
+ });
}
}
+ return this.options.callback(results);
}
- return this.options.callback(results);
- } else {
- elements = this.options.elements();
- if (search_text) {
+ const elements = this.options.elements();
+ if (searchText) {
+ // eslint-disable-next-line func-names
elements.each(function() {
const $el = $(this);
- const matches = fuzzaldrinPlus.match($el.text().trim(), search_text);
+ const matches = fuzzaldrinPlus.match($el.text().trim(), searchText);
if (!$el.is('.dropdown-header')) {
if (matches.length) {
return $el.show().removeClass('option-hidden');
- } else {
- return $el.hide().addClass('option-hidden');
}
+ return $el.hide().addClass('option-hidden');
}
});
} else {
@@ -196,235 +198,240 @@ GitLabDropdownFilter.prototype.filter = function(search_text) {
.find('.dropdown-menu-empty-item')
.toggleClass('hidden', elements.is(':visible'));
}
-};
-
-function GitLabDropdownRemote(dataEndpoint, options) {
- this.dataEndpoint = dataEndpoint;
- this.options = options;
}
-GitLabDropdownRemote.prototype.execute = function() {
- if (typeof this.dataEndpoint === 'string') {
- return this.fetchData();
- } else if (typeof this.dataEndpoint === 'function') {
+class GitLabDropdownRemote {
+ constructor(dataEndpoint, options) {
+ this.dataEndpoint = dataEndpoint;
+ this.options = options;
+ }
+
+ execute() {
+ if (typeof this.dataEndpoint === 'string') {
+ return this.fetchData();
+ } else if (typeof this.dataEndpoint === 'function') {
+ if (this.options.beforeSend) {
+ this.options.beforeSend();
+ }
+ return this.dataEndpoint('', data => {
+ // Fetch the data by calling the data function
+ if (this.options.success) {
+ this.options.success(data);
+ }
+ if (this.options.beforeSend) {
+ return this.options.beforeSend();
+ }
+ });
+ }
+ }
+
+ fetchData() {
if (this.options.beforeSend) {
this.options.beforeSend();
}
- return this.dataEndpoint('', data => {
- // Fetch the data by calling the data function
+
+ // Fetch the data through ajax if the data is a string
+ return axios.get(this.dataEndpoint).then(({ data }) => {
if (this.options.success) {
- this.options.success(data);
- }
- if (this.options.beforeSend) {
- return this.options.beforeSend();
+ return this.options.success(data);
}
});
}
-};
-
-GitLabDropdownRemote.prototype.fetchData = function() {
- if (this.options.beforeSend) {
- this.options.beforeSend();
- }
+}
- // Fetch the data through ajax if the data is a string
- return axios.get(this.dataEndpoint).then(({ data }) => {
- if (this.options.success) {
- return this.options.success(data);
+class GitLabDropdown {
+ constructor(el1, options) {
+ let selector, self;
+ this.el = el1;
+ this.options = options;
+ this.updateLabel = this.updateLabel.bind(this);
+ this.hidden = this.hidden.bind(this);
+ this.opened = this.opened.bind(this);
+ this.shouldPropagate = this.shouldPropagate.bind(this);
+ self = this;
+ selector = $(this.el).data('target');
+ this.dropdown = selector != null ? $(selector) : $(this.el).parent();
+ // Set Defaults
+ this.filterInput = this.options.filterInput || this.getElement(FILTER_INPUT);
+ this.noFilterInput = this.options.noFilterInput || this.getElement(NO_FILTER_INPUT);
+ this.highlight = Boolean(this.options.highlight);
+ this.icon = Boolean(this.options.icon);
+ this.filterInputBlur =
+ this.options.filterInputBlur != null ? this.options.filterInputBlur : true;
+ // If no input is passed create a default one
+ self = this;
+ // If selector was passed
+ if (_.isString(this.filterInput)) {
+ this.filterInput = this.getElement(this.filterInput);
}
- });
-};
-
-function GitLabDropdown(el1, options) {
- let selector, self;
- this.el = el1;
- this.options = options;
- this.updateLabel = this.updateLabel.bind(this);
- this.hidden = this.hidden.bind(this);
- this.opened = this.opened.bind(this);
- this.shouldPropagate = this.shouldPropagate.bind(this);
- self = this;
- selector = $(this.el).data('target');
- this.dropdown = selector != null ? $(selector) : $(this.el).parent();
- // Set Defaults
- this.filterInput = this.options.filterInput || this.getElement(FILTER_INPUT);
- this.noFilterInput = this.options.noFilterInput || this.getElement(NO_FILTER_INPUT);
- this.highlight = Boolean(this.options.highlight);
- this.icon = Boolean(this.options.icon);
- this.filterInputBlur = this.options.filterInputBlur != null ? this.options.filterInputBlur : true;
- // If no input is passed create a default one
- self = this;
- // If selector was passed
- if (_.isString(this.filterInput)) {
- this.filterInput = this.getElement(this.filterInput);
- }
- const searchFields = this.options.search ? this.options.search.fields : [];
- if (this.options.data) {
- // If we provided data
- // data could be an array of objects or a group of arrays
- if (_.isObject(this.options.data) && !_.isFunction(this.options.data)) {
- this.fullData = this.options.data;
- currentIndex = -1;
- this.parseData(this.options.data);
- this.focusTextInput();
- } else {
- this.remote = new GitLabDropdownRemote(this.options.data, {
- dataType: this.options.dataType,
- beforeSend: this.toggleLoading.bind(this),
- success: data => {
- this.fullData = data;
- this.parseData(this.fullData);
- this.focusTextInput();
-
- // Update dropdown position since remote data may have changed dropdown size
- this.dropdown.find('.dropdown-menu-toggle').dropdown('update');
-
- if (
- this.options.filterable &&
- this.filter &&
- this.filter.input &&
- this.filter.input.val() &&
- this.filter.input.val().trim() !== ''
- ) {
- return this.filter.input.trigger('input');
- }
- },
- instance: this,
- });
+ const searchFields = this.options.search ? this.options.search.fields : [];
+ if (this.options.data) {
+ // If we provided data
+ // data could be an array of objects or a group of arrays
+ if (_.isObject(this.options.data) && !_.isFunction(this.options.data)) {
+ this.fullData = this.options.data;
+ currentIndex = -1;
+ this.parseData(this.options.data);
+ this.focusTextInput();
+ } else {
+ this.remote = new GitLabDropdownRemote(this.options.data, {
+ dataType: this.options.dataType,
+ beforeSend: this.toggleLoading.bind(this),
+ success: data => {
+ this.fullData = data;
+ this.parseData(this.fullData);
+ this.focusTextInput();
+
+ // Update dropdown position since remote data may have changed dropdown size
+ this.dropdown.find('.dropdown-menu-toggle').dropdown('update');
+
+ if (
+ this.options.filterable &&
+ this.filter &&
+ this.filter.input &&
+ this.filter.input.val() &&
+ this.filter.input.val().trim() !== ''
+ ) {
+ return this.filter.input.trigger('input');
+ }
+ },
+ instance: this,
+ });
+ }
}
- }
- if (this.noFilterInput.length) {
- this.plainInput = new GitLabDropdownInput(this.noFilterInput, this.options);
- this.plainInput.onInput(this.addInput.bind(this));
- }
- // Init filterable
- if (this.options.filterable) {
- this.filter = new GitLabDropdownFilter(this.filterInput, {
- elIsInput: $(this.el).is('input'),
- filterInputBlur: this.filterInputBlur,
- filterByText: this.options.filterByText,
- onFilter: this.options.onFilter,
- remote: this.options.filterRemote,
- query: this.options.data,
- keys: searchFields,
- instance: this,
- elements: () => {
- selector = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES})`;
- if (this.dropdown.find('.dropdown-toggle-page').length) {
- selector = `.dropdown-page-one ${selector}`;
- }
- return $(selector, this.dropdown);
- },
- data: () => this.fullData,
- callback: data => {
- this.parseData(data);
- if (this.filterInput.val() !== '') {
- selector = SELECTABLE_CLASSES;
+ if (this.noFilterInput.length) {
+ this.plainInput = new GitLabDropdownInput(this.noFilterInput, this.options);
+ this.plainInput.onInput(this.addInput.bind(this));
+ }
+ // Init filterable
+ if (this.options.filterable) {
+ this.filter = new GitLabDropdownFilter(this.filterInput, {
+ elIsInput: $(this.el).is('input'),
+ filterInputBlur: this.filterInputBlur,
+ filterByText: this.options.filterByText,
+ onFilter: this.options.onFilter,
+ remote: this.options.filterRemote,
+ query: this.options.data,
+ keys: searchFields,
+ instance: this,
+ elements: () => {
+ selector = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES})`;
if (this.dropdown.find('.dropdown-toggle-page').length) {
selector = `.dropdown-page-one ${selector}`;
}
- if ($(this.el).is('input')) {
- currentIndex = -1;
- } else {
- $(selector, this.dropdown)
- .first()
- .find('a')
- .addClass('is-focused');
- currentIndex = 0;
+ return $(selector, this.dropdown);
+ },
+ data: () => this.fullData,
+ callback: data => {
+ this.parseData(data);
+ if (this.filterInput.val() !== '') {
+ selector = SELECTABLE_CLASSES;
+ if (this.dropdown.find('.dropdown-toggle-page').length) {
+ selector = `.dropdown-page-one ${selector}`;
+ }
+ if ($(this.el).is('input')) {
+ currentIndex = -1;
+ } else {
+ $(selector, this.dropdown)
+ .first()
+ .find('a')
+ .addClass('is-focused');
+ currentIndex = 0;
+ }
}
- }
- },
- });
- }
- // Event listeners
- this.dropdown.on('shown.bs.dropdown', this.opened);
- this.dropdown.on('hidden.bs.dropdown', this.hidden);
- $(this.el).on('update.label', this.updateLabel);
- this.dropdown.on('click', '.dropdown-menu, .dropdown-menu-close', this.shouldPropagate);
- this.dropdown.on('keyup', e => {
- // Escape key
- if (e.which === 27) {
- return $('.dropdown-menu-close', this.dropdown).trigger('click');
+ },
+ });
}
- });
- this.dropdown.on('blur', 'a', e => {
- let $dropdownMenu, $relatedTarget;
- if (e.relatedTarget != null) {
- $relatedTarget = $(e.relatedTarget);
- $dropdownMenu = $relatedTarget.closest('.dropdown-menu');
- if ($dropdownMenu.length === 0) {
- return this.dropdown.removeClass('show');
+ // Event listeners
+ this.dropdown.on('shown.bs.dropdown', this.opened);
+ this.dropdown.on('hidden.bs.dropdown', this.hidden);
+ $(this.el).on('update.label', this.updateLabel);
+ this.dropdown.on('click', '.dropdown-menu, .dropdown-menu-close', this.shouldPropagate);
+ this.dropdown.on('keyup', e => {
+ // Escape key
+ if (e.which === 27) {
+ return $('.dropdown-menu-close', this.dropdown).trigger('click');
+ }
+ });
+ this.dropdown.on('blur', 'a', e => {
+ let $dropdownMenu, $relatedTarget;
+ if (e.relatedTarget != null) {
+ $relatedTarget = $(e.relatedTarget);
+ $dropdownMenu = $relatedTarget.closest('.dropdown-menu');
+ if ($dropdownMenu.length === 0) {
+ return this.dropdown.removeClass('show');
+ }
}
- }
- });
- if (this.dropdown.find('.dropdown-toggle-page').length) {
- this.dropdown.find('.dropdown-toggle-page, .dropdown-menu-back').on('click', e => {
- e.preventDefault();
- e.stopPropagation();
- return this.togglePage();
});
- }
- if (this.options.selectable) {
- selector = '.dropdown-content a';
if (this.dropdown.find('.dropdown-toggle-page').length) {
- selector = '.dropdown-page-one .dropdown-content a';
- }
- this.dropdown.on('click', selector, e => {
- const $el = $(e.currentTarget);
- const selected = self.rowClicked($el);
- const selectedObj = selected ? selected[0] : null;
- const isMarking = selected ? selected[1] : null;
- if (this.options.clicked) {
- this.options.clicked.call(this, {
- selectedObj,
- $el,
- e,
- isMarking,
- });
+ this.dropdown.find('.dropdown-toggle-page, .dropdown-menu-back').on('click', e => {
+ e.preventDefault();
+ e.stopPropagation();
+ return this.togglePage();
+ });
+ }
+ if (this.options.selectable) {
+ selector = '.dropdown-content a';
+ if (this.dropdown.find('.dropdown-toggle-page').length) {
+ selector = '.dropdown-page-one .dropdown-content a';
}
+ this.dropdown.on('click', selector, e => {
+ const $el = $(e.currentTarget);
+ const selected = self.rowClicked($el);
+ const selectedObj = selected ? selected[0] : null;
+ const 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');
- });
+ $el.trigger('blur');
+ });
+ }
}
-}
-// Finds an element inside wrapper element
-GitLabDropdown.prototype.getElement = function(selector) {
- return this.dropdown.find(selector);
-};
+ // Finds an element inside wrapper element
+ getElement(selector) {
+ return this.dropdown.find(selector);
+ }
-GitLabDropdown.prototype.toggleLoading = function() {
- return $('.dropdown-menu', this.dropdown).toggleClass(LOADING_CLASS);
-};
+ toggleLoading() {
+ return $('.dropdown-menu', this.dropdown).toggleClass(LOADING_CLASS);
+ }
-GitLabDropdown.prototype.togglePage = function() {
- const menu = $('.dropdown-menu', this.dropdown);
- if (menu.hasClass(PAGE_TWO_CLASS)) {
- if (this.remote) {
- this.remote.execute();
+ togglePage() {
+ const menu = $('.dropdown-menu', this.dropdown);
+ if (menu.hasClass(PAGE_TWO_CLASS)) {
+ if (this.remote) {
+ this.remote.execute();
+ }
}
+ menu.toggleClass(PAGE_TWO_CLASS);
+ // Focus first visible input on active page
+ return this.dropdown.find('[class^="dropdown-page-"]:visible :text:visible:first').focus();
}
- menu.toggleClass(PAGE_TWO_CLASS);
- // Focus first visible input on active page
- return this.dropdown.find('[class^="dropdown-page-"]:visible :text:visible:first').focus();
-};
-GitLabDropdown.prototype.parseData = function(data) {
- let groupData, html, name;
- this.renderedData = data;
- if (this.options.filterable && data.length === 0) {
- // render no matching results
- html = [this.noResults()];
- } else {
+ parseData(data) {
+ let groupData, html;
+ this.renderedData = data;
+ if (this.options.filterable && data.length === 0) {
+ // render no matching results
+ html = [this.noResults()];
+ }
// Handle array groups
- if (isObject(data)) {
+ else if (isObject(data)) {
html = [];
- for (name in data) {
+
+ Object.keys(data).forEach(name => {
groupData = data[name];
html.push(
this.renderItem(
@@ -436,461 +443,455 @@ GitLabDropdown.prototype.parseData = function(data) {
),
);
this.renderData(groupData, name).map(item => html.push(item));
- }
+ });
} else {
// Render each row
html = this.renderData(data);
}
- }
- // Render the full menu
- const full_html = this.renderMenu(html);
- return this.appendMenu(full_html);
-};
-
-GitLabDropdown.prototype.renderData = function(data, group) {
- return data.map((obj, index) => this.renderItem(obj, group || false, index));
-};
-
-GitLabDropdown.prototype.shouldPropagate = function(e) {
- let $target;
- if (this.options.multiSelect || this.options.shouldPropagate === false) {
- $target = $(e.target);
- if (
- $target &&
- !$target.hasClass('dropdown-menu-close') &&
- !$target.hasClass('dropdown-menu-close-icon') &&
- !$target.data('isLink')
- ) {
- e.stopPropagation();
-
- // This prevents automatic scrolling to the top
- if ($target.closest('a').length) {
- return false;
+ // Render the full menu
+ const fullHtml = this.renderMenu(html);
+ return this.appendMenu(fullHtml);
+ }
+
+ renderData(data, group) {
+ return data.map((obj, index) => this.renderItem(obj, group || false, index));
+ }
+
+ shouldPropagate(e) {
+ let $target;
+ if (this.options.multiSelect || this.options.shouldPropagate === false) {
+ $target = $(e.target);
+ if (
+ $target &&
+ !$target.hasClass('dropdown-menu-close') &&
+ !$target.hasClass('dropdown-menu-close-icon') &&
+ !$target.data('isLink')
+ ) {
+ e.stopPropagation();
+
+ // This prevents automatic scrolling to the top
+ if ($target.closest('a').length) {
+ return false;
+ }
}
- }
- return true;
+ return true;
+ }
}
-};
-
-GitLabDropdown.prototype.filteredFullData = function() {
- return this.fullData.filter(
- r =>
- typeof r === 'object' &&
- !Object.prototype.hasOwnProperty.call(r, 'beforeDivider') &&
- !Object.prototype.hasOwnProperty.call(r, 'header'),
- );
-};
-GitLabDropdown.prototype.opened = function(e) {
- this.resetRows();
- this.addArrowKeyEvent();
-
- const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle');
- const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update');
- const shouldRefreshOnOpen = dropdownToggle.hasClass('js-gl-dropdown-refresh-on-open');
- const hasMultiSelect = dropdownToggle.hasClass('js-multiselect');
-
- // Makes indeterminate items effective
- if (this.fullData && (shouldRefreshOnOpen || hasFilterBulkUpdate)) {
- this.parseData(this.fullData);
- }
-
- // Process the data to make sure rendered data
- // matches the correct layout
- const inputValue = this.filterInput.val();
- if (this.fullData && hasMultiSelect && this.options.processData && inputValue.length === 0) {
- this.options.processData.call(
- this.options,
- inputValue,
- this.filteredFullData(),
- this.parseData.bind(this),
+ filteredFullData() {
+ return this.fullData.filter(
+ r =>
+ typeof r === 'object' &&
+ !Object.prototype.hasOwnProperty.call(r, 'beforeDivider') &&
+ !Object.prototype.hasOwnProperty.call(r, 'header'),
);
}
- const contentHtml = $('.dropdown-content', this.dropdown).html();
- if (this.remote && contentHtml === '') {
- this.remote.execute();
- } else {
- this.focusTextInput();
- }
+ opened(e) {
+ this.resetRows();
+ this.addArrowKeyEvent();
- if (this.options.showMenuAbove) {
- this.positionMenuAbove();
- }
+ const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle');
+ const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update');
+ const shouldRefreshOnOpen = dropdownToggle.hasClass('js-gl-dropdown-refresh-on-open');
+ const hasMultiSelect = dropdownToggle.hasClass('js-multiselect');
- if (this.options.opened) {
- if (this.options.preserveContext) {
- this.options.opened(e);
- } else {
- this.options.opened.call(this, e);
+ // Makes indeterminate items effective
+ if (this.fullData && (shouldRefreshOnOpen || hasFilterBulkUpdate)) {
+ this.parseData(this.fullData);
}
- }
- return this.dropdown.trigger('shown.gl.dropdown');
-};
+ // Process the data to make sure rendered data
+ // matches the correct layout
+ const inputValue = this.filterInput.val();
+ if (this.fullData && hasMultiSelect && this.options.processData && inputValue.length === 0) {
+ this.options.processData.call(
+ this.options,
+ inputValue,
+ this.filteredFullData(),
+ this.parseData.bind(this),
+ );
+ }
-GitLabDropdown.prototype.positionMenuAbove = function() {
- const $menu = this.dropdown.find('.dropdown-menu');
+ const contentHtml = $('.dropdown-content', this.dropdown).html();
+ if (this.remote && contentHtml === '') {
+ this.remote.execute();
+ } else {
+ this.focusTextInput();
+ }
- $menu.addClass('dropdown-open-top');
- $menu.css('top', 'initial');
- $menu.css('bottom', '100%');
-};
+ if (this.options.showMenuAbove) {
+ this.positionMenuAbove();
+ }
+
+ if (this.options.opened) {
+ if (this.options.preserveContext) {
+ this.options.opened(e);
+ } else {
+ this.options.opened.call(this, e);
+ }
+ }
-GitLabDropdown.prototype.hidden = function(e) {
- this.resetRows();
- this.removeArrowKeyEvent();
- const $input = this.dropdown.find('.dropdown-input-field');
- if (this.options.filterable) {
- $input.blur();
+ return this.dropdown.trigger('shown.gl.dropdown');
}
- if (this.dropdown.find('.dropdown-toggle-page').length) {
- $('.dropdown-menu', this.dropdown).removeClass(PAGE_TWO_CLASS);
+
+ positionMenuAbove() {
+ const $menu = this.dropdown.find('.dropdown-menu');
+
+ $menu.addClass('dropdown-open-top');
+ $menu.css('top', 'initial');
+ $menu.css('bottom', '100%');
}
- if (this.options.hidden) {
- this.options.hidden.call(this, e);
+
+ hidden(e) {
+ this.resetRows();
+ this.removeArrowKeyEvent();
+ const $input = this.dropdown.find('.dropdown-input-field');
+ if (this.options.filterable) {
+ $input.blur();
+ }
+ if (this.dropdown.find('.dropdown-toggle-page').length) {
+ $('.dropdown-menu', this.dropdown).removeClass(PAGE_TWO_CLASS);
+ }
+ if (this.options.hidden) {
+ this.options.hidden.call(this, e);
+ }
+ return this.dropdown.trigger('hidden.gl.dropdown');
}
- return this.dropdown.trigger('hidden.gl.dropdown');
-};
-// Render the full menu
-GitLabDropdown.prototype.renderMenu = function(html) {
- if (this.options.renderMenu) {
- return this.options.renderMenu(html);
- } else {
+ // Render the full menu
+ renderMenu(html) {
+ if (this.options.renderMenu) {
+ return this.options.renderMenu(html);
+ }
return $('<ul>').append(html);
}
-};
-// Append the menu into the dropdown
-GitLabDropdown.prototype.appendMenu = function(html) {
- return this.clearMenu().append(html);
-};
+ // Append the menu into the dropdown
+ appendMenu(html) {
+ return this.clearMenu().append(html);
+ }
-GitLabDropdown.prototype.clearMenu = function() {
- let selector;
- selector = '.dropdown-content';
- if (this.dropdown.find('.dropdown-toggle-page').length) {
- if (this.options.containerSelector) {
- selector = this.options.containerSelector;
- } else {
- selector = '.dropdown-page-one .dropdown-content';
+ clearMenu() {
+ let selector = '.dropdown-content';
+ if (this.dropdown.find('.dropdown-toggle-page').length) {
+ if (this.options.containerSelector) {
+ selector = this.options.containerSelector;
+ } else {
+ selector = '.dropdown-page-one .dropdown-content';
+ }
}
+
+ return $(selector, this.dropdown).empty();
}
- return $(selector, this.dropdown).empty();
-};
+ renderItem(data, group, index) {
+ let parent;
-GitLabDropdown.prototype.renderItem = function(data, group, index) {
- let parent;
-
- if (this.dropdown && this.dropdown[0]) {
- parent = this.dropdown[0].parentNode;
- }
-
- return renderItem({
- instance: this,
- options: Object.assign({}, this.options, {
- icon: this.icon,
- highlight: this.highlight,
- highlightText: text => this.highlightTextMatches(text, this.filterInput.val()),
- highlightTemplate: this.highlightTemplate.bind(this),
- parent,
- }),
- data,
- group,
- index,
- });
-};
+ if (this.dropdown && this.dropdown[0]) {
+ parent = this.dropdown[0].parentNode;
+ }
-GitLabDropdown.prototype.highlightTemplate = function(text, template) {
- return `"<b>${_.escape(text)}</b>" ${template}`;
-};
+ return renderItem({
+ instance: this,
+ options: Object.assign({}, this.options, {
+ icon: this.icon,
+ highlight: this.highlight,
+ highlightText: text => this.highlightTextMatches(text, this.filterInput.val()),
+ highlightTemplate: this.highlightTemplate.bind(this),
+ parent,
+ }),
+ data,
+ group,
+ index,
+ });
+ }
-GitLabDropdown.prototype.highlightTextMatches = function(text, term) {
- const occurrences = fuzzaldrinPlus.match(text, term);
- const { indexOf } = [];
+ // eslint-disable-next-line class-methods-use-this
+ highlightTemplate(text, template) {
+ return `"<b>${_.escape(text)}</b>" ${template}`;
+ }
- return text
- .split('')
- .map((character, i) => {
- if (indexOf.call(occurrences, i) !== -1) {
- return `<b>${character}</b>`;
- } else {
+ // eslint-disable-next-line class-methods-use-this
+ highlightTextMatches(text, term) {
+ const occurrences = fuzzaldrinPlus.match(text, term);
+ const { indexOf } = [];
+
+ return text
+ .split('')
+ .map((character, i) => {
+ if (indexOf.call(occurrences, i) !== -1) {
+ return `<b>${character}</b>`;
+ }
return character;
+ })
+ .join('');
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ noResults() {
+ return '<li class="dropdown-menu-empty-item"><a>No matching results</a></li>';
+ }
+
+ rowClicked(el) {
+ let field, groupName, selectedIndex, selectedObject, isMarking;
+ const { fieldName } = this.options;
+ const isInput = $(this.el).is('input');
+ if (this.renderedData) {
+ groupName = el.data('group');
+ if (groupName) {
+ selectedIndex = el.data('index');
+ selectedObject = this.renderedData[groupName][selectedIndex];
+ } else {
+ selectedIndex = el.closest('li').index();
+ this.selectedIndex = selectedIndex;
+ selectedObject = this.renderedData[selectedIndex];
}
- })
- .join('');
-};
+ }
-GitLabDropdown.prototype.noResults = function() {
- return '<li class="dropdown-menu-empty-item"><a>No matching results</a></li>';
-};
+ if (this.options.vue) {
+ if (el.hasClass(ACTIVE_CLASS)) {
+ el.removeClass(ACTIVE_CLASS);
+ } else {
+ el.addClass(ACTIVE_CLASS);
+ }
-GitLabDropdown.prototype.rowClicked = function(el) {
- let field, groupName, selectedIndex, selectedObject, isMarking;
- const { fieldName } = this.options;
- const isInput = $(this.el).is('input');
- if (this.renderedData) {
- groupName = el.data('group');
- if (groupName) {
- selectedIndex = el.data('index');
- selectedObject = this.renderedData[groupName][selectedIndex];
- } else {
- selectedIndex = el.closest('li').index();
- this.selectedIndex = selectedIndex;
- selectedObject = this.renderedData[selectedIndex];
+ return [selectedObject];
}
- }
- if (this.options.vue) {
- if (el.hasClass(ACTIVE_CLASS)) {
- el.removeClass(ACTIVE_CLASS);
- } else {
- el.addClass(ACTIVE_CLASS);
+ field = [];
+ const value = this.options.id ? this.options.id(selectedObject, el) : selectedObject.id;
+ if (isInput) {
+ field = $(this.el);
+ } else if (value != null) {
+ field = this.dropdown
+ .parent()
+ .find(`input[name='${fieldName}'][value='${value.toString().replace(/'/g, "\\'")}']`);
}
- return [selectedObject];
- }
+ if (this.options.isSelectable && !this.options.isSelectable(selectedObject, el)) {
+ return [selectedObject];
+ }
- field = [];
- const value = this.options.id ? this.options.id(selectedObject, el) : selectedObject.id;
- if (isInput) {
- field = $(this.el);
- } else if (value != null) {
- field = this.dropdown
- .parent()
- .find(`input[name='${fieldName}'][value='${value.toString().replace(/'/g, "\\'")}']`);
- }
-
- if (this.options.isSelectable && !this.options.isSelectable(selectedObject, el)) {
- return [selectedObject];
- }
-
- if (el.hasClass(ACTIVE_CLASS) && value !== 0) {
- isMarking = false;
- el.removeClass(ACTIVE_CLASS);
- if (field && field.length) {
- this.clearField(field, isInput);
- }
- } else if (el.hasClass(INDETERMINATE_CLASS)) {
- isMarking = true;
- el.addClass(ACTIVE_CLASS);
- el.removeClass(INDETERMINATE_CLASS);
- if (field && field.length && value == null) {
- this.clearField(field, isInput);
- }
- if ((!field || !field.length) && fieldName) {
- this.addInput(fieldName, value, selectedObject);
- }
- } else {
- isMarking = true;
- if (!this.options.multiSelect || el.hasClass('dropdown-clear-active')) {
- this.dropdown.find(`.${ACTIVE_CLASS}`).removeClass(ACTIVE_CLASS);
- if (!isInput) {
- this.dropdown
- .parent()
- .find(`input[name='${fieldName}']`)
- .remove();
+ if (el.hasClass(ACTIVE_CLASS) && value !== 0) {
+ isMarking = false;
+ el.removeClass(ACTIVE_CLASS);
+ if (field && field.length) {
+ this.clearField(field, isInput);
+ }
+ } else if (el.hasClass(INDETERMINATE_CLASS)) {
+ isMarking = true;
+ el.addClass(ACTIVE_CLASS);
+ el.removeClass(INDETERMINATE_CLASS);
+ if (field && field.length && value == null) {
+ this.clearField(field, isInput);
}
- }
- if (field && field.length && value == null) {
- this.clearField(field, isInput);
- }
- // Toggle active class for the tick mark
- el.addClass(ACTIVE_CLASS);
- if (value != null) {
if ((!field || !field.length) && fieldName) {
this.addInput(fieldName, value, selectedObject);
- } else if (field && field.length) {
- field.val(value).trigger('change');
+ }
+ } else {
+ isMarking = true;
+ if (!this.options.multiSelect || el.hasClass('dropdown-clear-active')) {
+ this.dropdown.find(`.${ACTIVE_CLASS}`).removeClass(ACTIVE_CLASS);
+ if (!isInput) {
+ this.dropdown
+ .parent()
+ .find(`input[name='${fieldName}']`)
+ .remove();
+ }
+ }
+ if (field && field.length && value == null) {
+ this.clearField(field, isInput);
+ }
+ // Toggle active class for the tick mark
+ el.addClass(ACTIVE_CLASS);
+ if (value != null) {
+ if ((!field || !field.length) && fieldName) {
+ this.addInput(fieldName, value, selectedObject);
+ } else if (field && field.length) {
+ field.val(value).trigger('change');
+ }
}
}
+
+ return [selectedObject, isMarking];
}
- return [selectedObject, isMarking];
-};
+ focusTextInput() {
+ if (this.options.filterable) {
+ const initialScrollTop = $(window).scrollTop();
-GitLabDropdown.prototype.focusTextInput = function() {
- if (this.options.filterable) {
- const initialScrollTop = $(window).scrollTop();
+ if (this.dropdown.is('.show') && !this.filterInput.is(':focus')) {
+ this.filterInput.focus();
+ }
- if (this.dropdown.is('.show') && !this.filterInput.is(':focus')) {
- this.filterInput.focus();
+ if ($(window).scrollTop() < initialScrollTop) {
+ $(window).scrollTop(initialScrollTop);
+ }
}
+ }
- if ($(window).scrollTop() < initialScrollTop) {
- $(window).scrollTop(initialScrollTop);
+ addInput(fieldName, value, selectedObject, single) {
+ // Create hidden input for form
+ if (single) {
+ $(`input[name="${fieldName}"]`).remove();
}
- }
-};
-GitLabDropdown.prototype.addInput = function(fieldName, value, selectedObject, single) {
- // Create hidden input for form
- if (single) {
- $(`input[name="${fieldName}"]`).remove();
- }
+ const $input = $('<input>')
+ .attr('type', 'hidden')
+ .attr('name', fieldName)
+ .val(value);
+ if (this.options.inputId != null) {
+ $input.attr('id', this.options.inputId);
+ }
- const $input = $('<input>')
- .attr('type', 'hidden')
- .attr('name', fieldName)
- .val(value);
- if (this.options.inputId != null) {
- $input.attr('id', this.options.inputId);
- }
+ if (this.options.multiSelect) {
+ Object.keys(selectedObject).forEach(attribute => {
+ $input.attr(`data-${attribute}`, selectedObject[attribute]);
+ });
+ }
- if (this.options.multiSelect) {
- Object.keys(selectedObject).forEach(attribute => {
- $input.attr(`data-${attribute}`, selectedObject[attribute]);
- });
- }
+ if (this.options.inputMeta) {
+ $input.attr('data-meta', selectedObject[this.options.inputMeta]);
+ }
- if (this.options.inputMeta) {
- $input.attr('data-meta', selectedObject[this.options.inputMeta]);
+ this.dropdown.before($input).trigger('change');
}
- this.dropdown.before($input).trigger('change');
-};
-
-GitLabDropdown.prototype.selectRowAtIndex = function(index) {
- let selector;
- // If we pass an option index
- if (typeof index !== 'undefined') {
- 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}`;
- }
- // simulate a click on the first link
- const $el = $(selector, this.dropdown);
- if ($el.length) {
- const href = $el.attr('href');
- if (href && href !== '#') {
- visitUrl(href);
+ selectRowAtIndex(index) {
+ // If we pass an option index
+ let selector;
+ if (typeof index !== 'undefined') {
+ selector = `${SELECTABLE_CLASSES}:eq(${index}) a`;
} else {
- $el.trigger('click');
+ selector = '.dropdown-content .is-focused';
+ }
+ if (this.dropdown.find('.dropdown-toggle-page').length) {
+ selector = `.dropdown-page-one ${selector}`;
+ }
+ // simulate a click on the first link
+ const $el = $(selector, this.dropdown);
+ if ($el.length) {
+ const href = $el.attr('href');
+ if (href && href !== '#') {
+ visitUrl(href);
+ } else {
+ $el.trigger('click');
+ }
}
}
-};
-GitLabDropdown.prototype.addArrowKeyEvent = function() {
- let selector;
- const ARROW_KEY_CODES = [38, 40];
- selector = SELECTABLE_CLASSES;
- if (this.dropdown.find('.dropdown-toggle-page').length) {
- selector = `.dropdown-page-one ${selector}`;
- }
- return $('body').on('keydown', e => {
- let $listItems, PREV_INDEX;
- const currentKeyCode = e.which;
- if (ARROW_KEY_CODES.indexOf(currentKeyCode) !== -1) {
- e.preventDefault();
- e.stopImmediatePropagation();
- PREV_INDEX = currentIndex;
- $listItems = $(selector, this.dropdown);
- // if @options.filterable
- // $input.blur()
- if (currentKeyCode === 40) {
- // Move down
- if (currentIndex < $listItems.length - 1) {
- currentIndex += 1;
+ addArrowKeyEvent() {
+ const ARROW_KEY_CODES = [38, 40];
+ let selector = SELECTABLE_CLASSES;
+ if (this.dropdown.find('.dropdown-toggle-page').length) {
+ selector = `.dropdown-page-one ${selector}`;
+ }
+ return $('body').on('keydown', e => {
+ let $listItems, PREV_INDEX;
+ const currentKeyCode = e.which;
+ if (ARROW_KEY_CODES.indexOf(currentKeyCode) !== -1) {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ PREV_INDEX = currentIndex;
+ $listItems = $(selector, this.dropdown);
+ // if @options.filterable
+ // $input.blur()
+ if (currentKeyCode === 40) {
+ // Move down
+ if (currentIndex < $listItems.length - 1) {
+ currentIndex += 1;
+ }
+ } else if (currentKeyCode === 38) {
+ // Move up
+ if (currentIndex > 0) {
+ currentIndex -= 1;
+ }
}
- } else if (currentKeyCode === 38) {
- // Move up
- if (currentIndex > 0) {
- currentIndex -= 1;
+ if (currentIndex !== PREV_INDEX) {
+ this.highlightRowAtIndex($listItems, currentIndex);
}
+ return false;
}
- if (currentIndex !== PREV_INDEX) {
- this.highlightRowAtIndex($listItems, currentIndex);
+ if (currentKeyCode === 13 && currentIndex !== -1) {
+ e.preventDefault();
+ this.selectRowAtIndex();
}
- return false;
- }
- if (currentKeyCode === 13 && currentIndex !== -1) {
- e.preventDefault();
- this.selectRowAtIndex();
- }
- });
-};
-
-GitLabDropdown.prototype.removeArrowKeyEvent = function() {
- return $('body').off('keydown');
-};
-
-GitLabDropdown.prototype.resetRows = function resetRows() {
- currentIndex = -1;
- $('.is-focused', this.dropdown).removeClass('is-focused');
-};
-
-GitLabDropdown.prototype.highlightRowAtIndex = function($listItems, index) {
- if (!$listItems) {
- $listItems = $(SELECTABLE_CLASSES, this.dropdown);
- }
-
- // Remove the class for the previously focused row
- $('.is-focused', this.dropdown).removeClass('is-focused');
- // Update the class for the row at the specific index
- const $listItem = $listItems.eq(index);
- $listItem.find('a:first-child').addClass('is-focused');
- // Dropdown content scroll area
- const $dropdownContent = $listItem.closest('.dropdown-content');
- const dropdownScrollTop = $dropdownContent.scrollTop();
- const dropdownContentHeight = $dropdownContent.outerHeight();
- const dropdownContentTop = $dropdownContent.prop('offsetTop');
- const dropdownContentBottom = dropdownContentTop + dropdownContentHeight;
- // Get the offset bottom of the list item
- const listItemHeight = $listItem.outerHeight();
- const listItemTop = $listItem.prop('offsetTop');
- const listItemBottom = listItemTop + listItemHeight;
- if (!index) {
- // Scroll the dropdown content to the top
- $dropdownContent.scrollTop(0);
- } else if (index === $listItems.length - 1) {
- // Scroll the dropdown content to the bottom
- $dropdownContent.scrollTop($dropdownContent.prop('scrollHeight'));
- } else if (listItemBottom > dropdownContentBottom + dropdownScrollTop) {
- // Scroll the dropdown content down
- $dropdownContent.scrollTop(
- listItemBottom - dropdownContentBottom + CURSOR_SELECT_SCROLL_PADDING,
- );
- } else if (listItemTop < dropdownContentTop + dropdownScrollTop) {
- // Scroll the dropdown content up
- return $dropdownContent.scrollTop(
- listItemTop - dropdownContentTop - CURSOR_SELECT_SCROLL_PADDING,
- );
+ });
}
-};
-GitLabDropdown.prototype.updateLabel = function(selected, el, instance) {
- if (selected == null) {
- selected = null;
+ // eslint-disable-next-line class-methods-use-this
+ removeArrowKeyEvent() {
+ return $('body').off('keydown');
}
- if (el == null) {
- el = null;
- }
- if (instance == null) {
- instance = null;
+
+ resetRows() {
+ currentIndex = -1;
+ $('.is-focused', this.dropdown).removeClass('is-focused');
}
- let toggleText = this.options.toggleLabel(selected, el, instance);
- if (this.options.updateLabel) {
- // Option to override the dropdown label text
- toggleText = this.options.updateLabel;
+ highlightRowAtIndex($listItems, index) {
+ if (!$listItems) {
+ // eslint-disable-next-line no-param-reassign
+ $listItems = $(SELECTABLE_CLASSES, this.dropdown);
+ }
+
+ // Remove the class for the previously focused row
+ $('.is-focused', this.dropdown).removeClass('is-focused');
+ // Update the class for the row at the specific index
+ const $listItem = $listItems.eq(index);
+ $listItem.find('a:first-child').addClass('is-focused');
+ // Dropdown content scroll area
+ const $dropdownContent = $listItem.closest('.dropdown-content');
+ const dropdownScrollTop = $dropdownContent.scrollTop();
+ const dropdownContentHeight = $dropdownContent.outerHeight();
+ const dropdownContentTop = $dropdownContent.prop('offsetTop');
+ const dropdownContentBottom = dropdownContentTop + dropdownContentHeight;
+ // Get the offset bottom of the list item
+ const listItemHeight = $listItem.outerHeight();
+ const listItemTop = $listItem.prop('offsetTop');
+ const listItemBottom = listItemTop + listItemHeight;
+ if (!index) {
+ // Scroll the dropdown content to the top
+ $dropdownContent.scrollTop(0);
+ } else if (index === $listItems.length - 1) {
+ // Scroll the dropdown content to the bottom
+ $dropdownContent.scrollTop($dropdownContent.prop('scrollHeight'));
+ } else if (listItemBottom > dropdownContentBottom + dropdownScrollTop) {
+ // Scroll the dropdown content down
+ $dropdownContent.scrollTop(
+ listItemBottom - dropdownContentBottom + CURSOR_SELECT_SCROLL_PADDING,
+ );
+ } else if (listItemTop < dropdownContentTop + dropdownScrollTop) {
+ // Scroll the dropdown content up
+ return $dropdownContent.scrollTop(
+ listItemTop - dropdownContentTop - CURSOR_SELECT_SCROLL_PADDING,
+ );
+ }
}
- return $(this.el)
- .find('.dropdown-toggle-text')
- .text(toggleText);
-};
+ updateLabel(selected = null, el = null, instance = null) {
+ let toggleText = this.options.toggleLabel(selected, el, instance);
+ if (this.options.updateLabel) {
+ // Option to override the dropdown label text
+ toggleText = this.options.updateLabel;
+ }
-GitLabDropdown.prototype.clearField = function(field, isInput) {
- return isInput ? field.val('') : field.remove();
-};
+ return $(this.el)
+ .find('.dropdown-toggle-text')
+ .text(toggleText);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ clearField(field, isInput) {
+ return isInput ? field.val('') : field.remove();
+ }
+}
+// eslint-disable-next-line func-names
$.fn.glDropdown = function(opts) {
+ // eslint-disable-next-line func-names
return this.each(function() {
if (!$.data(this, 'glDropdown')) {
return $.data(this, 'glDropdown', new GitLabDropdown(this, opts));
diff --git a/app/assets/javascripts/graphql_shared/fragments/author.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/author.fragment.graphql
new file mode 100644
index 00000000000..9a2ff1c1648
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/fragments/author.fragment.graphql
@@ -0,0 +1,6 @@
+fragment Author on User {
+ avatarUrl
+ name
+ username
+ webUrl
+}
diff --git a/app/assets/javascripts/graphql_shared/fragments/blobviewer.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/blobviewer.fragment.graphql
new file mode 100644
index 00000000000..b202ed12f80
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/fragments/blobviewer.fragment.graphql
@@ -0,0 +1,7 @@
+fragment BlobViewer on SnippetBlobViewer {
+ collapsed
+ renderError
+ tooLarge
+ type
+ fileType
+}
diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js
new file mode 100644
index 00000000000..a262fbd9ac3
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/utils.js
@@ -0,0 +1,12 @@
+/**
+ * Ids generated by GraphQL endpoints are usually in the format
+ * gid://gitlab/Environments/123. This method extracts Id number
+ * from the Id path
+ *
+ * @param {String} gid GraphQL global ID
+ * @returns {Number}
+ */
+export const getIdFromGraphQLId = (gid = '') =>
+ parseInt((gid || '').replace(/gid:\/\/gitlab\/.*\//g, ''), 10) || null;
+
+export default {};
diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue
index e885b2b5f41..cf8c9bf74ec 100644
--- a/app/assets/javascripts/groups/components/group_folder.vue
+++ b/app/assets/javascripts/groups/components/group_folder.vue
@@ -44,7 +44,7 @@ export default {
:action="action"
/>
<li v-if="hasMoreChildren" class="group-row">
- <a :href="parentGroup.relativePath" class="group-row-contents has-more-items">
+ <a :href="parentGroup.relativePath" class="group-row-contents has-more-items py-2">
<i class="fa fa-external-link" aria-hidden="true"> </i> {{ moreChildrenStats }}
</a>
</li>
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index ede74d18ed4..b192fb78631 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlBadge } from '@gitlab/ui';
import { visitUrl } from '../../lib/utils/url_utility';
import tooltip from '../../vue_shared/directives/tooltip';
import identicon from '../../vue_shared/components/identicon.vue';
@@ -17,6 +17,7 @@ export default {
tooltip,
},
components: {
+ GlBadge,
GlLoadingIcon,
identicon,
itemCaret,
@@ -62,6 +63,9 @@ export default {
isGroup() {
return this.group.type === 'group';
},
+ isGroupPendingRemoval() {
+ return this.group.type === 'group' && this.group.pendingRemoval;
+ },
visibilityIcon() {
return VISIBILITY_TYPE_ICON[this.group.visibility];
},
@@ -91,7 +95,7 @@ export default {
<li :id="groupDomId" :class="rowClass" class="group-row" @click.stop="onClickRowGroup">
<div
:class="{ 'project-row-contents': !isGroup }"
- class="group-row-contents d-flex align-items-center"
+ class="group-row-contents d-flex align-items-center py-2"
>
<div class="folder-toggle-wrap append-right-4 d-flex align-items-center">
<item-caret :is-group-open="group.isOpen" />
@@ -104,7 +108,7 @@ export default {
/>
<div
:class="{ 'd-sm-flex': !group.isChildrenLoading }"
- class="avatar-container rect-avatar s40 d-none flex-grow-0 flex-shrink-0 "
+ class="avatar-container rect-avatar s32 d-none flex-grow-0 flex-shrink-0 "
>
<a :href="group.relativePath" class="no-expand">
<img v-if="hasAvatar" :src="group.avatarUrl" class="avatar s40" />
@@ -139,6 +143,9 @@ export default {
<span v-html="group.description"> </span>
</div>
</div>
+ <div v-if="isGroupPendingRemoval">
+ <gl-badge variant="warning">{{ __('pending removal') }}</gl-badge>
+ </div>
<div
class="metadata align-items-md-center d-flex flex-grow-1 flex-shrink-0 flex-wrap justify-content-md-between"
>
diff --git a/app/assets/javascripts/groups/store/groups_store.js b/app/assets/javascripts/groups/store/groups_store.js
index 214ac5e3db5..6a1197fa163 100644
--- a/app/assets/javascripts/groups/store/groups_store.js
+++ b/app/assets/javascripts/groups/store/groups_store.js
@@ -93,7 +93,7 @@ export default class GroupsStore {
memberCount: rawGroupItem.number_users_with_delimiter,
starCount: rawGroupItem.star_count,
updatedAt: rawGroupItem.updated_at,
- pendingRemoval: rawGroupItem.marked_for_deletion_at,
+ pendingRemoval: rawGroupItem.marked_for_deletion,
};
}
diff --git a/app/assets/javascripts/helpers/avatar_helper.js b/app/assets/javascripts/helpers/avatar_helper.js
index 35ac7b2629c..7891b44dd27 100644
--- a/app/assets/javascripts/helpers/avatar_helper.js
+++ b/app/assets/javascripts/helpers/avatar_helper.js
@@ -1,4 +1,4 @@
-import _ from 'underscore';
+import { escape } from 'lodash';
import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility';
export const DEFAULT_SIZE_CLASS = 's40';
@@ -19,7 +19,7 @@ export function renderIdenticon(entity, options = {}) {
const bgClass = getIdenticonBackgroundClass(entity.id);
const title = getIdenticonTitle(entity.name);
- return `<div class="avatar identicon ${_.escape(sizeClass)} ${_.escape(bgClass)}">${_.escape(
+ return `<div class="avatar identicon ${escape(sizeClass)} ${escape(bgClass)}">${escape(
title,
)}</div>`;
}
@@ -31,5 +31,5 @@ export function renderAvatar(entity, options = {}) {
const { sizeClass = DEFAULT_SIZE_CLASS } = options;
- return `<img src="${_.escape(entity.avatar_url)}" class="avatar ${_.escape(sizeClass)}" />`;
+ return `<img src="${escape(entity.avatar_url)}" class="avatar ${escape(sizeClass)}" />`;
}
diff --git a/app/assets/javascripts/helpers/diffs_helper.js b/app/assets/javascripts/helpers/diffs_helper.js
index 9695d01ad3d..d2b8cb11fe0 100644
--- a/app/assets/javascripts/helpers/diffs_helper.js
+++ b/app/assets/javascripts/helpers/diffs_helper.js
@@ -1,9 +1,9 @@
export function hasInlineLines(diffFile) {
- return diffFile?.highlighted_diff_lines?.length > 0; /* eslint-disable-line camelcase */
+ return diffFile?.highlighted_diff_lines?.length > 0;
}
export function hasParallelLines(diffFile) {
- return diffFile?.parallel_diff_lines?.length > 0; /* eslint-disable-line camelcase */
+ return diffFile?.parallel_diff_lines?.length > 0;
}
export function isSingleViewStyle(diffFile) {
@@ -11,9 +11,5 @@ export function isSingleViewStyle(diffFile) {
}
export function hasDiff(diffFile) {
- return (
- hasInlineLines(diffFile) ||
- hasParallelLines(diffFile) ||
- !diffFile?.blob?.readable_text /* eslint-disable-line camelcase */
- );
+ return hasInlineLines(diffFile) || hasParallelLines(diffFile) || !diffFile?.blob?.readable_text;
}
diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue
index 7b4e03be8eb..186d4b6d7d2 100644
--- a/app/assets/javascripts/ide/components/activity_bar.vue
+++ b/app/assets/javascripts/ide/components/activity_bar.vue
@@ -3,7 +3,7 @@ import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
-import { activityBarViews } from '../constants';
+import { leftSidebarViews } from '../constants';
export default {
components: {
@@ -26,7 +26,7 @@ export default {
$(e.currentTarget).tooltip('hide');
},
},
- activityBarViews,
+ leftSidebarViews,
};
</script>
@@ -37,7 +37,7 @@ export default {
<button
v-tooltip
:class="{
- active: currentActivityView === $options.activityBarViews.edit,
+ active: currentActivityView === $options.leftSidebarViews.edit.name,
}"
:title="s__('IDE|Edit')"
:aria-label="s__('IDE|Edit')"
@@ -45,7 +45,7 @@ export default {
data-placement="right"
type="button"
class="ide-sidebar-link js-ide-edit-mode"
- @click.prevent="changedActivityView($event, $options.activityBarViews.edit)"
+ @click.prevent="changedActivityView($event, $options.leftSidebarViews.edit.name)"
>
<icon name="code" />
</button>
@@ -54,7 +54,7 @@ export default {
<button
v-tooltip
:class="{
- active: currentActivityView === $options.activityBarViews.review,
+ active: currentActivityView === $options.leftSidebarViews.review.name,
}"
:title="s__('IDE|Review')"
:aria-label="s__('IDE|Review')"
@@ -62,7 +62,7 @@ export default {
data-placement="right"
type="button"
class="ide-sidebar-link js-ide-review-mode"
- @click.prevent="changedActivityView($event, $options.activityBarViews.review)"
+ @click.prevent="changedActivityView($event, $options.leftSidebarViews.review.name)"
>
<icon name="file-modified" />
</button>
@@ -71,7 +71,7 @@ export default {
<button
v-tooltip
:class="{
- active: currentActivityView === $options.activityBarViews.commit,
+ active: currentActivityView === $options.leftSidebarViews.commit.name,
}"
:title="s__('IDE|Commit')"
:aria-label="s__('IDE|Commit')"
@@ -79,7 +79,7 @@ export default {
data-placement="right"
type="button"
class="ide-sidebar-link js-ide-commit-mode qa-commit-mode-tab"
- @click.prevent="changedActivityView($event, $options.activityBarViews.commit)"
+ @click.prevent="changedActivityView($event, $options.leftSidebarViews.commit.name)"
>
<icon name="commit" />
</button>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
index 549324831e9..2581c3e9928 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
@@ -1,7 +1,7 @@
<script>
import _ from 'underscore';
import { mapState, mapGetters, createNamespacedHelpers } from 'vuex';
-import { sprintf, __ } from '~/locale';
+import { sprintf, s__ } from '~/locale';
import consts from '../../stores/modules/commit/constants';
import RadioGroup from './radio_group.vue';
import NewMergeRequestOption from './new_merge_request_option.vue';
@@ -21,7 +21,7 @@ export default {
...mapGetters(['currentBranch']),
commitToCurrentBranchText() {
return sprintf(
- __('Commit to %{branchName} branch'),
+ s__('IDE|Commit to %{branchName} branch'),
{ branchName: `<strong class="monospace">${_.escape(this.currentBranchId)}</strong>` },
false,
);
@@ -56,8 +56,8 @@ export default {
},
commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH,
commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH,
- currentBranchPermissionsTooltip: __(
- "This option is disabled as you don't have write permissions for the current branch",
+ currentBranchPermissionsTooltip: s__(
+ "IDE|This option is disabled because you don't have write permissions for the current branch.",
),
};
</script>
@@ -70,7 +70,7 @@ export default {
:title="$options.currentBranchPermissionsTooltip"
>
<span
- class="ide-radio-label"
+ class="ide-option-label"
data-qa-selector="commit_to_current_branch_radio"
v-html="commitToCurrentBranchText"
></span>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
index 9d5473a1201..5ec3fc4041b 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
@@ -5,8 +5,7 @@ import LoadingButton from '~/vue_shared/components/loading_button.vue';
import CommitMessageField from './message_field.vue';
import Actions from './actions.vue';
import SuccessMessage from './success_message.vue';
-import { activityBarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { leftSidebarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants';
export default {
components: {
@@ -15,7 +14,6 @@ export default {
CommitMessageField,
SuccessMessage,
},
- mixins: [glFeatureFlagsMixin()],
data() {
return {
isCompact: true,
@@ -29,13 +27,9 @@ export default {
...mapGetters('commit', ['discardDraftButtonDisabled', 'preBuiltCommitMessage']),
overviewText() {
return sprintf(
- this.glFeatures.stageAllByDefault
- ? __(
- '<strong>%{stagedFilesLength} staged</strong> and <strong>%{changedFilesLength} unstaged</strong> changes',
- )
- : __(
- '<strong>%{changedFilesLength} unstaged</strong> and <strong>%{stagedFilesLength} staged</strong> changes',
- ),
+ __(
+ '<strong>%{stagedFilesLength} staged</strong> and <strong>%{changedFilesLength} unstaged</strong> changes',
+ ),
{
stagedFilesLength: this.stagedFiles.length,
changedFilesLength: this.changedFiles.length,
@@ -47,7 +41,7 @@ export default {
},
currentViewIsCommitView() {
- return this.currentActivityView === activityBarViews.commit;
+ return this.currentActivityView === leftSidebarViews.commit.name;
},
},
watch: {
@@ -63,7 +57,7 @@ export default {
lastCommitMsg() {
this.isCompact =
- this.currentActivityView !== activityBarViews.commit && this.lastCommitMsg === '';
+ this.currentActivityView !== leftSidebarViews.commit.name && this.lastCommitMsg === '';
},
},
methods: {
@@ -73,7 +67,7 @@ export default {
if (this.currentViewIsCommitView) {
this.isCompact = !this.isCompact;
} else {
- this.updateActivityBarView(activityBarViews.commit)
+ this.updateActivityBarView(leftSidebarViews.commit.name)
.then(() => {
this.isCompact = false;
})
@@ -102,7 +96,6 @@ export default {
this.componentHeight = null;
},
},
- activityBarViews,
};
</script>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue
index daa44a42765..0812599c25c 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue
@@ -1,16 +1,27 @@
<script>
import { createNamespacedHelpers } from 'vuex';
+import { GlTooltipDirective } from '@gitlab/ui';
+import { s__ } from '~/locale';
-const {
- mapState: mapCommitState,
- mapActions: mapCommitActions,
- mapGetters: mapCommitGetters,
-} = createNamespacedHelpers('commit');
+const { mapActions: mapCommitActions, mapGetters: mapCommitGetters } = createNamespacedHelpers(
+ 'commit',
+);
export default {
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
computed: {
- ...mapCommitState(['shouldCreateMR']),
- ...mapCommitGetters(['shouldHideNewMrOption']),
+ ...mapCommitGetters(['shouldHideNewMrOption', 'shouldDisableNewMrOption', 'shouldCreateMR']),
+ tooltipText() {
+ if (this.shouldDisableNewMrOption) {
+ return s__(
+ 'IDE|This option is disabled because you are not allowed to create merge requests in this project.',
+ );
+ }
+
+ return '';
+ },
},
methods: {
...mapCommitActions(['toggleShouldCreateMR']),
@@ -21,14 +32,19 @@ export default {
<template>
<fieldset v-if="!shouldHideNewMrOption">
<hr class="my-2" />
- <label class="mb-0 js-ide-commit-new-mr">
+ <label
+ v-gl-tooltip="tooltipText"
+ class="mb-0 js-ide-commit-new-mr"
+ :class="{ 'is-disabled': shouldDisableNewMrOption }"
+ >
<input
+ :disabled="shouldDisableNewMrOption"
:checked="shouldCreateMR"
type="checkbox"
data-qa-selector="start_new_mr_checkbox"
@change="toggleShouldCreateMR"
/>
- <span class="prepend-left-10">
+ <span class="prepend-left-10 ide-option-label">
{{ __('Start a new merge request') }}
</span>
</label>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
index 9161eb3d9b1..a9591805261 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
@@ -1,10 +1,10 @@
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
-import tooltip from '~/vue_shared/directives/tooltip';
+import { GlTooltipDirective } from '@gitlab/ui';
export default {
directives: {
- tooltip,
+ GlTooltip: GlTooltipDirective,
},
props: {
value: {
@@ -53,8 +53,7 @@ export default {
<template>
<fieldset>
<label
- v-tooltip
- :title="tooltipTitle"
+ v-gl-tooltip="tooltipTitle"
:class="{
'is-disabled': disabled,
}"
@@ -68,7 +67,7 @@ export default {
@change="updateCommitAction($event.target.value)"
/>
<span class="prepend-left-10">
- <span v-if="label" class="ide-radio-label"> {{ label }} </span> <slot v-else></slot>
+ <span v-if="label" class="ide-option-label"> {{ label }} </span> <slot v-else></slot>
</span>
</label>
<div v-if="commitAction === value && showInput" class="ide-commit-new-branch">
diff --git a/app/assets/javascripts/ide/components/error_message.vue b/app/assets/javascripts/ide/components/error_message.vue
index 500f6737839..d36adbd798e 100644
--- a/app/assets/javascripts/ide/components/error_message.vue
+++ b/app/assets/javascripts/ide/components/error_message.vue
@@ -1,9 +1,10 @@
<script>
import { mapActions } from 'vuex';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
export default {
components: {
+ GlAlert,
GlLoadingIcon,
},
props: {
@@ -17,9 +18,14 @@ export default {
isLoading: false,
};
},
+ computed: {
+ canDismiss() {
+ return !this.message.action;
+ },
+ },
methods: {
...mapActions(['setErrorMessage']),
- clickAction() {
+ doAction() {
if (this.isLoading) return;
this.isLoading = true;
@@ -33,28 +39,23 @@ export default {
this.isLoading = false;
});
},
- clickFlash() {
- if (!this.message.action) {
- this.setErrorMessage(null);
- }
+ dismiss() {
+ this.setErrorMessage(null);
},
},
};
</script>
<template>
- <div class="flash-container flash-container-page" @click="clickFlash">
- <div class="flash-alert" data-qa-selector="flash_alert">
- <span v-html="message.text"> </span>
- <button
- v-if="message.action"
- type="button"
- class="flash-action text-white p-0 border-top-0 border-right-0 border-left-0 bg-transparent"
- @click.stop.prevent="clickAction"
- >
- {{ message.actionText }}
- <gl-loading-icon v-show="isLoading" inline />
- </button>
- </div>
- </div>
+ <gl-alert
+ data-qa-selector="flash_alert"
+ variant="danger"
+ :dismissible="canDismiss"
+ :primary-button-text="message.actionText"
+ @dismiss="dismiss"
+ @primaryAction="doAction"
+ >
+ <span v-html="message.text"></span>
+ <gl-loading-icon v-show="isLoading" inline class="vertical-align-middle ml-1" />
+ </gl-alert>
</template>
diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue
index 33098eb1af0..3ef7d863bd5 100644
--- a/app/assets/javascripts/ide/components/file_row_extra.vue
+++ b/app/assets/javascripts/ide/components/file_row_extra.vue
@@ -6,7 +6,6 @@ import Icon from '~/vue_shared/components/icon.vue';
import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue';
import NewDropdown from './new_dropdown/index.vue';
import MrFileIcon from './mr_file_icon.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'FileRowExtra',
@@ -19,7 +18,6 @@ export default {
ChangedFileIcon,
MrFileIcon,
},
- mixins: [glFeatureFlagsMixin()],
props: {
file: {
type: Object,
@@ -57,15 +55,10 @@ export default {
return n__('%d staged change', '%d staged changes', this.folderStagedCount);
}
- return sprintf(
- this.glFeatures.stageAllByDefault
- ? __('%{staged} staged and %{unstaged} unstaged changes')
- : __('%{unstaged} unstaged and %{staged} staged changes'),
- {
- unstaged: this.folderUnstagedCount,
- staged: this.folderStagedCount,
- },
- );
+ return sprintf(__('%{staged} staged and %{unstaged} unstaged changes'), {
+ unstaged: this.folderUnstagedCount,
+ staged: this.folderStagedCount,
+ });
},
showTreeChangesCount() {
return this.isTree && this.changesCount > 0 && !this.file.opened;
diff --git a/app/assets/javascripts/ide/components/ide_file_row.vue b/app/assets/javascripts/ide/components/ide_file_row.vue
new file mode 100644
index 00000000000..b777d89f0bb
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_file_row.vue
@@ -0,0 +1,38 @@
+<script>
+/**
+ * This component is an iterative step towards refactoring and simplifying `vue_shared/components/file_row.vue`
+ * https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23720
+ */
+import FileRow from '~/vue_shared/components/file_row.vue';
+import FileRowExtra from './file_row_extra.vue';
+
+export default {
+ name: 'IdeFileRow',
+ components: {
+ FileRow,
+ FileRowExtra,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ dropdownOpen: false,
+ };
+ },
+ methods: {
+ toggleDropdown(val) {
+ this.dropdownOpen = val;
+ },
+ },
+};
+</script>
+
+<template>
+ <file-row :file="file" v-bind="$attrs" @mouseleave="toggleDropdown(false)" v-on="$listeners">
+ <file-row-extra :file="file" :dropdown-open="dropdownOpen" @toggle="toggleDropdown($event)" />
+ </file-row>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue
index 6178d2b1fc7..40cd2178e09 100644
--- a/app/assets/javascripts/ide/components/ide_side_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_side_bar.vue
@@ -4,19 +4,19 @@ import { GlSkeletonLoading } from '@gitlab/ui';
import IdeTree from './ide_tree.vue';
import ResizablePanel from './resizable_panel.vue';
import ActivityBar from './activity_bar.vue';
-import CommitSection from './repo_commit_section.vue';
+import RepoCommitSection from './repo_commit_section.vue';
import CommitForm from './commit_sidebar/form.vue';
import IdeReview from './ide_review.vue';
import SuccessMessage from './commit_sidebar/success_message.vue';
import IdeProjectHeader from './ide_project_header.vue';
-import { activityBarViews } from '../constants';
+import { leftSidebarViews } from '../constants';
export default {
components: {
GlSkeletonLoading,
ResizablePanel,
ActivityBar,
- CommitSection,
+ RepoCommitSection,
IdeTree,
CommitForm,
IdeReview,
@@ -28,7 +28,7 @@ export default {
...mapGetters(['currentProject', 'someUncommittedChanges']),
showSuccessMessage() {
return (
- this.currentActivityView === activityBarViews.edit &&
+ this.currentActivityView === leftSidebarViews.edit.name &&
(this.lastCommitMsg && !this.someUncommittedChanges)
);
},
diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue
index 6eaf08e8033..7ce33fd2278 100644
--- a/app/assets/javascripts/ide/components/ide_status_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_status_bar.vue
@@ -2,6 +2,7 @@
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { mapActions, mapState, mapGetters } from 'vuex';
import IdeStatusList from 'ee_else_ce/ide/components/ide_status_list.vue';
+import IdeStatusMr from './ide_status_mr.vue';
import icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import timeAgoMixin from '~/vue_shared/mixins/timeago';
@@ -15,6 +16,7 @@ export default {
userAvatarImage,
CiIcon,
IdeStatusList,
+ IdeStatusMr,
},
directives: {
tooltip,
@@ -27,7 +29,7 @@ export default {
},
computed: {
...mapState(['currentBranchId', 'currentProjectId']),
- ...mapGetters(['currentProject', 'lastCommit']),
+ ...mapGetters(['currentProject', 'lastCommit', 'currentMergeRequest']),
...mapState('pipelines', ['latestPipeline']),
},
watch: {
@@ -79,7 +81,7 @@ export default {
<span v-if="latestPipeline && latestPipeline.details" class="ide-status-pipeline">
<button
type="button"
- class="p-0 border-0 h-50"
+ class="p-0 border-0 bg-transparent"
@click="openRightPane($options.rightSidebarViews.pipelines)"
>
<ci-icon
@@ -121,6 +123,12 @@ export default {
>{{ lastCommitFormattedAge }}</time
>
</div>
+ <ide-status-mr
+ v-if="currentMergeRequest"
+ class="mx-3"
+ :url="currentMergeRequest.web_url"
+ :text="currentMergeRequest.references.short"
+ />
<ide-status-list class="ml-auto" />
</footer>
</template>
diff --git a/app/assets/javascripts/ide/components/ide_status_mr.vue b/app/assets/javascripts/ide/components/ide_status_mr.vue
new file mode 100644
index 00000000000..a3b26d23a17
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_status_mr.vue
@@ -0,0 +1,28 @@
+<script>
+import { GlIcon, GlLink } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ GlLink,
+ },
+ props: {
+ text: {
+ type: String,
+ required: true,
+ },
+ url: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="d-flex-center flex-nowrap text-nowrap js-ide-status-mr">
+ <gl-icon name="merge-request" />
+ <span class="ml-1 d-none d-sm-block">{{ s__('WebIDE|Merge request') }}</span>
+ <gl-link class="ml-1" :href="url">{{ text }}</gl-link>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue
index bacdfc7c05e..36e8951bea3 100644
--- a/app/assets/javascripts/ide/components/ide_tree_list.vue
+++ b/app/assets/javascripts/ide/components/ide_tree_list.vue
@@ -1,15 +1,15 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlSkeletonLoading } from '@gitlab/ui';
-import FileRow from '~/vue_shared/components/file_row.vue';
+import FileTree from '~/vue_shared/components/file_tree.vue';
+import IdeFileRow from './ide_file_row.vue';
import NavDropdown from './nav_dropdown.vue';
-import FileRowExtra from './file_row_extra.vue';
export default {
components: {
GlSkeletonLoading,
NavDropdown,
- FileRow,
+ FileTree,
},
props: {
viewerType: {
@@ -35,7 +35,7 @@ export default {
methods: {
...mapActions(['updateViewer', 'toggleTreeOpen']),
},
- FileRowExtra,
+ IdeFileRow,
};
</script>
@@ -53,12 +53,12 @@ export default {
</header>
<div class="ide-tree-body h-100">
<template v-if="currentTree.tree.length">
- <file-row
+ <file-tree
v-for="file in currentTree.tree"
:key="file.key"
:file="file"
:level="0"
- :extra-component="$options.FileRowExtra"
+ :file-row-component="$options.IdeFileRow"
@toggleTreeOpen="toggleTreeOpen"
/>
</template>
diff --git a/app/assets/javascripts/ide/components/jobs/detail/description.vue b/app/assets/javascripts/ide/components/jobs/detail/description.vue
index 7280fba9e7a..9c0c97bc5ae 100644
--- a/app/assets/javascripts/ide/components/jobs/detail/description.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail/description.vue
@@ -26,7 +26,7 @@ export default {
<ci-icon :status="job.status" :borderless="true" :size="24" class="d-flex" />
<span class="prepend-left-8">
{{ job.name }}
- <a :href="job.path" target="_blank" class="ide-external-link">
+ <a :href="job.path" target="_blank" class="ide-external-link position-relative">
{{ jobId }} <icon :size="12" name="external-link" />
</a>
</span>
diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue
index 52ca61c06b0..ba8407382f4 100644
--- a/app/assets/javascripts/ide/components/jobs/stage.vue
+++ b/app/assets/javascripts/ide/components/jobs/stage.vue
@@ -71,7 +71,7 @@ export default {
v-tooltip="showTooltip"
:title="showTooltip ? stage.name : null"
data-container="body"
- class="prepend-left-8 ide-stage-title"
+ class="prepend-left-8 text-truncate"
>
{{ stage.name }}
</strong>
@@ -80,7 +80,7 @@ export default {
</div>
<icon :name="collapseIcon" class="ide-stage-collapse-icon" />
</div>
- <div v-show="!stage.isCollapsed" ref="jobList" class="card-body">
+ <div v-show="!stage.isCollapsed" ref="jobList" class="card-body p-0">
<gl-loading-icon v-if="showLoadingIcon" />
<template v-else>
<item v-for="job in stage.jobs" :key="job.id" :job="job" @clickViewLog="clickViewLog" />
diff --git a/app/assets/javascripts/ide/components/merge_requests/info.vue b/app/assets/javascripts/ide/components/merge_requests/info.vue
deleted file mode 100644
index 73ec992466c..00000000000
--- a/app/assets/javascripts/ide/components/merge_requests/info.vue
+++ /dev/null
@@ -1,38 +0,0 @@
-<script>
-import { mapGetters } from 'vuex';
-import Icon from '../../../vue_shared/components/icon.vue';
-import TitleComponent from '../../../issue_show/components/title.vue';
-import DescriptionComponent from '../../../issue_show/components/description.vue';
-
-export default {
- components: {
- Icon,
- TitleComponent,
- DescriptionComponent,
- },
- computed: {
- ...mapGetters(['currentMergeRequest']),
- },
-};
-</script>
-
-<template>
- <div class="ide-merge-request-info h-100 d-flex flex-column">
- <div class="detail-page-header">
- <icon name="git-merge" class="align-self-center append-right-8" />
- <strong> !{{ currentMergeRequest.iid }} </strong>
- </div>
- <div class="issuable-details">
- <title-component
- :issuable-ref="currentMergeRequest.iid"
- :title-html="currentMergeRequest.title_html"
- :title-text="currentMergeRequest.title"
- />
- <description-component
- :description-html="currentMergeRequest.description_html"
- :description-text="currentMergeRequest.description"
- :can-update="false"
- />
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/ide/components/nav_dropdown.vue b/app/assets/javascripts/ide/components/nav_dropdown.vue
index 2e290de0943..2307efd1d24 100644
--- a/app/assets/javascripts/ide/components/nav_dropdown.vue
+++ b/app/assets/javascripts/ide/components/nav_dropdown.vue
@@ -1,5 +1,6 @@
<script>
import $ from 'jquery';
+import { mapGetters } from 'vuex';
import NavForm from './nav_form.vue';
import NavDropdownButton from './nav_dropdown_button.vue';
@@ -13,6 +14,9 @@ export default {
isVisibleDropdown: false,
};
},
+ computed: {
+ ...mapGetters(['canReadMergeRequests']),
+ },
mounted() {
this.addDropdownListeners();
},
@@ -42,7 +46,9 @@ export default {
<template>
<div ref="dropdown" class="btn-group ide-nav-dropdown dropdown">
- <nav-dropdown-button />
- <div class="dropdown-menu dropdown-menu-left p-0"><nav-form v-if="isVisibleDropdown" /></div>
+ <nav-dropdown-button :show-merge-requests="canReadMergeRequests" />
+ <div class="dropdown-menu dropdown-menu-left p-0">
+ <nav-form v-if="isVisibleDropdown" :show-merge-requests="canReadMergeRequests" />
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/nav_dropdown_button.vue b/app/assets/javascripts/ide/components/nav_dropdown_button.vue
index f1d44443125..4cd320d5d66 100644
--- a/app/assets/javascripts/ide/components/nav_dropdown_button.vue
+++ b/app/assets/javascripts/ide/components/nav_dropdown_button.vue
@@ -10,6 +10,13 @@ export default {
Icon,
DropdownButton,
},
+ props: {
+ showMergeRequests: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
computed: {
...mapState(['currentBranchId', 'currentMergeRequestId']),
mergeRequestLabel() {
@@ -25,10 +32,10 @@ export default {
<template>
<dropdown-button>
<span class="row">
- <span class="col-7 text-truncate">
+ <span class="col-auto text-truncate" :class="{ 'col-7': showMergeRequests }">
<icon :size="16" :aria-label="__('Current Branch')" name="branch" /> {{ branchLabel }}
</span>
- <span class="col-5 pl-0 text-truncate">
+ <span v-if="showMergeRequests" class="col-5 pl-0 text-truncate">
<icon :size="16" :aria-label="__('Merge Request')" name="merge-request" />
{{ mergeRequestLabel }}
</span>
diff --git a/app/assets/javascripts/ide/components/nav_form.vue b/app/assets/javascripts/ide/components/nav_form.vue
index 23c068f329d..195504a6861 100644
--- a/app/assets/javascripts/ide/components/nav_form.vue
+++ b/app/assets/javascripts/ide/components/nav_form.vue
@@ -11,24 +11,32 @@ export default {
BranchesSearchList,
MergeRequestSearchList,
},
+ props: {
+ showMergeRequests: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
};
</script>
<template>
<div class="ide-nav-form p-0">
- <tabs stop-propagation>
+ <tabs v-if="showMergeRequests" stop-propagation>
<tab active>
<template slot="title">
- {{ __('Merge Requests') }}
+ {{ __('Branches') }}
</template>
- <merge-request-search-list />
+ <branches-search-list />
</tab>
<tab>
<template slot="title">
- {{ __('Branches') }}
+ {{ __('Merge Requests') }}
</template>
- <branches-search-list />
+ <merge-request-search-list />
</tab>
</tabs>
+ <branches-search-list v-else />
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue
index 27d24fa5e1d..9961c0df52e 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/index.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue
@@ -64,7 +64,7 @@ export default {
class="rounded border-0 d-flex ide-entry-dropdown-toggle"
@click.stop="openDropdown()"
>
- <icon name="ellipsis_v" /> <icon name="arrow-down" />
+ <icon name="ellipsis_v" /> <icon name="chevron-down" />
</button>
<ul ref="dropdownMenu" class="dropdown-menu dropdown-menu-right">
<template v-if="type === 'tree'">
@@ -91,7 +91,7 @@ export default {
</template>
<li>
<item-button
- :label="__('Rename')"
+ :label="__('Rename/Move')"
class="d-flex"
icon="pencil"
icon-classes="mr-2"
diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
index e52613086a4..0efb0012246 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
@@ -43,21 +43,28 @@ export default {
},
createFile(target, file) {
const { name } = file;
- let { result } = target;
- const encodedContent = result.split('base64,')[1];
+ const encodedContent = target.result.split('base64,')[1];
const rawContent = encodedContent ? atob(encodedContent) : '';
const isText = this.isText(rawContent, file.type);
- result = isText ? rawContent : encodedContent;
+ const emitCreateEvent = content =>
+ this.$emit('create', {
+ name: `${this.path ? `${this.path}/` : ''}${name}`,
+ type: 'blob',
+ content,
+ base64: !isText,
+ binary: !isText,
+ rawPath: !isText ? target.result : '',
+ });
- this.$emit('create', {
- name: `${this.path ? `${this.path}/` : ''}${name}`,
- type: 'blob',
- content: result,
- base64: !isText,
- binary: !isText,
- rawPath: !isText ? target.result : '',
- });
+ if (isText) {
+ const reader = new FileReader();
+
+ reader.addEventListener('load', e => emitCreateEvent(e.target.result), { once: true });
+ reader.readAsText(file);
+ } else {
+ emitCreateEvent(encodedContent);
+ }
},
readFile(file) {
const reader = new FileReader();
diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue
index 40ed7d9c422..4a9de9e0c03 100644
--- a/app/assets/javascripts/ide/components/panes/right.vue
+++ b/app/assets/javascripts/ide/components/panes/right.vue
@@ -3,7 +3,6 @@ import { mapGetters, mapState } from 'vuex';
import { __ } from '~/locale';
import CollapsibleSidebar from './collapsible_sidebar.vue';
import { rightSidebarViews } from '../../constants';
-import MergeRequestInfo from '../merge_requests/info.vue';
import PipelinesList from '../pipelines/list.vue';
import JobsDetail from '../jobs/detail.vue';
import Clientside from '../preview/clientside.vue';
@@ -29,12 +28,6 @@ export default {
rightExtensionTabs() {
return [
{
- show: Boolean(this.currentMergeRequestId),
- title: __('Merge Request'),
- views: [{ component: MergeRequestInfo, ...rightSidebarViews.mergeRequestInfo }],
- icon: 'text-description',
- },
- {
show: true,
title: __('Pipelines'),
views: [
diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue
index 5ae73b2fc9c..b61d0a47795 100644
--- a/app/assets/javascripts/ide/components/pipelines/list.vue
+++ b/app/assets/javascripts/ide/components/pipelines/list.vue
@@ -62,7 +62,11 @@ export default {
<ci-icon :status="latestPipeline.details.status" :size="24" />
<span class="prepend-left-8">
<strong> {{ __('Pipeline') }} </strong>
- <a :href="latestPipeline.path" target="_blank" class="ide-external-link">
+ <a
+ :href="latestPipeline.path"
+ target="_blank"
+ class="ide-external-link position-relative"
+ >
#{{ latestPipeline.id }} <icon :size="12" name="external-link" />
</a>
</span>
diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue
index b3a7597e7bb..62fb0b03975 100644
--- a/app/assets/javascripts/ide/components/repo_commit_section.vue
+++ b/app/assets/javascripts/ide/components/repo_commit_section.vue
@@ -5,7 +5,7 @@ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import CommitFilesList from './commit_sidebar/list.vue';
import EmptyState from './commit_sidebar/empty_state.vue';
import consts from '../stores/modules/commit/constants';
-import { activityBarViews, stageKeys } from '../constants';
+import { leftSidebarViews, stageKeys } from '../constants';
export default {
components: {
@@ -37,7 +37,7 @@ export default {
watch: {
hasChanges() {
if (!this.hasChanges) {
- this.updateActivityBarView(activityBarViews.edit);
+ this.updateActivityBarView(leftSidebarViews.edit.name);
}
},
},
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 7e2ab96d1de..bfb760f3579 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -5,7 +5,7 @@ import flash from '~/flash';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import {
- activityBarViews,
+ leftSidebarViews,
viewerTypes,
FILE_VIEW_MODE_EDITOR,
FILE_VIEW_MODE_PREVIEW,
@@ -38,6 +38,7 @@ export default {
'panelResizing',
'currentActivityView',
'renderWhitespaceInCode',
+ 'editorTheme',
]),
...mapGetters([
'currentMergeRequest',
@@ -85,6 +86,7 @@ export default {
editorOptions() {
return {
renderWhitespace: this.renderWhitespaceInCode ? 'all' : 'none',
+ theme: this.editorTheme,
};
},
},
@@ -98,7 +100,7 @@ export default {
if (oldVal.key !== this.file.key) {
this.initEditor();
- if (this.currentActivityView !== activityBarViews.edit) {
+ if (this.currentActivityView !== leftSidebarViews.edit.name) {
this.setFileViewMode({
file: this.file,
viewMode: FILE_VIEW_MODE_EDITOR,
@@ -107,7 +109,7 @@ export default {
}
},
currentActivityView() {
- if (this.currentActivityView !== activityBarViews.edit) {
+ if (this.currentActivityView !== leftSidebarViews.edit.name) {
this.setFileViewMode({
file: this.file,
viewMode: FILE_VIEW_MODE_EDITOR,
@@ -274,7 +276,7 @@ export default {
<template>
<div id="ide" class="blob-viewer-container blob-editor-container">
<div class="ide-mode-tabs clearfix">
- <ul v-if="!shouldHideEditor && isEditModeActive" class="nav-links float-left">
+ <ul v-if="!shouldHideEditor && isEditModeActive" class="nav-links float-left border-bottom-0">
<li :class="editTabCSS">
<a
href="javascript:void(0);"
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index 673ac1bfa9a..e7762f9e0f2 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -8,11 +8,8 @@ export const MAX_BODY_LENGTH = 72;
export const FILE_VIEW_MODE_EDITOR = 'editor';
export const FILE_VIEW_MODE_PREVIEW = 'preview';
-export const activityBarViews = {
- edit: 'ide-tree',
- commit: 'commit-section',
- review: 'ide-review',
-};
+export const PERMISSION_CREATE_MR = 'createMergeRequestIn';
+export const PERMISSION_READ_MR = 'readMergeRequest';
export const viewerTypes = {
mr: 'mrdiff',
@@ -44,6 +41,12 @@ export const diffViewerErrors = Object.freeze({
stored_externally: 'server_side_but_stored_externally',
});
+export const leftSidebarViews = {
+ edit: { name: 'ide-tree', keepAlive: false },
+ review: { name: 'ide-review', keepAlive: false },
+ commit: { name: 'repo-commit-section', keepAlive: false },
+};
+
export const rightSidebarViews = {
pipelines: { name: 'pipelines-list', keepAlive: true },
jobsDetail: { name: 'jobs-detail', keepAlive: false },
diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js
index 8c84b98a108..0fab3ee0f3b 100644
--- a/app/assets/javascripts/ide/ide_router.js
+++ b/app/assets/javascripts/ide/ide_router.js
@@ -1,11 +1,11 @@
import Vue from 'vue';
-import VueRouter from 'vue-router';
+import IdeRouter from '~/ide/ide_router_extension';
import { joinPaths } from '~/lib/utils/url_utility';
import flash from '~/flash';
import store from './stores';
import { __ } from '~/locale';
-Vue.use(VueRouter);
+Vue.use(IdeRouter);
/**
* Routes below /-/ide/:
@@ -33,7 +33,7 @@ const EmptyRouterComponent = {
},
};
-const router = new VueRouter({
+const router = new IdeRouter({
mode: 'history',
base: joinPaths(gon.relative_url_root || '', '/-/ide/'),
routes: [
diff --git a/app/assets/javascripts/ide/ide_router_extension.js b/app/assets/javascripts/ide/ide_router_extension.js
new file mode 100644
index 00000000000..a146aca7283
--- /dev/null
+++ b/app/assets/javascripts/ide/ide_router_extension.js
@@ -0,0 +1,21 @@
+import VueRouter from 'vue-router';
+import { escapeFileUrl } from '~/lib/utils/url_utility';
+
+// To allow special characters (like "#," for example) in the branch names, we
+// should encode all the locations before those get processed by History API.
+// Otherwise, paths get messed up so that the router receives incorrect
+// branchid. The only way to do it consistently and in a more or less
+// future-proof manner is, unfortunately, to monkey-patch VueRouter or, as
+// suggested here, achieve the same more reliably by subclassing VueRouter and
+// update the methods, used in WebIDE.
+//
+// More context: https://gitlab.com/gitlab-org/gitlab/issues/35473
+
+export default class IDERouter extends VueRouter {
+ push(location, onComplete, onAbort) {
+ super.push(escapeFileUrl(location), onComplete, onAbort);
+ }
+ resolve(to, current, append) {
+ return super.resolve(escapeFileUrl(to), current, append);
+ }
+}
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index 4c4166e11f5..a3450522697 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -7,6 +7,7 @@ import store from './stores';
import router from './ide_router';
import { parseBoolean } from '../lib/utils/common_utils';
import { resetServiceWorkersPublicPath } from '../lib/utils/webpack';
+import { DEFAULT_THEME } from './lib/themes';
Vue.use(Translate);
@@ -51,6 +52,7 @@ export function initIde(el, options = {}) {
this.setInitialData({
clientsidePreviewEnabled: parseBoolean(el.dataset.clientsidePreviewEnabled),
renderWhitespaceInCode: parseBoolean(el.dataset.renderWhitespaceInCode),
+ editorTheme: window.gon?.user_color_scheme || DEFAULT_THEME,
});
},
methods: {
diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js
index d1056ea6b98..3d729463cb4 100644
--- a/app/assets/javascripts/ide/lib/editor.js
+++ b/app/assets/javascripts/ide/lib/editor.js
@@ -6,22 +6,16 @@ import DirtyDiffController from './diff/controller';
import Disposable from './common/disposable';
import ModelManager from './common/model_manager';
import editorOptions, { defaultEditorOptions } from './editor_options';
-import gitlabTheme from './themes/gl_theme';
+import { themes } from './themes';
import keymap from './keymap.json';
+import { clearDomElement } from '~/editor/utils';
-function setupMonacoTheme() {
- monacoEditor.defineTheme(gitlabTheme.themeName, gitlabTheme.monacoTheme);
- monacoEditor.setTheme('gitlab');
+function setupThemes() {
+ themes.forEach(theme => {
+ monacoEditor.defineTheme(theme.name, theme.data);
+ });
}
-export const clearDomElement = el => {
- if (!el || !el.firstChild) return;
-
- while (el.firstChild) {
- el.removeChild(el.firstChild);
- }
-};
-
export default class Editor {
static create(options = {}) {
if (!this.editorInstance) {
@@ -42,7 +36,7 @@ export default class Editor {
...options,
};
- setupMonacoTheme();
+ setupThemes();
this.debouncedUpdate = _.debounce(() => {
this.updateDimensions();
diff --git a/app/assets/javascripts/ide/lib/themes/dark.js b/app/assets/javascripts/ide/lib/themes/dark.js
new file mode 100644
index 00000000000..96aaa0cbb50
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/themes/dark.js
@@ -0,0 +1,268 @@
+/*
+
+https://github.com/brijeshb42/monaco-themes/blob/master/themes/Tomorrow-Night.json
+
+The MIT License (MIT)
+
+Copyright (c) Brijesh Bittu
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+*/
+
+export default {
+ base: 'vs-dark',
+ inherit: true,
+ rules: [
+ {
+ foreground: '969896',
+ token: 'comment',
+ },
+ {
+ foreground: 'ced1cf',
+ token: 'keyword.operator.class',
+ },
+ {
+ foreground: 'ced1cf',
+ token: 'constant.other',
+ },
+ {
+ foreground: 'ced1cf',
+ token: 'source.php.embedded.line',
+ },
+ {
+ foreground: 'cc6666',
+ token: 'variable',
+ },
+ {
+ foreground: 'cc6666',
+ token: 'support.other.variable',
+ },
+ {
+ foreground: 'cc6666',
+ token: 'string.other.link',
+ },
+ {
+ foreground: 'cc6666',
+ token: 'string.regexp',
+ },
+ {
+ foreground: 'cc6666',
+ token: 'entity.name.tag',
+ },
+ {
+ foreground: 'cc6666',
+ token: 'entity.other.attribute-name',
+ },
+ {
+ foreground: 'cc6666',
+ token: 'meta.tag',
+ },
+ {
+ foreground: 'cc6666',
+ token: 'declaration.tag',
+ },
+ {
+ foreground: 'cc6666',
+ token: 'markup.deleted.git_gutter',
+ },
+ {
+ foreground: 'de935f',
+ token: 'constant.numeric',
+ },
+ {
+ foreground: 'de935f',
+ token: 'constant.language',
+ },
+ {
+ foreground: 'de935f',
+ token: 'support.constant',
+ },
+ {
+ foreground: 'de935f',
+ token: 'constant.character',
+ },
+ {
+ foreground: 'de935f',
+ token: 'variable.parameter',
+ },
+ {
+ foreground: 'de935f',
+ token: 'punctuation.section.embedded',
+ },
+ {
+ foreground: 'de935f',
+ token: 'keyword.other.unit',
+ },
+ {
+ foreground: 'f0c674',
+ token: 'entity.name.class',
+ },
+ {
+ foreground: 'f0c674',
+ token: 'entity.name.type.class',
+ },
+ {
+ foreground: 'f0c674',
+ token: 'support.type',
+ },
+ {
+ foreground: 'f0c674',
+ token: 'support.class',
+ },
+ {
+ foreground: 'b5bd68',
+ token: 'string',
+ },
+ {
+ foreground: 'b5bd68',
+ token: 'constant.other.symbol',
+ },
+ {
+ foreground: 'b5bd68',
+ token: 'entity.other.inherited-class',
+ },
+ {
+ foreground: 'b5bd68',
+ token: 'markup.heading',
+ },
+ {
+ foreground: 'b5bd68',
+ token: 'markup.inserted.git_gutter',
+ },
+ {
+ foreground: '8abeb7',
+ token: 'keyword.operator',
+ },
+ {
+ foreground: '8abeb7',
+ token: 'constant.other.color',
+ },
+ {
+ foreground: '81a2be',
+ token: 'entity.name.function',
+ },
+ {
+ foreground: '81a2be',
+ token: 'meta.function-call',
+ },
+ {
+ foreground: '81a2be',
+ token: 'support.function',
+ },
+ {
+ foreground: '81a2be',
+ token: 'keyword.other.special-method',
+ },
+ {
+ foreground: '81a2be',
+ token: 'meta.block-level',
+ },
+ {
+ foreground: '81a2be',
+ token: 'markup.changed.git_gutter',
+ },
+ {
+ foreground: 'b294bb',
+ token: 'keyword',
+ },
+ {
+ foreground: 'b294bb',
+ token: 'storage',
+ },
+ {
+ foreground: 'b294bb',
+ token: 'storage.type',
+ },
+ {
+ foreground: 'b294bb',
+ token: 'entity.name.tag.css',
+ },
+ {
+ foreground: 'ced2cf',
+ background: 'df5f5f',
+ token: 'invalid',
+ },
+ {
+ foreground: 'ced2cf',
+ background: '82a3bf',
+ token: 'meta.separator',
+ },
+ {
+ foreground: 'ced2cf',
+ background: 'b798bf',
+ token: 'invalid.deprecated',
+ },
+ {
+ foreground: 'ffffff',
+ token: 'markup.inserted.diff',
+ },
+ {
+ foreground: 'ffffff',
+ token: 'markup.deleted.diff',
+ },
+ {
+ foreground: 'ffffff',
+ token: 'meta.diff.header.to-file',
+ },
+ {
+ foreground: 'ffffff',
+ token: 'meta.diff.header.from-file',
+ },
+ {
+ foreground: '718c00',
+ token: 'markup.inserted.diff',
+ },
+ {
+ foreground: '718c00',
+ token: 'meta.diff.header.to-file',
+ },
+ {
+ foreground: 'c82829',
+ token: 'markup.deleted.diff',
+ },
+ {
+ foreground: 'c82829',
+ token: 'meta.diff.header.from-file',
+ },
+ {
+ foreground: 'ffffff',
+ background: '4271ae',
+ token: 'meta.diff.header.from-file',
+ },
+ {
+ foreground: 'ffffff',
+ background: '4271ae',
+ token: 'meta.diff.header.to-file',
+ },
+ {
+ foreground: '3e999f',
+ fontStyle: 'italic',
+ token: 'meta.diff.range',
+ },
+ ],
+ colors: {
+ 'editor.foreground': '#C5C8C6',
+ 'editor.background': '#1D1F21',
+ 'editor.selectionBackground': '#373B41',
+ 'editor.lineHighlightBackground': '#282A2E',
+ 'editorCursor.foreground': '#AEAFAD',
+ 'editorWhitespace.foreground': '#4B4E55',
+ },
+};
diff --git a/app/assets/javascripts/ide/lib/themes/gl_theme.js b/app/assets/javascripts/ide/lib/themes/gl_theme.js
deleted file mode 100644
index 439ae50448a..00000000000
--- a/app/assets/javascripts/ide/lib/themes/gl_theme.js
+++ /dev/null
@@ -1,15 +0,0 @@
-export default {
- themeName: 'gitlab',
- monacoTheme: {
- base: 'vs',
- inherit: true,
- rules: [],
- colors: {
- 'editorLineNumber.foreground': '#CCCCCC',
- 'diffEditor.insertedTextBackground': '#ddfbe6',
- 'diffEditor.removedTextBackground': '#f9d7dc',
- 'editor.selectionBackground': '#aad6f8',
- 'editorIndentGuide.activeBackground': '#cccccc',
- },
- },
-};
diff --git a/app/assets/javascripts/ide/lib/themes/index.js b/app/assets/javascripts/ide/lib/themes/index.js
new file mode 100644
index 00000000000..6ed9f6679a4
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/themes/index.js
@@ -0,0 +1,15 @@
+import white from './white';
+import dark from './dark';
+
+export const themes = [
+ {
+ name: 'white',
+ data: white,
+ },
+ {
+ name: 'dark',
+ data: dark,
+ },
+];
+
+export const DEFAULT_THEME = 'white';
diff --git a/app/assets/javascripts/ide/lib/themes/white.js b/app/assets/javascripts/ide/lib/themes/white.js
new file mode 100644
index 00000000000..273bc783fc6
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/themes/white.js
@@ -0,0 +1,12 @@
+export default {
+ base: 'vs',
+ inherit: true,
+ rules: [],
+ colors: {
+ 'editorLineNumber.foreground': '#CCCCCC',
+ 'diffEditor.insertedTextBackground': '#A0F5B420',
+ 'diffEditor.removedTextBackground': '#f9d7dc20',
+ 'editor.selectionBackground': '#aad6f8',
+ 'editorIndentGuide.activeBackground': '#cccccc',
+ },
+};
diff --git a/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql b/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql
new file mode 100644
index 00000000000..48f63995f44
--- /dev/null
+++ b/app/assets/javascripts/ide/queries/getUserPermissions.query.graphql
@@ -0,0 +1,8 @@
+query getUserPermissions($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ userPermissions {
+ createMergeRequestIn,
+ readMergeRequest
+ }
+ }
+}
diff --git a/app/assets/javascripts/ide/services/gql.js b/app/assets/javascripts/ide/services/gql.js
new file mode 100644
index 00000000000..8a7f27328ba
--- /dev/null
+++ b/app/assets/javascripts/ide/services/gql.js
@@ -0,0 +1,8 @@
+import createGqClient, { fetchPolicies } from '~/lib/graphql';
+
+export default createGqClient(
+ {},
+ {
+ fetchPolicy: fetchPolicies.NO_CACHE,
+ },
+);
diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js
index b130e6e8b81..84a2b2bd58e 100644
--- a/app/assets/javascripts/ide/services/index.js
+++ b/app/assets/javascripts/ide/services/index.js
@@ -1,6 +1,18 @@
import axios from '~/lib/utils/axios_utils';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
import Api from '~/api';
+import getUserPermissions from '../queries/getUserPermissions.query.graphql';
+import gqClient from './gql';
+
+const fetchApiProjectData = projectPath => Api.project(projectPath).then(({ data }) => data);
+
+const fetchGqlProjectData = projectPath =>
+ gqClient
+ .query({
+ query: getUserPermissions,
+ variables: { projectPath },
+ })
+ .then(({ data }) => data.project);
export default {
getFileData(endpoint) {
@@ -35,6 +47,7 @@ export default {
joinPaths(
gon.relative_url_root || '/',
file.projectId,
+ '-',
'raw',
sha,
escapeFileUrl(filePath),
@@ -46,7 +59,16 @@ export default {
.then(({ data }) => data);
},
getProjectData(namespace, project) {
- return Api.project(`${namespace}/${project}`);
+ const projectPath = `${namespace}/${project}`;
+
+ return Promise.all([fetchApiProjectData(projectPath), fetchGqlProjectData(projectPath)]).then(
+ ([apiProjectData, gqlProjectData]) => ({
+ data: {
+ ...apiProjectData,
+ ...gqlProjectData,
+ },
+ }),
+ );
},
getProjectMergeRequests(projectId, params = {}) {
return Api.projectMergeRequests(projectId, params);
@@ -67,7 +89,7 @@ export default {
return Api.commitMultiple(projectId, payload);
},
getFiles(projectUrl, ref) {
- const url = `${projectUrl}/files/${ref}`;
+ const url = `${projectUrl}/-/files/${ref}`;
return axios.get(url, { params: { format: 'json' } });
},
lastCommitPipelines({ getters }) {
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index 34e7cc304dd..ddc0925efb9 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -79,14 +79,10 @@ export const createTempEntry = (
if (type === 'blob') {
commit(types.TOGGLE_FILE_OPEN, file.path);
-
- if (gon.features?.stageAllByDefault)
- commit(types.STAGE_CHANGE, { path: file.path, diffInfo: getters.getDiffInfo(file.path) });
- else commit(types.ADD_FILE_TO_CHANGED, file.path);
+ commit(types.STAGE_CHANGE, { path: file.path, diffInfo: getters.getDiffInfo(file.path) });
dispatch('setFileActive', file.path);
dispatch('triggerFilesChange');
- dispatch('burstUnusedSeal');
}
if (parentPath && !state.entries[parentPath].opened) {
@@ -175,12 +171,6 @@ export const updateTempFlagForEntry = ({ commit, dispatch, state }, { file, temp
export const toggleFileFinder = ({ commit }, fileFindVisible) =>
commit(types.TOGGLE_FILE_FINDER, fileFindVisible);
-export const burstUnusedSeal = ({ state, commit }) => {
- if (state.unusedSeal) {
- commit(types.BURST_UNUSED_SEAL);
- }
-};
-
export const setLinks = ({ commit }, links) => commit(types.SET_LINKS, links);
export const setErrorMessage = ({ commit }, errorMessage) =>
@@ -209,8 +199,6 @@ export const deleteEntry = ({ commit, dispatch, state }, path) => {
return;
}
- dispatch('burstUnusedSeal');
-
if (entry.opened) dispatch('closeFile', entry);
if (isTree) {
@@ -259,11 +247,7 @@ export const renameEntry = ({ dispatch, commit, state, getters }, { path, name,
if (isReset) {
commit(types.REMOVE_FILE_FROM_STAGED_AND_CHANGED, newEntry);
} else if (!isInChanges) {
- if (gon.features?.stageAllByDefault)
- commit(types.STAGE_CHANGE, { path: newPath, diffInfo: getters.getDiffInfo(newPath) });
- else commit(types.ADD_FILE_TO_CHANGED, newPath);
-
- dispatch('burstUnusedSeal');
+ commit(types.STAGE_CHANGE, { path: newPath, diffInfo: getters.getDiffInfo(newPath) });
}
if (!newEntry.tempFile) {
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
index 70a966afa66..da7d4a44bde 100644
--- a/app/assets/javascripts/ide/stores/actions/file.js
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -71,6 +71,7 @@ export const getFileData = (
const url = joinPaths(
gon.relative_url_root || '/',
state.currentProjectId,
+ '-',
file.type,
getters.lastCommit && getters.lastCommit.id,
escapeFileUrl(file.prevPath || file.path),
@@ -89,7 +90,7 @@ export const getFileData = (
.catch(() => {
commit(types.TOGGLE_LOADING, { entry: file });
dispatch('setErrorMessage', {
- text: __('An error occurred whilst loading the file.'),
+ text: __('An error occurred while loading the file.'),
action: payload =>
dispatch('getFileData', payload).then(() => dispatch('setErrorMessage', null)),
actionText: __('Please try again'),
@@ -136,7 +137,7 @@ export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) =
})
.catch(() => {
dispatch('setErrorMessage', {
- text: __('An error occurred whilst loading the file content.'),
+ text: __('An error occurred while loading the file content.'),
action: payload =>
dispatch('getRawFileData', payload).then(() => dispatch('setErrorMessage', null)),
actionText: __('Please try again'),
@@ -147,7 +148,7 @@ export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) =
});
};
-export const changeFileContent = ({ commit, dispatch, state, getters }, { path, content }) => {
+export const changeFileContent = ({ commit, state, getters }, { path, content }) => {
const file = state.entries[path];
commit(types.UPDATE_FILE_CONTENT, {
path,
@@ -157,14 +158,10 @@ export const changeFileContent = ({ commit, dispatch, state, getters }, { path,
const indexOfChangedFile = state.changedFiles.findIndex(f => f.path === path);
if (file.changed && indexOfChangedFile === -1) {
- if (gon.features?.stageAllByDefault)
- commit(types.STAGE_CHANGE, { path, diffInfo: getters.getDiffInfo(path) });
- else commit(types.ADD_FILE_TO_CHANGED, path);
+ commit(types.STAGE_CHANGE, { path, diffInfo: getters.getDiffInfo(path) });
} else if (!file.changed && !file.tempFile && indexOfChangedFile !== -1) {
commit(types.REMOVE_FILE_FROM_CHANGED, path);
}
-
- dispatch('burstUnusedSeal', {}, { root: true });
};
export const setFileLanguage = ({ getters, commit }, { fileLanguage }) => {
diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js
index 806ec38430c..fcaf060ef09 100644
--- a/app/assets/javascripts/ide/stores/actions/merge_request.js
+++ b/app/assets/javascripts/ide/stores/actions/merge_request.js
@@ -2,10 +2,17 @@ import flash from '~/flash';
import { __ } from '~/locale';
import service from '../../services';
import * as types from '../mutation_types';
-import { activityBarViews } from '../../constants';
+import { leftSidebarViews, PERMISSION_READ_MR } from '../../constants';
-export const getMergeRequestsForBranch = ({ commit, state }, { projectId, branchId } = {}) =>
- service
+export const getMergeRequestsForBranch = (
+ { commit, state, getters },
+ { projectId, branchId } = {},
+) => {
+ if (!getters.findProjectPermissions(projectId)[PERMISSION_READ_MR]) {
+ return Promise.resolve();
+ }
+
+ return service
.getProjectMergeRequests(`${projectId}`, {
source_branch: branchId,
source_project_id: state.projects[projectId].id,
@@ -36,6 +43,7 @@ export const getMergeRequestsForBranch = ({ commit, state }, { projectId, branch
);
throw e;
});
+};
export const getMergeRequestData = (
{ commit, dispatch, state },
@@ -44,9 +52,7 @@ export const getMergeRequestData = (
new Promise((resolve, reject) => {
if (!state.projects[projectId].mergeRequests[mergeRequestId] || force) {
service
- .getProjectMergeRequestData(targetProjectId || projectId, mergeRequestId, {
- render_html: true,
- })
+ .getProjectMergeRequestData(targetProjectId || projectId, mergeRequestId)
.then(({ data }) => {
commit(types.SET_MERGE_REQUEST, {
projectPath: projectId,
@@ -58,7 +64,7 @@ export const getMergeRequestData = (
})
.catch(() => {
dispatch('setErrorMessage', {
- text: __('An error occurred whilst loading the merge request.'),
+ text: __('An error occurred while loading the merge request.'),
action: payload =>
dispatch('getMergeRequestData', payload).then(() =>
dispatch('setErrorMessage', null),
@@ -91,7 +97,7 @@ export const getMergeRequestChanges = (
})
.catch(() => {
dispatch('setErrorMessage', {
- text: __('An error occurred whilst loading the merge request changes.'),
+ text: __('An error occurred while loading the merge request changes.'),
action: payload =>
dispatch('getMergeRequestChanges', payload).then(() =>
dispatch('setErrorMessage', null),
@@ -125,7 +131,7 @@ export const getMergeRequestVersions = (
})
.catch(() => {
dispatch('setErrorMessage', {
- text: __('An error occurred whilst loading the merge request version data.'),
+ text: __('An error occurred while loading the merge request version data.'),
action: payload =>
dispatch('getMergeRequestVersions', payload).then(() =>
dispatch('setErrorMessage', null),
@@ -181,7 +187,7 @@ export const openMergeRequest = (
)
.then(mrChanges => {
if (mrChanges.changes.length) {
- dispatch('updateActivityBarView', activityBarViews.review);
+ dispatch('updateActivityBarView', leftSidebarViews.review.name);
}
mrChanges.changes.forEach((change, ind) => {
diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
index e206f9bee9e..62084892d13 100644
--- a/app/assets/javascripts/ide/stores/actions/project.js
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -133,9 +133,9 @@ export const loadBranch = ({ dispatch, getters }, { projectId, branchId }) =>
ref: branch.commit.id,
});
})
- .catch(() => {
+ .catch(err => {
dispatch('showBranchNotFoundError', branchId);
- return Promise.reject();
+ throw err;
});
export const openBranch = ({ dispatch, state, getters }, { projectId, branchId, basePath }) => {
@@ -152,7 +152,7 @@ export const openBranch = ({ dispatch, state, getters }, { projectId, branchId,
() =>
new Error(
sprintf(
- __('An error occurred whilst getting files for - %{branchId}'),
+ __('An error occurred while getting files for - %{branchId}'),
{
branchId: `<strong>${_.escape(projectId)}/${_.escape(branchId)}</strong>`,
},
diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js
index ba85194b910..828e4ed5eb9 100644
--- a/app/assets/javascripts/ide/stores/actions/tree.js
+++ b/app/assets/javascripts/ide/stores/actions/tree.js
@@ -77,7 +77,7 @@ export const getFiles = ({ state, commit, dispatch }, payload = {}) =>
})
.catch(e => {
dispatch('setErrorMessage', {
- text: __('An error occurred whilst loading all the files.'),
+ text: __('An error occurred while loading all the files.'),
action: actionPayload =>
dispatch('getFiles', actionPayload).then(() => dispatch('setErrorMessage', null)),
actionText: __('Please try again'),
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index 2fc574cd343..d7ad39019a5 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -1,5 +1,10 @@
import { getChangesCountForFiles, filePathMatches } from './utils';
-import { activityBarViews, packageJsonPath } from '../constants';
+import {
+ leftSidebarViews,
+ packageJsonPath,
+ PERMISSION_READ_MR,
+ PERMISSION_CREATE_MR,
+} from '../constants';
export const activeFile = state => state.openFiles.find(file => file.active) || null;
@@ -69,9 +74,11 @@ export const getOpenFile = state => path => state.openFiles.find(f => f.path ===
export const lastOpenedFile = state =>
[...state.changedFiles, ...state.stagedFiles].sort((a, b) => b.lastOpenedAt - a.lastOpenedAt)[0];
-export const isEditModeActive = state => state.currentActivityView === activityBarViews.edit;
-export const isCommitModeActive = state => state.currentActivityView === activityBarViews.commit;
-export const isReviewModeActive = state => state.currentActivityView === activityBarViews.review;
+export const isEditModeActive = state => state.currentActivityView === leftSidebarViews.edit.name;
+export const isCommitModeActive = state =>
+ state.currentActivityView === leftSidebarViews.commit.name;
+export const isReviewModeActive = state =>
+ state.currentActivityView === leftSidebarViews.review.name;
export const someUncommittedChanges = state =>
Boolean(state.changedFiles.length || state.stagedFiles.length);
@@ -141,5 +148,14 @@ export const getDiffInfo = (state, getters) => path => {
};
};
+export const findProjectPermissions = (state, getters) => projectId =>
+ getters.findProject(projectId)?.userPermissions || {};
+
+export const canReadMergeRequests = (state, getters) =>
+ Boolean(getters.findProjectPermissions(state.currentProjectId)[PERMISSION_READ_MR]);
+
+export const canCreateMergeRequests = (state, getters) =>
+ Boolean(getters.findProjectPermissions(state.currentProjectId)[PERMISSION_CREATE_MR]);
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index e89ed49318b..9bf0542cd0b 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -7,7 +7,7 @@ import router from '../../../ide_router';
import service from '../../../services';
import * as types from './mutation_types';
import consts from './constants';
-import { activityBarViews } from '../../../constants';
+import { leftSidebarViews } from '../../../constants';
import eventHub from '../../../eventhub';
export const updateCommitMessage = ({ commit }, message) => {
@@ -44,7 +44,7 @@ export const setLastCommitMessage = ({ commit, rootGetters }, data) => {
const commitMsg = sprintf(
__('Your changes have been committed. Commit %{commitId} %{commitStats}'),
{
- commitId: `<a href="${currentProject.web_url}/commit/${data.short_id}" class="commit-sha">${data.short_id}</a>`,
+ commitId: `<a href="${currentProject.web_url}/-/commit/${data.short_id}" class="commit-sha">${data.short_id}</a>`,
commitStats,
},
false,
@@ -56,7 +56,7 @@ export const setLastCommitMessage = ({ commit, rootGetters }, data) => {
export const updateFilesAfterCommit = ({ commit, dispatch, rootState, rootGetters }, { data }) => {
const selectedProject = rootGetters.currentProject;
const lastCommit = {
- commit_path: `${selectedProject.web_url}/commit/${data.id}`,
+ commit_path: `${selectedProject.web_url}/-/commit/${data.id}`,
commit: {
id: data.id,
message: data.message,
@@ -158,7 +158,7 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
commit(rootTypes.SET_LAST_COMMIT_MSG, '', { root: true });
}, 5000);
- if (state.shouldCreateMR) {
+ if (getters.shouldCreateMR) {
const { currentProject } = rootGetters;
const targetBranch = getters.isCreatingNewBranch
? rootState.currentBranchId
@@ -189,7 +189,7 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
throw e;
});
} else {
- dispatch('updateActivityBarView', activityBarViews.edit, { root: true });
+ dispatch('updateActivityBarView', leftSidebarViews.edit.name, { root: true });
dispatch('updateViewer', 'editor', { root: true });
if (rootGetters.activeFile) {
@@ -218,7 +218,7 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
dispatch(
'setErrorMessage',
{
- text: __('An error occurred whilst committing your changes.'),
+ text: __('An error occurred while committing your changes.'),
action: () =>
dispatch('commitChanges').then(() =>
dispatch('setErrorMessage', null, { root: true }),
diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js
index de289e27199..e421d44b6de 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/getters.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js
@@ -54,5 +54,11 @@ export const shouldHideNewMrOption = (_state, getters, _rootState, rootGetters)
(!rootGetters.hasMergeRequest && rootGetters.isOnDefaultBranch)) &&
rootGetters.canPushToBranch;
+export const shouldDisableNewMrOption = (state, getters, rootState, rootGetters) =>
+ !rootGetters.canCreateMergeRequests;
+
+export const shouldCreateMR = (state, getters) =>
+ state.shouldCreateMR && !getters.shouldDisableNewMrOption;
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js
index f10891a8e5b..453df8d7e0c 100644
--- a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js
+++ b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js
@@ -1,4 +1,4 @@
-import { activityBarViews } from '../../../constants';
+import { leftSidebarViews } from '../../../constants';
import { __ } from '~/locale';
export const templateTypes = () => [
@@ -22,6 +22,6 @@ export const templateTypes = () => [
export const showFileTemplatesBar = (_, getters, rootState) => name =>
getters.templateTypes.find(t => t.name === name) &&
- rootState.currentActivityView === activityBarViews.edit;
+ rootState.currentActivityView === leftSidebarViews.edit.name;
export default () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js b/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js
index 0eba9c39817..7576b2477d1 100644
--- a/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js
+++ b/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js
@@ -14,9 +14,10 @@ export default {
iid: mergeRequest.iid,
title: mergeRequest.title,
projectId: mergeRequest.project_id,
- projectPathWithNamespace: mergeRequest.web_url
- .replace(`${gon.gitlab_url}/`, '')
- .replace(`/merge_requests/${mergeRequest.iid}`, ''),
+ projectPathWithNamespace: mergeRequest.references.full.replace(
+ mergeRequest.references.short,
+ '',
+ ),
}));
},
[types.RESET_MERGE_REQUESTS](state) {
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js
index 51cf4dede42..9862c556c2e 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js
@@ -28,7 +28,7 @@ export const receiveLatestPipelineError = ({ commit, dispatch }, err) => {
dispatch(
'setErrorMessage',
{
- text: __('An error occurred whilst fetching the latest pipeline.'),
+ text: __('An error occurred while fetching the latest pipeline.'),
action: () =>
dispatch('forcePipelineRequest').then(() =>
dispatch('setErrorMessage', null, { root: true }),
@@ -84,7 +84,7 @@ export const receiveJobsError = ({ commit, dispatch }, stage) => {
dispatch(
'setErrorMessage',
{
- text: __('An error occurred whilst loading the pipelines jobs.'),
+ text: __('An error occurred while loading the pipelines jobs.'),
action: payload =>
dispatch('fetchJobs', payload).then(() =>
dispatch('setErrorMessage', null, { root: true }),
@@ -123,7 +123,7 @@ export const receiveJobTraceError = ({ commit, dispatch }) => {
dispatch(
'setErrorMessage',
{
- text: __('An error occurred whilst fetching the job trace.'),
+ text: __('An error occurred while fetching the job trace.'),
action: () =>
dispatch('fetchJobTrace').then(() => dispatch('setErrorMessage', null, { root: true })),
actionText: __('Please try again'),
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index 4dde53a9fdf..78831bdf022 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -67,7 +67,6 @@ export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB';
export const UPDATE_ACTIVITY_BAR_VIEW = 'UPDATE_ACTIVITY_BAR_VIEW';
export const UPDATE_TEMP_FLAG = 'UPDATE_TEMP_FLAG';
export const TOGGLE_FILE_FINDER = 'TOGGLE_FILE_FINDER';
-export const BURST_UNUSED_SEAL = 'BURST_UNUSED_SEAL';
export const CLEAR_PROJECTS = 'CLEAR_PROJECTS';
export const RESET_OPEN_FILES = 'RESET_OPEN_FILES';
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index e84e2782e46..49485f4d575 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -180,11 +180,6 @@ export default {
});
}
},
- [types.BURST_UNUSED_SEAL](state) {
- Object.assign(state, {
- unusedSeal: false,
- });
- },
[types.SET_LINKS](state, links) {
Object.assign(state, { links });
},
@@ -226,6 +221,8 @@ export default {
state.changedFiles = state.changedFiles.concat(entry);
}
}
+
+ state.unusedSeal = false;
},
[types.RENAME_ENTRY](state, { path, name, parentPath }) {
const oldEntry = state.entries[path];
diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js
index 313fa1fe029..5c5920a3027 100644
--- a/app/assets/javascripts/ide/stores/mutations/file.js
+++ b/app/assets/javascripts/ide/stores/mutations/file.js
@@ -153,11 +153,13 @@ export default {
[types.ADD_FILE_TO_CHANGED](state, path) {
Object.assign(state, {
changedFiles: state.changedFiles.concat(state.entries[path]),
+ unusedSeal: false,
});
},
[types.REMOVE_FILE_FROM_CHANGED](state, path) {
Object.assign(state, {
changedFiles: state.changedFiles.filter(f => f.path !== path),
+ unusedSeal: false,
});
},
[types.STAGE_CHANGE](state, { path, diffInfo }) {
@@ -173,6 +175,7 @@ export default {
deleted: diffInfo.deleted,
}),
}),
+ unusedSeal: false,
});
if (stagedFile) {
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index 6488389977c..a714562c5b2 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -1,4 +1,5 @@
-import { activityBarViews, viewerTypes } from '../constants';
+import { leftSidebarViews, viewerTypes } from '../constants';
+import { DEFAULT_THEME } from '../lib/themes';
export default () => ({
currentProjectId: '',
@@ -20,7 +21,7 @@ export default () => ({
entries: {},
viewer: viewerTypes.edit,
delayViewerUpdated: false,
- currentActivityView: activityBarViews.edit,
+ currentActivityView: leftSidebarViews.edit.name,
unusedSeal: true,
fileFindVisible: false,
links: {},
@@ -32,4 +33,5 @@ export default () => ({
},
clientsidePreviewEnabled: false,
renderWhitespaceInCode: false,
+ editorTheme: DEFAULT_THEME,
});
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index 47a2e6b5202..06e66da1069 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -163,7 +163,7 @@ export const createCommitPayload = ({
});
export const createNewMergeRequestUrl = (projectUrl, source, target) =>
- `${projectUrl}/merge_requests/new?merge_request[source_branch]=${source}&merge_request[target_branch]=${target}&nav_source=webide`;
+ `${projectUrl}/-/merge_requests/new?merge_request[source_branch]=${source}&merge_request[target_branch]=${target}&nav_source=webide`;
const sortTreesByTypeAndName = (a, b) => {
if (a.type === 'tree' && b.type === 'blob') {
diff --git a/app/assets/javascripts/issuables_list/components/issuable.vue b/app/assets/javascripts/issuables_list/components/issuable.vue
index eb924609a8a..2fd92e009eb 100644
--- a/app/assets/javascripts/issuables_list/components/issuable.vue
+++ b/app/assets/javascripts/issuables_list/components/issuable.vue
@@ -3,7 +3,7 @@
* This is tightly coupled to projects/issues/_issue.html.haml,
* any changes done to the haml need to be reflected here.
*/
-import { escape, isNumber } from 'underscore';
+import { escape, isNumber } from 'lodash';
import { GlLink, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import {
dateInWords,
@@ -19,8 +19,6 @@ import { mergeUrlParams } from '~/lib/utils/url_utility';
import Icon from '~/vue_shared/components/icon.vue';
import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
-const ISSUE_TOKEN = '#';
-
export default {
components: {
Icon,
@@ -119,8 +117,7 @@ export default {
);
},
referencePath() {
- // TODO: The API should return the reference path (it doesn't now) https://gitlab.com/gitlab-org/gitlab/issues/31301
- return `${ISSUE_TOKEN}${this.issuable.iid}`;
+ return this.issuable.references.relative;
},
updatedDateString() {
return formatDate(new Date(this.issuable.updated_at), 'mmm d, yyyy h:MMtt');
@@ -230,7 +227,7 @@ export default {
</div>
<div class="issuable-info">
- <span>{{ referencePath }}</span>
+ <span class="js-ref-path">{{ referencePath }}</span>
<span class="d-none d-sm-inline-block mr-1">
&middot;
diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue
index 163849d3c40..d9168f57cc7 100644
--- a/app/assets/javascripts/jobs/components/environments_block.vue
+++ b/app/assets/javascripts/jobs/components/environments_block.vue
@@ -1,5 +1,5 @@
<script>
-import _ from 'underscore';
+import { escape as esc, isEmpty } from 'lodash';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { sprintf, __ } from '../../locale';
@@ -12,6 +12,11 @@ export default {
type: Object,
required: true,
},
+ deploymentCluster: {
+ type: Object,
+ required: false,
+ default: null,
+ },
iconStatus: {
type: Object,
required: true,
@@ -38,7 +43,7 @@ export default {
'%{startLink}%{name}%{endLink}',
{
startLink: `<a href="${this.deploymentStatus.environment.environment_path}" class="js-environment-link">`,
- name: _.escape(this.deploymentStatus.environment.name),
+ name: esc(this.deploymentStatus.environment.name),
endLink: '</a>',
},
false,
@@ -53,24 +58,24 @@ export default {
return this.hasLastDeployment ? this.deploymentStatus.environment.last_deployment : {};
},
hasEnvironment() {
- return !_.isEmpty(this.deploymentStatus.environment);
+ return !isEmpty(this.deploymentStatus.environment);
},
lastDeploymentPath() {
- return !_.isEmpty(this.lastDeployment.deployable)
+ return !isEmpty(this.lastDeployment.deployable)
? this.lastDeployment.deployable.build_path
: '';
},
hasCluster() {
- return this.hasLastDeployment && this.lastDeployment.cluster;
+ return Boolean(this.deploymentCluster) && Boolean(this.deploymentCluster.name);
},
clusterNameOrLink() {
if (!this.hasCluster) {
return '';
}
- const { name, path } = this.lastDeployment.cluster;
- const escapedName = _.escape(name);
- const escapedPath = _.escape(path);
+ const { name, path } = this.deploymentCluster;
+ const escapedName = esc(name);
+ const escapedPath = esc(path);
if (!escapedPath) {
return escapedName;
@@ -86,6 +91,9 @@ export default {
false,
);
},
+ kubernetesNamespace() {
+ return this.hasCluster ? this.deploymentCluster.kubernetes_namespace : null;
+ },
},
methods: {
deploymentLink(name) {
@@ -109,75 +117,153 @@ export default {
);
},
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);
+ const { environmentLink, clusterNameOrLink, hasCluster, kubernetesNamespace } = this;
+ if (hasCluster) {
+ if (kubernetesNamespace) {
+ return sprintf(
+ __(
+ 'This job is deployed to %{environmentLink} using cluster %{clusterNameOrLink} and namespace %{kubernetesNamespace}.',
+ ),
+ { environmentLink, clusterNameOrLink, kubernetesNamespace },
+ false,
+ );
+ }
+ // we know the cluster but not the namespace
+ return sprintf(
+ __('This job is deployed to %{environmentLink} using cluster %{clusterNameOrLink}.'),
+ { environmentLink, clusterNameOrLink },
+ false,
+ );
+ }
+ // not a cluster deployment
+ return sprintf(__('This job is deployed to %{environmentLink}.'), { environmentLink }, false);
},
outOfDateEnvironmentMessage() {
- const { hasLastDeployment, hasCluster, environmentLink, clusterNameOrLink } = this;
+ const {
+ hasLastDeployment,
+ hasCluster,
+ environmentLink,
+ clusterNameOrLink,
+ kubernetesNamespace,
+ } = 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}.',
+ const deploymentLink = this.deploymentLink(__('most recent deployment'));
+ if (hasCluster) {
+ if (kubernetesNamespace) {
+ return sprintf(
+ __(
+ 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink} and namespace %{kubernetesNamespace}. View the %{deploymentLink}.',
+ ),
+ { environmentLink, clusterNameOrLink, kubernetesNamespace, deploymentLink },
+ false,
);
-
+ }
+ // we know the cluster but not the namespace
+ return sprintf(
+ __(
+ 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink}. View the %{deploymentLink}.',
+ ),
+ { environmentLink, clusterNameOrLink, deploymentLink },
+ false,
+ );
+ }
+ // not a cluster deployment
return sprintf(
- message,
- {
- environmentLink,
- clusterNameOrLink,
- deploymentLink: this.deploymentLink(__('most recent deployment')),
- },
+ __(
+ 'This job is an out-of-date deployment to %{environmentLink}. View the %{deploymentLink}.',
+ ),
+ { environmentLink, deploymentLink },
false,
);
}
-
- const message = hasCluster
- ? __(
+ // no last deployment, i.e. this is the first deployment
+ if (hasCluster) {
+ if (kubernetesNamespace) {
+ return sprintf(
+ __(
+ 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink} and namespace %{kubernetesNamespace}.',
+ ),
+ { environmentLink, clusterNameOrLink, kubernetesNamespace },
+ false,
+ );
+ }
+ // we know the cluster but not the namespace
+ return sprintf(
+ __(
'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink}.',
- )
- : __('This job is an out-of-date deployment to %{environmentLink}.');
-
+ ),
+ { environmentLink, clusterNameOrLink },
+ false,
+ );
+ }
+ // not a cluster deployment
return sprintf(
- message,
- {
- environmentLink,
- clusterNameOrLink,
- },
+ __('This job is an out-of-date deployment to %{environmentLink}.'),
+ { environmentLink },
false,
);
},
creatingEnvironmentMessage() {
- const { hasLastDeployment, hasCluster, environmentLink, clusterNameOrLink } = this;
+ const {
+ hasLastDeployment,
+ hasCluster,
+ environmentLink,
+ clusterNameOrLink,
+ kubernetesNamespace,
+ } = 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}.',
+ const deploymentLink = this.deploymentLink(__('latest deployment'));
+ if (hasCluster) {
+ if (kubernetesNamespace) {
+ return sprintf(
+ __(
+ 'This job is creating a deployment to %{environmentLink} using cluster %{clusterNameOrLink} and namespace %{kubernetesNamespace}. This will overwrite the %{deploymentLink}.',
+ ),
+ { environmentLink, clusterNameOrLink, kubernetesNamespace, deploymentLink },
+ false,
);
-
+ }
+ // we know the cluster but not the namespace
+ return sprintf(
+ __(
+ 'This job is creating a deployment to %{environmentLink} using cluster %{clusterNameOrLink}. This will overwrite the %{deploymentLink}.',
+ ),
+ { environmentLink, clusterNameOrLink, deploymentLink },
+ false,
+ );
+ }
+ // not a cluster deployment
return sprintf(
- message,
- {
- environmentLink,
- clusterNameOrLink,
- deploymentLink: this.deploymentLink(__('latest deployment')),
- },
+ __(
+ 'This job is creating a deployment to %{environmentLink}. This will overwrite the %{deploymentLink}.',
+ ),
+ { environmentLink, deploymentLink },
false,
);
}
-
+ // no last deployment, i.e. this is the first deployment
+ if (hasCluster) {
+ if (kubernetesNamespace) {
+ return sprintf(
+ __(
+ 'This job is creating a deployment to %{environmentLink} using cluster %{clusterNameOrLink} and namespace %{kubernetesNamespace}.',
+ ),
+ { environmentLink, clusterNameOrLink, kubernetesNamespace },
+ false,
+ );
+ }
+ // we know the cluster but not the namespace
+ return sprintf(
+ __(
+ 'This job is creating a deployment to %{environmentLink} using cluster %{clusterNameOrLink}.',
+ ),
+ { environmentLink, clusterNameOrLink },
+ false,
+ );
+ }
+ // not a cluster deployment
return sprintf(
__('This job is creating a deployment to %{environmentLink}.'),
{ environmentLink },
diff --git a/app/assets/javascripts/jobs/components/erased_block.vue b/app/assets/javascripts/jobs/components/erased_block.vue
index 8437ad89301..fc5e022f44a 100644
--- a/app/assets/javascripts/jobs/components/erased_block.vue
+++ b/app/assets/javascripts/jobs/components/erased_block.vue
@@ -1,5 +1,5 @@
<script>
-import _ from 'underscore';
+import { isEmpty } from 'lodash';
import { GlLink } from '@gitlab/ui';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -21,7 +21,7 @@ export default {
},
computed: {
isErasedByUser() {
- return !_.isEmpty(this.user);
+ return !isEmpty(this.user);
},
},
};
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index 809b3d5f57e..0783d1157be 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -1,5 +1,5 @@
<script>
-import _ from 'underscore';
+import { throttle, isEmpty } from 'lodash';
import { mapGetters, mapState, mapActions } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
@@ -8,7 +8,6 @@ import { polyfillSticky } from '~/lib/utils/sticky';
import CiHeader from '~/vue_shared/components/header_ci_component.vue';
import Callout from '~/vue_shared/components/callout.vue';
import Icon from '~/vue_shared/components/icon.vue';
-import createStore from '../store';
import EmptyState from './empty_state.vue';
import EnvironmentsBlock from './environments_block.vue';
import ErasedBlock from './erased_block.vue';
@@ -22,7 +21,6 @@ import { isNewJobLogActive } from '../store/utils';
export default {
name: 'JobPageApp',
- store: createStore(),
components: {
CiHeader,
Callout,
@@ -60,27 +58,15 @@ export default {
required: false,
default: null,
},
- endpoint: {
- type: String,
- required: true,
- },
terminalPath: {
type: String,
required: false,
default: null,
},
- pagePath: {
- type: String,
- required: true,
- },
projectPath: {
type: String,
required: true,
},
- logState: {
- type: String,
- required: true,
- },
subscriptionsMoreMinutesUrl: {
type: String,
required: false,
@@ -139,7 +125,7 @@ export default {
// Once the job log is loaded,
// fetch the stages for the dropdown on the sidebar
job(newVal, oldVal) {
- if (_.isEmpty(oldVal) && !_.isEmpty(newVal.pipeline)) {
+ if (isEmpty(oldVal) && !isEmpty(newVal.pipeline)) {
const stages = this.job.pipeline.details.stages || [];
const defaultStage = stages.find(stage => stage && stage.name === this.selectedStage);
@@ -159,16 +145,7 @@ export default {
},
},
created() {
- this.throttled = _.throttle(this.toggleScrollButtons, 100);
-
- this.setJobEndpoint(this.endpoint);
- this.setTraceOptions({
- logState: this.logState,
- pagePath: this.pagePath,
- });
-
- this.fetchJob();
- this.fetchTrace();
+ this.throttled = throttle(this.toggleScrollButtons, 100);
window.addEventListener('resize', this.onResize);
window.addEventListener('scroll', this.updateScroll);
@@ -176,22 +153,22 @@ export default {
mounted() {
this.updateSidebar();
},
- destroyed() {
+ beforeDestroy() {
+ this.stopPollingTrace();
+ this.stopPolling();
window.removeEventListener('resize', this.onResize);
window.removeEventListener('scroll', this.updateScroll);
},
methods: {
...mapActions([
- 'setJobEndpoint',
- 'setTraceOptions',
- 'fetchJob',
'fetchJobsForStage',
'hideSidebar',
'showSidebar',
'toggleSidebar',
- 'fetchTrace',
'scrollBottom',
'scrollTop',
+ 'stopPollingTrace',
+ 'stopPolling',
'toggleScrollButtons',
'toggleScrollAnimation',
]),
@@ -223,7 +200,7 @@ export default {
<div>
<gl-loading-icon
v-if="isLoading"
- :size="2"
+ size="lg"
class="js-job-loading qa-loading-animation prepend-top-20"
/>
@@ -279,6 +256,7 @@ export default {
v-if="hasEnvironment"
class="js-job-environment"
:deployment-status="job.deployment_status"
+ :deployment-cluster="job.deployment_cluster"
:icon-status="job.status"
/>
diff --git a/app/assets/javascripts/jobs/components/manual_variables_form.vue b/app/assets/javascripts/jobs/components/manual_variables_form.vue
index c32a3cac7be..a23f30d571a 100644
--- a/app/assets/javascripts/jobs/components/manual_variables_form.vue
+++ b/app/assets/javascripts/jobs/components/manual_variables_form.vue
@@ -1,5 +1,5 @@
<script>
-import _ from 'underscore';
+import { uniqueId } from 'lodash';
import { mapActions } from 'vuex';
import { GlButton } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
@@ -19,7 +19,9 @@ export default {
validator(value) {
return (
value === null ||
- (_.has(value, 'path') && _.has(value, 'method') && _.has(value, 'button_title'))
+ (Object.prototype.hasOwnProperty.call(value, 'path') &&
+ Object.prototype.hasOwnProperty.call(value, 'method') &&
+ Object.prototype.hasOwnProperty.call(value, 'button_title'))
);
},
},
@@ -78,7 +80,7 @@ export default {
const newVariable = {
key: this.key,
secret_value: this.secretValue,
- id: _.uniqueId(),
+ id: uniqueId(),
};
this.variables.push(newVariable);
diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue
index 415fa46835b..f1683bc2195 100644
--- a/app/assets/javascripts/jobs/components/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/sidebar.vue
@@ -1,5 +1,5 @@
<script>
-import _ from 'underscore';
+import { isEmpty } from 'lodash';
import { mapActions, mapState } from 'vuex';
import { GlLink, GlButton } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
@@ -84,10 +84,10 @@ export default {
);
},
hasArtifact() {
- return !_.isEmpty(this.job.artifact);
+ return !isEmpty(this.job.artifact);
},
hasTriggers() {
- return !_.isEmpty(this.job.trigger);
+ return !isEmpty(this.job.trigger);
},
hasStages() {
return (
@@ -119,6 +119,7 @@ export default {
:class="retryButtonClass"
:href="job.retry_path"
data-method="post"
+ data-qa-selector="retry_button"
rel="nofollow"
>{{ __('Retry') }}</gl-link
>
diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue
index 09f9647a680..ddcfc3d6db6 100644
--- a/app/assets/javascripts/jobs/components/stages_dropdown.vue
+++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue
@@ -1,5 +1,5 @@
<script>
-import _ from 'underscore';
+import { isEmpty } from 'lodash';
import { GlLink } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
@@ -24,7 +24,7 @@ export default {
},
computed: {
hasRef() {
- return !_.isEmpty(this.pipeline.ref);
+ return !isEmpty(this.pipeline.ref);
},
isTriggeredByMergeRequest() {
return Boolean(this.pipeline.merge_request);
diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js
index 9c35534523e..024a13ce102 100644
--- a/app/assets/javascripts/jobs/index.js
+++ b/app/assets/javascripts/jobs/index.js
@@ -1,11 +1,18 @@
import Vue from 'vue';
import JobApp from './components/job_app.vue';
+import createStore from './store';
export default () => {
const element = document.getElementById('js-job-vue-app');
+ const store = createStore();
+
+ // Let's start initializing the store (i.e. fetching data) right away
+ store.dispatch('init', element.dataset);
+
return new Vue({
el: element,
+ store,
components: {
JobApp,
},
diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js
index 41cc5a181dc..f4030939f2c 100644
--- a/app/assets/javascripts/jobs/store/actions.js
+++ b/app/assets/javascripts/jobs/store/actions.js
@@ -14,6 +14,16 @@ import {
scrollUp,
} from '~/lib/utils/scroll_utils';
+export const init = ({ dispatch }, { endpoint, logState, pagePath }) => {
+ dispatch('setJobEndpoint', endpoint);
+ dispatch('setTraceOptions', {
+ logState,
+ pagePath,
+ });
+
+ return Promise.all([dispatch('fetchJob'), dispatch('fetchTrace')]);
+};
+
export const setJobEndpoint = ({ commit }, endpoint) => commit(types.SET_JOB_ENDPOINT, endpoint);
export const setTraceOptions = ({ commit }, options) => commit(types.SET_TRACE_OPTIONS, options);
@@ -147,7 +157,6 @@ export const toggleScrollisInBottom = ({ commit }, toggle) => {
export const requestTrace = ({ commit }) => commit(types.REQUEST_TRACE);
-let traceTimeout;
export const fetchTrace = ({ dispatch, state }) =>
axios
.get(`${state.traceEndpoint}/trace.json`, {
@@ -157,24 +166,32 @@ export const fetchTrace = ({ dispatch, state }) =>
dispatch('toggleScrollisInBottom', isScrolledToBottom());
dispatch('receiveTraceSuccess', data);
- if (!data.complete) {
- traceTimeout = setTimeout(() => {
- dispatch('fetchTrace');
- }, 4000);
- } else {
+ if (data.complete) {
dispatch('stopPollingTrace');
+ } else if (!state.traceTimeout) {
+ dispatch('startPollingTrace');
}
})
.catch(() => dispatch('receiveTraceError'));
-export const stopPollingTrace = ({ commit }) => {
+export const startPollingTrace = ({ dispatch, commit }) => {
+ const traceTimeout = setTimeout(() => {
+ commit(types.SET_TRACE_TIMEOUT, 0);
+ dispatch('fetchTrace');
+ }, 4000);
+
+ commit(types.SET_TRACE_TIMEOUT, traceTimeout);
+};
+
+export const stopPollingTrace = ({ state, commit }) => {
+ clearTimeout(state.traceTimeout);
+ commit(types.SET_TRACE_TIMEOUT, 0);
commit(types.STOP_POLLING_TRACE);
- clearTimeout(traceTimeout);
};
+
export const receiveTraceSuccess = ({ commit }, log) => commit(types.RECEIVE_TRACE_SUCCESS, log);
-export const receiveTraceError = ({ commit }) => {
- commit(types.RECEIVE_TRACE_ERROR);
- clearTimeout(traceTimeout);
+export const receiveTraceError = ({ dispatch }) => {
+ dispatch('stopPollingTrace');
flash(__('An error occurred while fetching the job log.'));
};
/**
diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js
index 406b1a2e375..3f02f924eed 100644
--- a/app/assets/javascripts/jobs/store/getters.js
+++ b/app/assets/javascripts/jobs/store/getters.js
@@ -1,4 +1,4 @@
-import _ from 'underscore';
+import { isEmpty, isString } from 'lodash';
import { isScrolledToBottom } from '~/lib/utils/scroll_utils';
export const headerTime = state => (state.job.started ? state.job.started : state.job.created_at);
@@ -7,15 +7,15 @@ export const hasUnmetPrerequisitesFailure = state =>
state.job && state.job.failure_reason && state.job.failure_reason === 'unmet_prerequisites';
export const shouldRenderCalloutMessage = state =>
- !_.isEmpty(state.job.status) && !_.isEmpty(state.job.callout_message);
+ !isEmpty(state.job.status) && !isEmpty(state.job.callout_message);
/**
* When job has not started the key will be null
* When job started the key will be a string with a date.
*/
-export const shouldRenderTriggeredLabel = state => _.isString(state.job.started);
+export const shouldRenderTriggeredLabel = state => isString(state.job.started);
-export const hasEnvironment = state => !_.isEmpty(state.job.deployment_status);
+export const hasEnvironment = state => !isEmpty(state.job.deployment_status);
/**
* Checks if it the job has trace.
@@ -23,7 +23,7 @@ export const hasEnvironment = state => !_.isEmpty(state.job.deployment_status);
* @returns {Boolean}
*/
export const hasTrace = state =>
- state.job.has_trace || (!_.isEmpty(state.job.status) && state.job.status.group === 'running');
+ state.job.has_trace || (!isEmpty(state.job.status) && state.job.status.group === 'running');
export const emptyStateIllustration = state =>
(state.job && state.job.status && state.job.status.illustration) || {};
@@ -38,8 +38,8 @@ export const emptyStateAction = state =>
* @returns {Boolean}
*/
export const shouldRenderSharedRunnerLimitWarning = state =>
- !_.isEmpty(state.job.runners) &&
- !_.isEmpty(state.job.runners.quota) &&
+ !isEmpty(state.job.runners) &&
+ !isEmpty(state.job.runners.quota) &&
state.job.runners.quota.used >= state.job.runners.quota.limit;
export const isScrollingDown = state => isScrolledToBottom() && !state.isTraceComplete;
diff --git a/app/assets/javascripts/jobs/store/mutation_types.js b/app/assets/javascripts/jobs/store/mutation_types.js
index 858fa3b73ab..6c4f1b5a191 100644
--- a/app/assets/javascripts/jobs/store/mutation_types.js
+++ b/app/assets/javascripts/jobs/store/mutation_types.js
@@ -10,7 +10,6 @@ export const DISABLE_SCROLL_BOTTOM = 'DISABLE_SCROLL_BOTTOM';
export const DISABLE_SCROLL_TOP = 'DISABLE_SCROLL_TOP';
export const ENABLE_SCROLL_BOTTOM = 'ENABLE_SCROLL_BOTTOM';
export const ENABLE_SCROLL_TOP = 'ENABLE_SCROLL_TOP';
-// TODO
export const TOGGLE_SCROLL_ANIMATION = 'TOGGLE_SCROLL_ANIMATION';
export const TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_TRACE = 'TOGGLE_IS_SCROLL_IN_BOTTOM';
@@ -20,6 +19,7 @@ export const RECEIVE_JOB_SUCCESS = 'RECEIVE_JOB_SUCCESS';
export const RECEIVE_JOB_ERROR = 'RECEIVE_JOB_ERROR';
export const REQUEST_TRACE = 'REQUEST_TRACE';
+export const SET_TRACE_TIMEOUT = 'SET_TRACE_TIMEOUT';
export const STOP_POLLING_TRACE = 'STOP_POLLING_TRACE';
export const RECEIVE_TRACE_SUCCESS = 'RECEIVE_TRACE_SUCCESS';
export const RECEIVE_TRACE_ERROR = 'RECEIVE_TRACE_ERROR';
diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js
index 77c68cac4a6..6193d8d34ab 100644
--- a/app/assets/javascripts/jobs/store/mutations.js
+++ b/app/assets/javascripts/jobs/store/mutations.js
@@ -53,17 +53,14 @@ export default {
state.isTraceComplete = log.complete || state.isTraceComplete;
},
- /**
- * Will remove loading animation
- */
- [types.STOP_POLLING_TRACE](state) {
- state.isTraceComplete = true;
+ [types.SET_TRACE_TIMEOUT](state, id) {
+ state.traceTimeout = id;
},
/**
* Will remove loading animation
*/
- [types.RECEIVE_TRACE_ERROR](state) {
+ [types.STOP_POLLING_TRACE](state) {
state.isTraceComplete = true;
},
diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js
index cdc1780f3d6..5a61828ec6d 100644
--- a/app/assets/javascripts/jobs/store/state.js
+++ b/app/assets/javascripts/jobs/store/state.js
@@ -22,6 +22,7 @@ export default () => ({
isTraceComplete: false,
traceSize: 0,
isTraceSizeVisible: false,
+ traceTimeout: 0,
// used as a query parameter to fetch the trace
traceState: null,
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index 2c5278d16ae..b49fe9362c2 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -5,6 +5,14 @@ import { ApolloLink } from 'apollo-link';
import { BatchHttpLink } from 'apollo-link-batch-http';
import csrf from '~/lib/utils/csrf';
+export const fetchPolicies = {
+ CACHE_FIRST: 'cache-first',
+ CACHE_AND_NETWORK: 'cache-and-network',
+ NETWORK_ONLY: 'network-only',
+ NO_CACHE: 'no-cache',
+ CACHE_ONLY: 'cache-only',
+};
+
export default (resolvers = {}, config = {}) => {
let uri = `${gon.relative_url_root}/api/graphql`;
@@ -32,5 +40,10 @@ export default (resolvers = {}, config = {}) => {
}),
resolvers,
assumeImmutableResults: config.assumeImmutableResults,
+ defaultOptions: {
+ query: {
+ fetchPolicy: config.fetchPolicy || fetchPolicies.CACHE_FIRST,
+ },
+ },
});
};
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index a2591180039..dd5a52fe1ce 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -327,7 +327,10 @@ export const getSelectedFragment = restrictToNode => {
documentFragment.originalNodes.push(range.commonAncestorContainer);
}
}
- if (documentFragment.textContent.length === 0) return null;
+
+ if (documentFragment.textContent.length === 0 && documentFragment.children.length === 0) {
+ return null;
+ }
return documentFragment;
};
diff --git a/app/assets/javascripts/lib/utils/datetime_range.js b/app/assets/javascripts/lib/utils/datetime_range.js
new file mode 100644
index 00000000000..6d4e21cf386
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/datetime_range.js
@@ -0,0 +1,320 @@
+import dateformat from 'dateformat';
+import { pick, omit, isEqual, isEmpty } from 'lodash';
+import { secondsToMilliseconds } from './datetime_utility';
+
+const MINIMUM_DATE = new Date(0);
+
+const DEFAULT_DIRECTION = 'before';
+
+const durationToMillis = duration => {
+ if (Object.entries(duration).length === 1 && Number.isFinite(duration.seconds)) {
+ return secondsToMilliseconds(duration.seconds);
+ }
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
+ throw new Error('Invalid duration: only `seconds` is supported');
+};
+
+const dateMinusDuration = (date, duration) => new Date(date.getTime() - durationToMillis(duration));
+
+const datePlusDuration = (date, duration) => new Date(date.getTime() + durationToMillis(duration));
+
+const isValidDuration = duration => Boolean(duration && Number.isFinite(duration.seconds));
+
+const isValidDateString = dateString => {
+ if (typeof dateString !== 'string' || !dateString.trim()) {
+ return false;
+ }
+
+ try {
+ // dateformat throws error that can be caught.
+ // This is better than using `new Date()`
+ dateformat(dateString, 'isoUtcDateTime');
+ return true;
+ } catch (e) {
+ return false;
+ }
+};
+
+const handleRangeDirection = ({ direction = DEFAULT_DIRECTION, anchorDate, minDate, maxDate }) => {
+ let startDate;
+ let endDate;
+
+ if (direction === DEFAULT_DIRECTION) {
+ startDate = minDate;
+ endDate = anchorDate;
+ } else {
+ startDate = anchorDate;
+ endDate = maxDate;
+ }
+
+ return {
+ startDate,
+ endDate,
+ };
+};
+
+/**
+ * Converts a fixed range to a fixed range
+ * @param {Object} fixedRange - A range with fixed start and
+ * end (e.g. "midnight January 1st 2020 to midday January31st 2020")
+ */
+const convertFixedToFixed = ({ start, end }) => ({
+ start,
+ end,
+});
+
+/**
+ * Converts an anchored range to a fixed range
+ * @param {Object} anchoredRange - A duration of time
+ * relative to a fixed point in time (e.g., "the 30 minutes
+ * before midnight January 1st 2020", or "the 2 days
+ * after midday on the 11th of May 2019")
+ */
+const convertAnchoredToFixed = ({ anchor, duration, direction }) => {
+ const anchorDate = new Date(anchor);
+
+ const { startDate, endDate } = handleRangeDirection({
+ minDate: dateMinusDuration(anchorDate, duration),
+ maxDate: datePlusDuration(anchorDate, duration),
+ direction,
+ anchorDate,
+ });
+
+ return {
+ start: startDate.toISOString(),
+ end: endDate.toISOString(),
+ };
+};
+
+/**
+ * Converts a rolling change to a fixed range
+ *
+ * @param {Object} rollingRange - A time range relative to
+ * now (e.g., "last 2 minutes", or "next 2 days")
+ */
+const convertRollingToFixed = ({ duration, direction }) => {
+ // Use Date.now internally for easier mocking in tests
+ const now = new Date(Date.now());
+
+ return convertAnchoredToFixed({
+ duration,
+ direction,
+ anchor: now.toISOString(),
+ });
+};
+
+/**
+ * Converts an open range to a fixed range
+ *
+ * @param {Object} openRange - A time range relative
+ * to an anchor (e.g., "before midnight on the 1st of
+ * January 2020", or "after midday on the 11th of May 2019")
+ */
+const convertOpenToFixed = ({ anchor, direction }) => {
+ // Use Date.now internally for easier mocking in tests
+ const now = new Date(Date.now());
+
+ const { startDate, endDate } = handleRangeDirection({
+ minDate: MINIMUM_DATE,
+ maxDate: now,
+ direction,
+ anchorDate: new Date(anchor),
+ });
+
+ return {
+ start: startDate.toISOString(),
+ end: endDate.toISOString(),
+ };
+};
+
+/**
+ * Handles invalid date ranges
+ */
+const handleInvalidRange = () => {
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
+ throw new Error('The input range does not have the right format.');
+};
+
+const handlers = {
+ invalid: handleInvalidRange,
+ fixed: convertFixedToFixed,
+ anchored: convertAnchoredToFixed,
+ rolling: convertRollingToFixed,
+ open: convertOpenToFixed,
+};
+
+/**
+ * Validates and returns the type of range
+ *
+ * @param {Object} Date time range
+ * @returns {String} `key` value for one of the handlers
+ */
+export function getRangeType(range) {
+ const { start, end, anchor, duration } = range;
+
+ if ((start || end) && !anchor && !duration) {
+ return isValidDateString(start) && isValidDateString(end) ? 'fixed' : 'invalid';
+ }
+ if (anchor && duration) {
+ return isValidDateString(anchor) && isValidDuration(duration) ? 'anchored' : 'invalid';
+ }
+ if (duration && !anchor) {
+ return isValidDuration(duration) ? 'rolling' : 'invalid';
+ }
+ if (anchor && !duration) {
+ return isValidDateString(anchor) ? 'open' : 'invalid';
+ }
+ return 'invalid';
+}
+
+/**
+ * convertToFixedRange Transforms a `range of time` into a `fixed range of time`.
+ *
+ * The following types of a `ranges of time` can be represented:
+ *
+ * Fixed Range: A range with fixed start and end (e.g. "midnight January 1st 2020 to midday January 31st 2020")
+ * Anchored Range: A duration of time relative to a fixed point in time (e.g., "the 30 minutes before midnight January 1st 2020", or "the 2 days after midday on the 11th of May 2019")
+ * Rolling Range: A time range relative to now (e.g., "last 2 minutes", or "next 2 days")
+ * Open Range: A time range relative to an anchor (e.g., "before midnight on the 1st of January 2020", or "after midday on the 11th of May 2019")
+ *
+ * @param {Object} dateTimeRange - A Time Range representation
+ * It contains the data needed to create a fixed time range plus
+ * a label (recommended) to indicate the range that is covered.
+ *
+ * A definition via a TypeScript notation is presented below:
+ *
+ *
+ * type Duration = { // A duration of time, always in seconds
+ * seconds: number;
+ * }
+ *
+ * type Direction = 'before' | 'after'; // Direction of time relative to an anchor
+ *
+ * type FixedRange = {
+ * start: ISO8601;
+ * end: ISO8601;
+ * label: string;
+ * }
+ *
+ * type AnchoredRange = {
+ * anchor: ISO8601;
+ * duration: Duration;
+ * direction: Direction; // defaults to 'before'
+ * label: string;
+ * }
+ *
+ * type RollingRange = {
+ * duration: Duration;
+ * direction: Direction; // defaults to 'before'
+ * label: string;
+ * }
+ *
+ * type OpenRange = {
+ * anchor: ISO8601;
+ * direction: Direction; // defaults to 'before'
+ * label: string;
+ * }
+ *
+ * type DateTimeRange = FixedRange | AnchoredRange | RollingRange | OpenRange;
+ *
+ *
+ * @returns {FixedRange} An object with a start and end in ISO8601 format.
+ */
+export const convertToFixedRange = dateTimeRange =>
+ handlers[getRangeType(dateTimeRange)](dateTimeRange);
+
+/**
+ * Returns a copy of the object only with time range
+ * properties relevant to time range calculation.
+ *
+ * Filtered properties are:
+ * - 'start'
+ * - 'end'
+ * - 'anchor'
+ * - 'duration'
+ * - 'direction': if direction is already the default, its removed.
+ *
+ * @param {Object} timeRange - A time range object
+ * @returns Copy of time range
+ */
+const pruneTimeRange = timeRange => {
+ const res = pick(timeRange, ['start', 'end', 'anchor', 'duration', 'direction']);
+ if (res.direction === DEFAULT_DIRECTION) {
+ return omit(res, 'direction');
+ }
+ return res;
+};
+
+/**
+ * Returns true if the time ranges are equal according to
+ * the time range calculation properties
+ *
+ * @param {Object} timeRange - A time range object
+ * @param {Object} other - Time range object to compare with.
+ * @returns true if the time ranges are equal, false otherwise
+ */
+export const isEqualTimeRanges = (timeRange, other) => {
+ const tr1 = pruneTimeRange(timeRange);
+ const tr2 = pruneTimeRange(other);
+ return isEqual(tr1, tr2);
+};
+
+/**
+ * Searches for a time range in a array of time ranges using
+ * only the properies relevant to time ranges calculation.
+ *
+ * @param {Object} timeRange - Time range to search (needle)
+ * @param {Array} timeRanges - Array of time tanges (haystack)
+ */
+export const findTimeRange = (timeRange, timeRanges) =>
+ timeRanges.find(element => isEqualTimeRanges(element, timeRange));
+
+// Time Ranges as URL Parameters Utils
+
+/**
+ * List of possible time ranges parameters
+ */
+export const timeRangeParamNames = ['start', 'end', 'anchor', 'duration_seconds', 'direction'];
+
+/**
+ * Converts a valid time range to a flat key-value pairs object.
+ *
+ * Duration is flatted to avoid having nested objects.
+ *
+ * @param {Object} A time range
+ * @returns key-value pairs object that can be used as parameters in a URL.
+ */
+export const timeRangeToParams = timeRange => {
+ let params = pruneTimeRange(timeRange);
+ if (timeRange.duration) {
+ const durationParms = {};
+ Object.keys(timeRange.duration).forEach(key => {
+ durationParms[`duration_${key}`] = timeRange.duration[key].toString();
+ });
+ params = { ...durationParms, ...params };
+ params = omit(params, 'duration');
+ }
+ return params;
+};
+
+/**
+ * Converts a valid set of flat params to a time range object
+ *
+ * Parameters that are not part of time range object are ignored.
+ *
+ * @param {params} params - key-value pairs object.
+ */
+export const timeRangeFromParams = params => {
+ const timeRangeParams = pick(params, timeRangeParamNames);
+ let range = Object.entries(timeRangeParams).reduce((acc, [key, val]) => {
+ // unflatten duration
+ if (key.startsWith('duration_')) {
+ acc.duration = acc.duration || {};
+ acc.duration[key.slice('duration_'.length)] = parseInt(val, 10);
+ return acc;
+ }
+ return { [key]: val, ...acc };
+ }, {});
+ range = pruneTimeRange(range);
+ return !isEmpty(range) ? range : null;
+};
diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js
index 1c7d59054dc..08a77966bbd 100644
--- a/app/assets/javascripts/lib/utils/http_status.js
+++ b/app/assets/javascripts/lib/utils/http_status.js
@@ -19,6 +19,7 @@ const httpStatusCodes = {
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
+ CONFLICT: 409,
GONE: 410,
UNPROCESSABLE_ENTITY: 422,
SERVICE_UNAVAILABLE: 503,
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index d48678c21f6..1ff4f7bab97 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -1,6 +1,14 @@
const PATH_SEPARATOR = '/';
const PATH_SEPARATOR_LEADING_REGEX = new RegExp(`^${PATH_SEPARATOR}+`);
const PATH_SEPARATOR_ENDING_REGEX = new RegExp(`${PATH_SEPARATOR}+$`);
+const SHA_REGEX = /[\da-f]{40}/gi;
+
+// Reset the cursor in a Regex so that multiple uses before a recompile don't fail
+function resetRegExp(regex) {
+ regex.lastIndex = 0; /* eslint-disable-line no-param-reassign */
+
+ return regex;
+}
// Returns a decoded url parameter value
// - Treats '+' as '%20'
@@ -128,6 +136,20 @@ export function doesHashExistInUrl(hashName) {
return hash && hash.includes(hashName);
}
+export function urlContainsSha({ url = String(window.location) } = {}) {
+ return resetRegExp(SHA_REGEX).test(url);
+}
+
+export function getShaFromUrl({ url = String(window.location) } = {}) {
+ let sha = null;
+
+ if (urlContainsSha({ url })) {
+ [sha] = url.match(resetRegExp(SHA_REGEX));
+ }
+
+ return sha;
+}
+
/**
* 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
@@ -144,7 +166,7 @@ export const setUrlFragment = (url, fragment) => {
export function visitUrl(url, external = false) {
if (external) {
- // Simulate `target="blank" rel="noopener noreferrer"`
+ // Simulate `target="_blank" rel="noopener noreferrer"`
// See https://mathiasbynens.github.io/rel-noopener/
const otherWindow = window.open();
otherWindow.opener = null;
@@ -154,6 +176,16 @@ export function visitUrl(url, external = false) {
}
}
+export function updateHistory({ state = {}, title = '', url, replace = false, win = window } = {}) {
+ if (win.history) {
+ if (replace) {
+ win.history.replaceState(state, title, url);
+ } else {
+ win.history.pushState(state, title, url);
+ }
+ }
+}
+
export function refreshCurrentPage() {
visitUrl(window.location.href);
}
@@ -162,12 +194,14 @@ export function redirectTo(url) {
return window.location.assign(url);
}
+export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/');
+
export function webIDEUrl(route = undefined) {
let returnUrl = `${gon.relative_url_root || ''}/-/ide/`;
if (route) {
returnUrl += `project${route.replace(new RegExp(`^${gon.relative_url_root || ''}`), '')}`;
}
- return returnUrl;
+ return escapeFileUrl(returnUrl);
}
/**
@@ -281,4 +315,6 @@ export const setUrlParams = (params, url = window.location.href, clearParams = f
return urlObj.toString();
};
-export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/');
+export function urlIsDifferent(url, compare = String(window.location)) {
+ return url !== compare;
+}
diff --git a/app/assets/javascripts/lib/utils/webpack.js b/app/assets/javascripts/lib/utils/webpack.js
index 37b17f0fe23..390294afcb7 100644
--- a/app/assets/javascripts/lib/utils/webpack.js
+++ b/app/assets/javascripts/lib/utils/webpack.js
@@ -8,7 +8,7 @@ export function resetServiceWorkersPublicPath() {
// see: https://webpack.js.org/guides/public-path/
const relativeRootPath = (gon && gon.relative_url_root) || '';
const webpackAssetPath = joinPaths(relativeRootPath, '/assets/webpack/');
- __webpack_public_path__ = webpackAssetPath; // eslint-disable-line camelcase
+ __webpack_public_path__ = webpackAssetPath; // eslint-disable-line babel/camelcase
// monaco-editor-webpack-plugin currently (incorrectly) references the
// public path as a property of `window`. Once this is fixed upstream we
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index d755e7e8cdb..5b645b032ed 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -35,6 +35,8 @@ import initPerformanceBar from './performance_bar';
import initSearchAutocomplete from './search_autocomplete';
import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
+import initBroadcastNotifications from './broadcast_notification';
+import PersistentUserCallout from './persistent_user_callout';
import { initUserTracking } from './tracking';
import { __ } from './locale';
@@ -105,6 +107,10 @@ function deferredInitialisation() {
initUsagePingConsent();
initUserPopovers();
initUserTracking();
+ initBroadcastNotifications();
+
+ const recoverySettingsCallout = document.querySelector('.js-recovery-settings-callout');
+ PersistentUserCallout.factory(recoverySettingsCallout);
if (document.querySelector('.search')) initSearchAutocomplete();
@@ -195,9 +201,15 @@ document.addEventListener('DOMContentLoaded', () => {
});
if (bootstrapBreakpoint === 'sm' || bootstrapBreakpoint === 'xs') {
- const $rightSidebar = $('aside.right-sidebar, .layout-page');
+ const $rightSidebar = $('aside.right-sidebar');
+ const $layoutPage = $('.layout-page');
- $rightSidebar.removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed');
+ if ($rightSidebar.length > 0) {
+ $rightSidebar.removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed');
+ $layoutPage.removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed');
+ } else {
+ $layoutPage.removeClass('right-sidebar-expanded right-sidebar-collapsed');
+ }
}
// prevent default action for disabled buttons
diff --git a/app/assets/javascripts/manual_ordering.js b/app/assets/javascripts/manual_ordering.js
index f93dbcd4c47..683fe8b0b14 100644
--- a/app/assets/javascripts/manual_ordering.js
+++ b/app/assets/javascripts/manual_ordering.js
@@ -29,6 +29,7 @@ const initManualOrdering = (draggableSelector = 'li.issue') => {
issueList,
getBoardSortableDefaultOptions({
scroll: true,
+ fallbackTolerance: 1,
dataIdAttr: 'data-id',
fallbackOnBody: false,
group: {
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
index e7fcc183715..25c357b6073 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-param-reassign, camelcase, no-nested-ternary, no-continue */
+/* eslint-disable no-param-reassign, babel/camelcase, no-nested-ternary, no-continue */
import $ from 'jquery';
import Vue from 'vue';
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index 3a7ade5ad94..6c794c1d324 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -24,7 +24,7 @@ function MergeRequest(opts) {
this.initCommitMessageListeners();
this.closeReopenReportToggle = IssuablesHelper.initCloseReopenReport();
- if ($('a.btn-close').length) {
+ if ($('.description.js-task-list-container').length) {
this.taskList = new TaskList({
dataType: 'merge_request',
fieldName: 'description',
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 96c4741fc2e..87de58443e0 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -32,17 +32,17 @@ import { __ } from './locale';
//
// <ul class="nav-links merge-request-tabs">
// <li class="notes-tab active">
-// <a data-action="notes" data-target="#notes" data-toggle="tab" href="/foo/bar/merge_requests/1">
+// <a data-action="notes" data-target="#notes" data-toggle="tab" href="/foo/bar/-/merge_requests/1">
// Discussion
// </a>
// </li>
// <li class="commits-tab">
-// <a data-action="commits" data-target="#commits" data-toggle="tab" href="/foo/bar/merge_requests/1/commits">
+// <a data-action="commits" data-target="#commits" data-toggle="tab" href="/foo/bar/-/merge_requests/1/commits">
// Commits
// </a>
// </li>
// <li class="diffs-tab">
-// <a data-action="diffs" data-target="#diffs" data-toggle="tab" href="/foo/bar/merge_requests/1/diffs">
+// <a data-action="diffs" data-target="#diffs" data-toggle="tab" href="/foo/bar/-/merge_requests/1/diffs">
// Diffs
// </a>
// </li>
@@ -260,17 +260,17 @@ export default class MergeRequestTabs {
//
// Examples:
//
- // location.pathname # => "/namespace/project/merge_requests/1"
+ // location.pathname # => "/namespace/project/-/merge_requests/1"
// setCurrentAction('diffs')
- // location.pathname # => "/namespace/project/merge_requests/1/diffs"
+ // location.pathname # => "/namespace/project/-/merge_requests/1/diffs"
//
- // location.pathname # => "/namespace/project/merge_requests/1/diffs"
+ // location.pathname # => "/namespace/project/-/merge_requests/1/diffs"
// setCurrentAction('show')
- // location.pathname # => "/namespace/project/merge_requests/1"
+ // location.pathname # => "/namespace/project/-/merge_requests/1"
//
- // location.pathname # => "/namespace/project/merge_requests/1/diffs"
+ // location.pathname # => "/namespace/project/-/merge_requests/1/diffs"
// setCurrentAction('commits')
- // location.pathname # => "/namespace/project/merge_requests/1/commits"
+ // location.pathname # => "/namespace/project/-/merge_requests/1/commits"
//
// Returns the new URL String
setCurrentAction(action) {
diff --git a/app/assets/javascripts/mirrors/mirror_repos.js b/app/assets/javascripts/mirrors/mirror_repos.js
index 33e9b1c4e46..e5acaaf9366 100644
--- a/app/assets/javascripts/mirrors/mirror_repos.js
+++ b/app/assets/javascripts/mirrors/mirror_repos.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import _ from 'underscore';
+import { debounce } from 'lodash';
import { __ } from '~/locale';
import Flash from '~/flash';
import axios from '~/lib/utils/axios_utils';
@@ -62,7 +62,7 @@ export default class MirrorRepos {
}
registerUpdateListeners() {
- this.debouncedUpdateUrl = _.debounce(() => this.updateUrl(), 200);
+ this.debouncedUpdateUrl = debounce(() => this.updateUrl(), 200);
this.$urlInput.on('input', () => this.debouncedUpdateUrl());
this.$protectedBranchesInput.on('change', () => this.updateProtectedBranches());
this.$table.on('click', '.js-delete-mirror', event => this.deleteMirror(event));
diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js
index bb5ae6ce2d1..550e1aeeb9c 100644
--- a/app/assets/javascripts/mirrors/ssh_mirror.js
+++ b/app/assets/javascripts/mirrors/ssh_mirror.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import _ from 'underscore';
+import { escape as esc } from 'lodash';
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import Flash from '~/flash';
@@ -162,7 +162,7 @@ export default class SSHMirror {
const $fingerprintsList = this.$hostKeysInformation.find('.js-fingerprints-list');
let fingerprints = '';
sshHostKeys.fingerprints.forEach(fingerprint => {
- const escFingerprints = _.escape(fingerprint.fingerprint);
+ const escFingerprints = esc(fingerprint.fingerprint);
fingerprints += `<code>${escFingerprints}</code>`;
});
diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
index 64704701d1a..447f8845506 100644
--- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue
+++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
@@ -1,5 +1,5 @@
<script>
-import { flatten, isNumber } from 'underscore';
+import { flattenDeep, isNumber } from 'lodash';
import { GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import { roundOffFloat } from '~/lib/utils/common_utils';
import { hexToRgb } from '~/lib/utils/color_utils';
@@ -77,7 +77,7 @@ export default {
* This offset is the lowest value.
*/
yOffset() {
- const values = flatten(this.series.map(ser => ser.data.map(([, y]) => y)));
+ const values = flattenDeep(this.series.map(ser => ser.data.map(([, y]) => y)));
const min = values.length ? Math.floor(Math.min(...values)) : 0;
return min < 0 ? -min : 0;
},
@@ -127,7 +127,6 @@ export default {
});
const yAxisWithOffset = {
- name: this.yAxisLabel,
axisLabel: {
formatter: num => roundOffFloat(num - this.yOffset, 3).toString(),
},
@@ -162,6 +161,7 @@ export default {
}),
);
}
+
return { yAxis: yAxisWithOffset, series: boundarySeries };
},
},
diff --git a/app/assets/javascripts/monitoring/components/charts/column.vue b/app/assets/javascripts/monitoring/components/charts/column.vue
index eb407ad1d7f..0acdfe7675c 100644
--- a/app/assets/javascripts/monitoring/components/charts/column.vue
+++ b/app/assets/javascripts/monitoring/components/charts/column.vue
@@ -1,6 +1,6 @@
<script>
+import { GlResizeObserverDirective } from '@gitlab/ui';
import { GlColumnChart } from '@gitlab/ui/dist/charts';
-import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import { chartHeight } from '../../constants';
import { makeDataSeries } from '~/helpers/monitor_helper';
@@ -10,24 +10,21 @@ export default {
components: {
GlColumnChart,
},
- inheritAttrs: false,
+ directives: {
+ GlResizeObserverDirective,
+ },
props: {
graphData: {
type: Object,
required: true,
validator: graphDataValidatorForValues.bind(null, false),
},
- containerWidth: {
- type: Number,
- required: true,
- },
},
data() {
return {
width: 0,
height: chartHeight,
svgs: {},
- debouncedResizeCallback: {},
};
},
computed: {
@@ -68,15 +65,7 @@ export default {
};
},
},
- watch: {
- containerWidth: 'onResize',
- },
- beforeDestroy() {
- window.removeEventListener('resize', this.debouncedResizeCallback);
- },
created() {
- this.debouncedResizeCallback = debounceByAnimationFrame(this.onResize);
- window.addEventListener('resize', this.debouncedResizeCallback);
this.setSvg('scroll-handle');
},
methods: {
@@ -84,6 +73,7 @@ export default {
return `${query.label}`;
},
onResize() {
+ if (!this.$refs.columnChart) return;
const { width } = this.$refs.columnChart.$el.getBoundingClientRect();
this.width = width;
},
@@ -100,7 +90,7 @@ export default {
};
</script>
<template>
- <div class="prometheus-graph">
+ <div v-gl-resize-observer-directive="onResize" class="prometheus-graph">
<div class="prometheus-graph-header">
<h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5>
<div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div>
diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue
index 6ab5aaeba1d..881904cbd0c 100644
--- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue
+++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue
@@ -1,26 +1,29 @@
<script>
+import { GlResizeObserverDirective } from '@gitlab/ui';
import { GlHeatmap } from '@gitlab/ui/dist/charts';
import dateformat from 'dateformat';
import PrometheusHeader from '../shared/prometheus_header.vue';
-import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
import { graphDataValidatorForValues } from '../../utils';
export default {
components: {
GlHeatmap,
- ResizableChartContainer,
PrometheusHeader,
},
+ directives: {
+ GlResizeObserverDirective,
+ },
props: {
graphData: {
type: Object,
required: true,
validator: graphDataValidatorForValues.bind(null, false),
},
- containerWidth: {
- type: Number,
- required: true,
- },
+ },
+ data() {
+ return {
+ width: 0,
+ };
},
computed: {
chartData() {
@@ -52,22 +55,27 @@ export default {
return this.graphData.metrics[0];
},
},
+ methods: {
+ onResize() {
+ if (this.$refs.heatmapChart) return;
+ const { width } = this.$refs.heatmapChart.$el.getBoundingClientRect();
+ this.width = width;
+ },
+ },
};
</script>
<template>
- <div class="prometheus-graph col-12 col-lg-6">
+ <div v-gl-resize-observer-directive="onResize" class="prometheus-graph col-12 col-lg-6">
<prometheus-header :graph-title="graphData.title" />
- <resizable-chart-container>
- <gl-heatmap
- ref="heatmapChart"
- v-bind="$attrs"
- :data-series="chartData"
- :x-axis-name="xAxisName"
- :y-axis-name="yAxisName"
- :x-axis-labels="xAxisLabels"
- :y-axis-labels="yAxisLabels"
- :width="containerWidth"
- />
- </resizable-chart-container>
+ <gl-heatmap
+ ref="heatmapChart"
+ v-bind="$attrs"
+ :data-series="chartData"
+ :x-axis-name="xAxisName"
+ :y-axis-name="yAxisName"
+ :x-axis-labels="xAxisLabels"
+ :y-axis-labels="yAxisLabels"
+ :width="width"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/single_stat.vue b/app/assets/javascripts/monitoring/components/charts/single_stat.vue
index e75ddb05808..3368be4df75 100644
--- a/app/assets/javascripts/monitoring/components/charts/single_stat.vue
+++ b/app/assets/javascripts/monitoring/components/charts/single_stat.vue
@@ -19,8 +19,21 @@ export default {
queryInfo() {
return this.graphData.metrics[0];
},
- engineeringNotation() {
- return `${roundOffFloat(this.queryInfo.result[0].value[1], 1)}${this.queryInfo.unit}`;
+ queryResult() {
+ return this.queryInfo.result[0]?.value[1];
+ },
+ /**
+ * This method formats the query result from a promQL expression
+ * allowing a user to format the data in percentile values
+ * by using the `max_value` inner property from the graphData prop
+ * @returns {(String)}
+ */
+ statValue() {
+ const chartValue = this.graphData?.max_value
+ ? (this.queryResult / Number(this.graphData.max_value)) * 100
+ : this.queryResult;
+
+ return `${roundOffFloat(chartValue, 1)}${this.queryInfo.unit}`;
},
graphTitle() {
return this.queryInfo.label;
@@ -33,6 +46,6 @@ export default {
<div class="prometheus-graph-header">
<h5 ref="graphTitle" class="prometheus-graph-title">{{ graphTitle }}</h5>
</div>
- <gl-single-stat :value="engineeringNotation" :title="graphTitle" variant="success" />
+ <gl-single-stat :value="statValue" :title="graphTitle" variant="success" />
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
new file mode 100644
index 00000000000..55ae4a3bdb2
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
@@ -0,0 +1,103 @@
+<script>
+import { GlResizeObserverDirective } from '@gitlab/ui';
+import { GlStackedColumnChart } from '@gitlab/ui/dist/charts';
+import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
+import { chartHeight } from '../../constants';
+import { graphDataValidatorForValues } from '../../utils';
+
+export default {
+ components: {
+ GlStackedColumnChart,
+ },
+ directives: {
+ GlResizeObserverDirective,
+ },
+ props: {
+ graphData: {
+ type: Object,
+ required: true,
+ validator: graphDataValidatorForValues.bind(null, false),
+ },
+ },
+ data() {
+ return {
+ width: 0,
+ height: chartHeight,
+ svgs: {},
+ };
+ },
+ computed: {
+ chartData() {
+ return this.graphData.metrics.map(metric => metric.result[0].values.map(val => val[1]));
+ },
+ xAxisTitle() {
+ return this.graphData.x_label !== undefined ? this.graphData.x_label : '';
+ },
+ yAxisTitle() {
+ return this.graphData.y_label !== undefined ? this.graphData.y_label : '';
+ },
+ xAxisType() {
+ return this.graphData.x_type !== undefined ? this.graphData.x_type : 'category';
+ },
+ groupBy() {
+ return this.graphData.metrics[0].result[0].values.map(val => val[0]);
+ },
+ dataZoomConfig() {
+ const handleIcon = this.svgs['scroll-handle'];
+
+ return handleIcon ? { handleIcon } : {};
+ },
+ chartOptions() {
+ return {
+ dataZoom: this.dataZoomConfig,
+ };
+ },
+ seriesNames() {
+ return this.graphData.metrics.map(metric => metric.series_name);
+ },
+ },
+ created() {
+ this.setSvg('scroll-handle');
+ },
+ methods: {
+ setSvg(name) {
+ getSvgIconPathContent(name)
+ .then(path => {
+ if (path) {
+ this.$set(this.svgs, name, `path://${path}`);
+ }
+ })
+ .catch(e => {
+ // eslint-disable-next-line no-console, @gitlab/i18n/no-non-i18n-strings
+ console.error('SVG could not be rendered correctly: ', e);
+ });
+ },
+ onResize() {
+ if (!this.$refs.chart) return;
+ const { width } = this.$refs.chart.$el.getBoundingClientRect();
+ this.width = width;
+ },
+ },
+};
+</script>
+<template>
+ <div v-gl-resize-observer-directive="onResize" class="prometheus-graph">
+ <div class="prometheus-graph-header">
+ <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5>
+ <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div>
+ </div>
+ <gl-stacked-column-chart
+ ref="chart"
+ v-bind="$attrs"
+ :data="chartData"
+ :option="chartOptions"
+ :x-axis-title="xAxisTitle"
+ :y-axis-title="yAxisTitle"
+ :x-axis-type="xAxisType"
+ :group-by="groupBy"
+ :width="width"
+ :height="height"
+ :series-names="seriesNames"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
index 0d442f14aea..d2b1e4da3fd 100644
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -1,5 +1,5 @@
<script>
-import _ from 'underscore';
+import { omit, throttle } from 'lodash';
import { GlLink, GlButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/ui';
import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
@@ -14,10 +14,29 @@ import {
lineWidths,
symbolSizes,
dateFormats,
+ chartColorValues,
} from '../../constants';
import { makeDataSeries } from '~/helpers/monitor_helper';
import { graphDataValidatorForValues } from '../../utils';
+/**
+ * A "virtual" coordinates system for the deployment icons.
+ * Deployment icons are displayed along the [min, max]
+ * range at height `pos`.
+ */
+const deploymentYAxisCoords = {
+ min: 0,
+ pos: 3, // 3% height of chart's grid
+ max: 100,
+};
+
+const THROTTLED_DATAZOOM_WAIT = 1000; // miliseconds
+const timestampToISODate = timestamp => new Date(timestamp).toISOString();
+
+const events = {
+ datazoom: 'datazoom',
+};
+
export default {
components: {
GlAreaChart,
@@ -98,6 +117,7 @@ export default {
height: chartHeight,
svgs: {},
primaryColor: null,
+ throttledDatazoom: null,
};
},
computed: {
@@ -105,7 +125,7 @@ export default {
// Transforms & supplements query data to render appropriate labels & styles
// Input: [{ queryAttributes1 }, { queryAttributes2 }]
// Output: [{ seriesAttributes1 }, { seriesAttributes2 }]
- return this.graphData.metrics.reduce((acc, query) => {
+ return this.graphData.metrics.reduce((acc, query, i) => {
const { appearance } = query;
const lineType =
appearance && appearance.line && appearance.line.type
@@ -126,7 +146,7 @@ export default {
lineStyle: {
type: lineType,
width: lineWidth,
- color: this.primaryColor,
+ color: chartColorValues[i % chartColorValues.length],
},
showSymbol: false,
areaStyle: this.graphData.type === 'area-chart' ? areaStyle : undefined,
@@ -137,28 +157,52 @@ export default {
}, []);
},
chartOptionSeries() {
- return (this.option.series || []).concat(this.scatterSeries ? [this.scatterSeries] : []);
+ return (this.option.series || []).concat(
+ this.deploymentSeries ? [this.deploymentSeries] : [],
+ );
},
chartOptions() {
- const option = _.omit(this.option, 'series');
- return {
- series: this.chartOptionSeries,
- xAxis: {
- name: __('Time'),
- type: 'time',
- axisLabel: {
- formatter: date => dateFormat(date, dateFormats.timeOfDay),
- },
- axisPointer: {
- snap: true,
- },
+ const { yAxis, xAxis } = this.option;
+ const option = omit(this.option, ['series', 'yAxis', 'xAxis']);
+
+ const dataYAxis = {
+ name: this.yAxisLabel,
+ nameGap: 50, // same as gitlab-ui's default
+ nameLocation: 'center', // same as gitlab-ui's default
+ boundaryGap: [0.1, 0.1],
+ scale: true,
+ axisLabel: {
+ formatter: num => roundOffFloat(num, 3).toString(),
},
- yAxis: {
- name: this.yAxisLabel,
- axisLabel: {
- formatter: num => roundOffFloat(num, 3).toString(),
- },
+ ...yAxis,
+ };
+
+ const deploymentsYAxis = {
+ show: false,
+ min: deploymentYAxisCoords.min,
+ max: deploymentYAxisCoords.max,
+ axisLabel: {
+ // formatter fn required to trigger tooltip re-positioning
+ formatter: () => {},
},
+ };
+
+ const timeXAxis = {
+ name: __('Time'),
+ type: 'time',
+ axisLabel: {
+ formatter: date => dateFormat(date, dateFormats.timeOfDay),
+ },
+ axisPointer: {
+ snap: true,
+ },
+ ...xAxis,
+ };
+
+ return {
+ series: this.chartOptionSeries,
+ xAxis: timeXAxis,
+ yAxis: [dataYAxis, deploymentsYAxis],
dataZoom: [this.dataZoomConfig],
...option,
};
@@ -209,7 +253,7 @@ export default {
id,
createdAt: created_at,
sha,
- commitUrl: `${this.projectPath}/commit/${sha}`,
+ commitUrl: `${this.projectPath}/-/commit/${sha}`,
tag,
tagUrl: tag ? `${this.tagsPath}/${ref.name}` : null,
ref: ref.name,
@@ -220,10 +264,16 @@ export default {
return acc;
}, []);
},
- scatterSeries() {
+ deploymentSeries() {
return {
type: graphTypes.deploymentData,
- data: this.recentDeployments.map(deployment => [deployment.createdAt, 0]),
+
+ yAxisIndex: 1, // deploymentsYAxis index
+ data: this.recentDeployments.map(deployment => [
+ deployment.createdAt,
+ deploymentYAxisCoords.pos,
+ ]),
+
symbol: this.svgs.rocket,
symbolSize: symbolSizes.default,
itemStyle: {
@@ -245,6 +295,11 @@ export default {
this.setSvg('rocket');
this.setSvg('scroll-handle');
},
+ destroyed() {
+ if (this.throttledDatazoom) {
+ this.throttledDatazoom.cancel();
+ }
+ },
methods: {
formatLegendLabel(query) {
return `${query.label}`;
@@ -252,6 +307,7 @@ export default {
formatTooltipText(params) {
this.tooltip.title = dateFormat(params.value, dateFormats.default);
this.tooltip.content = [];
+
params.seriesData.forEach(dataPoint => {
if (dataPoint.value) {
const [xVal, yVal] = dataPoint.value;
@@ -287,8 +343,39 @@ export default {
console.error('SVG could not be rendered correctly: ', e);
});
},
- onChartUpdated(chart) {
- [this.primaryColor] = chart.getOption().color;
+ onChartUpdated(eChart) {
+ [this.primaryColor] = eChart.getOption().color;
+ },
+
+ onChartCreated(eChart) {
+ // Emit a datazoom event that corresponds to the eChart
+ // `datazoom` event.
+
+ if (this.throttledDatazoom) {
+ // Chart can be created multiple times in this component's
+ // lifetime, remove previous handlers every time
+ // chart is created.
+ this.throttledDatazoom.cancel();
+ }
+
+ // Emitting is throttled to avoid flurries of calls when
+ // the user changes or scrolls the zoom bar.
+ this.throttledDatazoom = throttle(
+ () => {
+ const { startValue, endValue } = eChart.getOption().dataZoom[0];
+ this.$emit(events.datazoom, {
+ start: timestampToISODate(startValue),
+ end: timestampToISODate(endValue),
+ });
+ },
+ THROTTLED_DATAZOOM_WAIT,
+ {
+ leading: false,
+ },
+ );
+
+ eChart.off('datazoom');
+ eChart.on('datazoom', this.throttledDatazoom);
},
onResize() {
if (!this.$refs.chart) return;
@@ -311,7 +398,10 @@ export default {
<gl-tooltip :target="() => $refs.graphTitle" :disabled="!showTitleTooltip">
{{ graphData.title }}
</gl-tooltip>
- <div class="prometheus-graph-widgets js-graph-widgets flex-fill">
+ <div
+ class="prometheus-graph-widgets js-graph-widgets flex-fill"
+ data-qa-selector="prometheus_graph_widgets"
+ >
<slot></slot>
</div>
</div>
@@ -328,6 +418,7 @@ export default {
:height="height"
:average-text="legendAverageText"
:max-text="legendMaxText"
+ @created="onChartCreated"
@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 b03ee12aef3..79f32b357fc 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,34 +1,37 @@
<script>
-import _ from 'underscore';
+import { debounce, pickBy } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import VueDraggable from 'vuedraggable';
import {
GlButton,
GlDropdown,
GlDropdownItem,
+ GlDropdownHeader,
+ GlDropdownDivider,
GlFormGroup,
GlModal,
+ GlLoadingIcon,
+ GlSearchBoxByType,
GlModalDirective,
GlTooltipDirective,
} from '@gitlab/ui';
import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
import { s__ } from '~/locale';
import createFlash from '~/flash';
-import Icon from '~/vue_shared/components/icon.vue';
-import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
import invalidUrl from '~/lib/utils/invalid_url';
+import Icon from '~/vue_shared/components/icon.vue';
+import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
-import DateTimePicker from './date_time_picker/date_time_picker.vue';
import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue';
import GroupEmptyState from './group_empty_state.vue';
import DashboardsDropdown from './dashboards_dropdown.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
-import { getTimeDiff, getAddMetricTrackingOptions } from '../utils';
-import { metricStates } from '../constants';
-
-const defaultTimeDiff = getTimeDiff();
+import { getAddMetricTrackingOptions, timeRangeToUrl, timeRangeFromUrl } from '../utils';
+import { defaultTimeRange, timeRanges, metricStates } from '../constants';
export default {
components: {
@@ -37,7 +40,11 @@ export default {
Icon,
GlButton,
GlDropdown,
+ GlLoadingIcon,
GlDropdownItem,
+ GlDropdownHeader,
+ GlDropdownDivider,
+ GlSearchBoxByType,
GlFormGroup,
GlModal,
@@ -52,6 +59,7 @@ export default {
GlTooltip: GlTooltipDirective,
TrackEvent: TrackEventDirective,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
externalDashboardUrl: {
type: String,
@@ -63,6 +71,11 @@ export default {
required: false,
default: true,
},
+ showHeader: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
showPanels: {
type: Boolean,
required: false,
@@ -88,6 +101,11 @@ export default {
type: String,
required: true,
},
+ logsPath: {
+ type: String,
+ required: false,
+ default: invalidUrl,
+ },
defaultBranch: {
type: String,
required: true,
@@ -121,10 +139,6 @@ export default {
type: String,
required: true,
},
- environmentsEndpoint: {
- type: String,
- required: true,
- },
currentEnvironmentName: {
type: String,
required: true,
@@ -184,9 +198,9 @@ export default {
return {
state: 'gettingStarted',
formIsValid: null,
- startDate: getParameterValues('start')[0] || defaultTimeDiff.start,
- endDate: getParameterValues('end')[0] || defaultTimeDiff.end,
+ selectedTimeRange: timeRangeFromUrl() || defaultTimeRange,
hasValidDates: true,
+ timeRanges,
isRearrangingPanels: false,
};
},
@@ -198,17 +212,15 @@ export default {
'dashboard',
'emptyState',
'showEmptyState',
- 'environments',
'deploymentData',
'useDashboardEndpoint',
'allDashboards',
'additionalPanelTypesEnabled',
+ 'environmentsLoading',
]),
- ...mapGetters('monitoringDashboard', ['getMetricStates']),
+ ...mapGetters('monitoringDashboard', ['getMetricStates', 'filteredEnvironments']),
firstDashboard() {
- return this.environmentsEndpoint.length > 0 && this.allDashboards.length > 0
- ? this.allDashboards[0]
- : {};
+ return this.allDashboards.length > 0 ? this.allDashboards[0] : {};
},
selectedDashboard() {
return this.allDashboards.find(d => d.path === this.currentDashboard) || this.firstDashboard;
@@ -227,34 +239,37 @@ export default {
this.externalDashboardUrl.length
);
},
+ shouldShowEnvironmentsDropdownNoMatchedMsg() {
+ return !this.environmentsLoading && this.filteredEnvironments.length === 0;
+ },
},
created() {
this.setEndpoints({
metricsEndpoint: this.metricsEndpoint,
- environmentsEndpoint: this.environmentsEndpoint,
deploymentsEndpoint: this.deploymentsEndpoint,
dashboardEndpoint: this.dashboardEndpoint,
dashboardsEndpoint: this.dashboardsEndpoint,
currentDashboard: this.currentDashboard,
projectPath: this.projectPath,
+ logsPath: this.logsPath,
});
},
mounted() {
if (!this.hasMetrics) {
this.setGettingStartedEmptyState();
} else {
- this.fetchData({
- start: this.startDate,
- end: this.endDate,
- });
+ this.setTimeRange(this.selectedTimeRange);
+ this.fetchData();
}
},
methods: {
...mapActions('monitoringDashboard', [
+ 'setTimeRange',
'fetchData',
'setGettingStartedEmptyState',
'setEndpoints',
'setPanelGroupMetrics',
+ 'filterEnvironments',
]),
updatePanels(key, panels) {
this.setPanelGroupMetrics({
@@ -269,8 +284,8 @@ export default {
});
},
- onDateTimePickerApply(params) {
- redirectTo(mergeUrlParams(params, window.location.href));
+ onDateTimePickerInput(timeRange) {
+ redirectTo(timeRangeToUrl(timeRange));
},
onDateTimePickerInvalid() {
createFlash(
@@ -278,13 +293,13 @@ export default {
'Metrics|Link contains an invalid time window, please verify the link to see the requested time range.',
),
);
- this.startDate = defaultTimeDiff.start;
- this.endDate = defaultTimeDiff.end;
+ // As a fallback, switch to default time range instead
+ this.selectedTimeRange = defaultTimeRange;
},
generateLink(group, title, yLabel) {
const dashboard = this.currentDashboard || this.firstDashboard.path;
- const params = _.pick({ dashboard, group, title, y_label: yLabel }, value => value != null);
+ const params = pickBy({ dashboard, group, title, y_label: yLabel }, value => value != null);
return mergeUrlParams(params, window.location.href);
},
hideAddMetricModal() {
@@ -296,6 +311,9 @@ export default {
setFormValidity(isValid) {
this.formIsValid = isValid;
},
+ debouncedEnvironmentsSearch: debounce(function environmentsSearchOnInput(searchTerm) {
+ this.filterEnvironments(searchTerm);
+ }, 500),
submitCustomMetricsForm() {
this.$refs.customMetricsForm.submit();
},
@@ -342,64 +360,94 @@ export default {
</script>
<template>
- <div class="prometheus-graphs">
- <div class="prometheus-graphs-header gl-p-3 pb-0 border-bottom bg-gray-light">
+ <div class="prometheus-graphs" data-qa-selector="prometheus_graphs">
+ <div
+ v-if="showHeader"
+ ref="prometheusGraphsHeader"
+ class="prometheus-graphs-header gl-p-3 pb-0 border-bottom bg-gray-light"
+ >
<div class="row">
- <template v-if="environmentsEndpoint">
- <gl-form-group
- :label="__('Dashboard')"
- label-size="sm"
- label-for="monitor-dashboards-dropdown"
- class="col-sm-12 col-md-6 col-lg-2"
- >
- <dashboards-dropdown
- id="monitor-dashboards-dropdown"
- class="mb-0 d-flex"
- toggle-class="dropdown-menu-toggle"
- :default-branch="defaultBranch"
- :selected-dashboard="selectedDashboard"
- @selectDashboard="selectDashboard($event)"
- />
- </gl-form-group>
+ <gl-form-group
+ :label="__('Dashboard')"
+ label-size="sm"
+ label-for="monitor-dashboards-dropdown"
+ class="col-sm-12 col-md-6 col-lg-2"
+ >
+ <dashboards-dropdown
+ id="monitor-dashboards-dropdown"
+ class="mb-0 d-flex"
+ toggle-class="dropdown-menu-toggle"
+ :default-branch="defaultBranch"
+ :selected-dashboard="selectedDashboard"
+ @selectDashboard="selectDashboard($event)"
+ />
+ </gl-form-group>
- <gl-form-group
- :label="s__('Metrics|Environment')"
- label-size="sm"
- label-for="monitor-environments-dropdown"
- class="col-sm-6 col-md-6 col-lg-2"
+ <gl-form-group
+ :label="s__('Metrics|Environment')"
+ label-size="sm"
+ label-for="monitor-environments-dropdown"
+ class="col-sm-6 col-md-6 col-lg-2"
+ >
+ <gl-dropdown
+ id="monitor-environments-dropdown"
+ ref="monitorEnvironmentsDropdown"
+ data-qa-selector="environments_dropdown"
+ class="mb-0 d-flex"
+ toggle-class="dropdown-menu-toggle"
+ menu-class="monitor-environment-dropdown-menu"
+ :text="currentEnvironmentName"
>
- <gl-dropdown
- id="monitor-environments-dropdown"
- class="mb-0 d-flex js-environments-dropdown"
- toggle-class="dropdown-menu-toggle"
- :text="currentEnvironmentName"
- :disabled="environments.length === 0"
- >
- <gl-dropdown-item
- v-for="environment in environments"
- :key="environment.id"
- :active="environment.name === currentEnvironmentName"
- active-class="is-active"
- :href="environment.metrics_path"
- >{{ environment.name }}</gl-dropdown-item
+ <div class="d-flex flex-column overflow-hidden">
+ <gl-dropdown-header class="monitor-environment-dropdown-header text-center">{{
+ __('Environment')
+ }}</gl-dropdown-header>
+ <gl-dropdown-divider />
+ <gl-search-box-by-type
+ ref="monitorEnvironmentsDropdownSearch"
+ class="m-2"
+ @input="debouncedEnvironmentsSearch"
+ />
+ <gl-loading-icon
+ v-if="environmentsLoading"
+ ref="monitorEnvironmentsDropdownLoading"
+ :inline="true"
+ />
+ <div v-else class="flex-fill overflow-auto">
+ <gl-dropdown-item
+ v-for="environment in filteredEnvironments"
+ :key="environment.id"
+ :active="environment.name === currentEnvironmentName"
+ active-class="is-active"
+ :href="environment.metrics_path"
+ >{{ environment.name }}</gl-dropdown-item
+ >
+ </div>
+ <div
+ v-show="shouldShowEnvironmentsDropdownNoMatchedMsg"
+ ref="monitorEnvironmentsDropdownMsg"
+ class="text-secondary no-matches-message"
>
- </gl-dropdown>
- </gl-form-group>
+ {{ __('No matching results') }}
+ </div>
+ </div>
+ </gl-dropdown>
+ </gl-form-group>
- <gl-form-group
- :label="s__('Metrics|Show last')"
- label-size="sm"
- label-for="monitor-time-window-dropdown"
- class="col-sm-6 col-md-6 col-lg-4"
- >
- <date-time-picker
- :start="startDate"
- :end="endDate"
- @apply="onDateTimePickerApply"
- @invalid="onDateTimePickerInvalid"
- />
- </gl-form-group>
- </template>
+ <gl-form-group
+ :label="s__('Metrics|Show last')"
+ label-size="sm"
+ label-for="monitor-time-window-dropdown"
+ class="col-sm-6 col-md-6 col-lg-4"
+ >
+ <date-time-picker
+ ref="dateTimePicker"
+ :value="selectedTimeRange"
+ :options="timeRanges"
+ @input="onDateTimePickerInput"
+ @invalid="onDateTimePickerInvalid"
+ />
+ </gl-form-group>
<gl-form-group
v-if="hasHeaderButtons"
@@ -413,18 +461,16 @@ export default {
variant="default"
class="mr-2 mt-1 js-rearrange-button"
@click="toggleRearrangingPanels"
+ >{{ __('Arrange charts') }}</gl-button
>
- {{ __('Arrange charts') }}
- </gl-button>
<gl-button
v-if="addingMetricsAvailable"
ref="addMetricBtn"
v-gl-modal="$options.addMetric.modalId"
variant="outline-success"
class="mr-2 mt-1"
+ >{{ $options.addMetric.title }}</gl-button
>
- {{ $options.addMetric.title }}
- </gl-button>
<gl-modal
v-if="addingMetricsAvailable"
ref="addMetricModal"
@@ -446,9 +492,8 @@ export default {
:disabled="!formIsValid"
variant="success"
@click="submitCustomMetricsForm"
+ >{{ __('Save changes') }}</gl-button
>
- {{ __('Save changes') }}
- </gl-button>
</div>
</gl-modal>
@@ -456,9 +501,8 @@ export default {
v-if="selectedDashboard.can_edit"
class="mt-1 js-edit-link"
:href="selectedDashboard.project_blob_path"
+ >{{ __('Edit dashboard') }}</gl-button
>
- {{ __('Edit dashboard') }}
- </gl-button>
<gl-button
v-if="externalDashboardUrl.length"
@@ -484,44 +528,41 @@ export default {
:show-panels="showPanels"
:collapse-group="collapseGroup(groupData.key)"
>
- <div v-if="!groupSingleEmptyState(groupData.key)">
- <vue-draggable
- :value="groupData.panels"
- group="metrics-dashboard"
- :component-data="{ attrs: { class: 'row mx-0 w-100' } }"
- :disabled="!isRearrangingPanels"
- @input="updatePanels(groupData.key, $event)"
+ <vue-draggable
+ v-if="!groupSingleEmptyState(groupData.key)"
+ :value="groupData.panels"
+ group="metrics-dashboard"
+ :component-data="{ attrs: { class: 'row mx-0 w-100' } }"
+ :disabled="!isRearrangingPanels"
+ @input="updatePanels(groupData.key, $event)"
+ >
+ <div
+ v-for="(graphData, graphIndex) in groupData.panels"
+ :key="`panel-type-${graphIndex}`"
+ class="col-12 col-lg-6 px-2 mb-2 draggable"
+ :class="{ 'draggable-enabled': isRearrangingPanels }"
>
- <div
- v-for="(graphData, graphIndex) in groupData.panels"
- :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="removePanel(groupData.key, groupData.panels, 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"
- :alerts-endpoint="alertsEndpoint"
- :prometheus-alerts-available="prometheusAlertsAvailable"
- :index="`${index}-${graphIndex}`"
- />
+ <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="removePanel(groupData.key, groupData.panels, 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"
+ :alerts-endpoint="alertsEndpoint"
+ :prometheus-alerts-available="prometheusAlertsAvailable"
+ :index="`${index}-${graphIndex}`"
+ />
</div>
- </vue-draggable>
- </div>
+ </div>
+ </vue-draggable>
<div v-else class="py-5 col col-sm-10 col-md-8 col-lg-7 col-xl-6">
<group-empty-state
ref="empty-group"
diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
index 6d93eee0b4f..8f3e0a6ec75 100644
--- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
+++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
@@ -4,11 +4,14 @@ import {
GlAlert,
GlDropdown,
GlDropdownItem,
+ GlDropdownHeader,
GlDropdownDivider,
+ GlSearchBoxByType,
GlModal,
GlLoadingIcon,
GlModalDirective,
} from '@gitlab/ui';
+import { s__ } from '~/locale';
import DuplicateDashboardForm from './duplicate_dashboard_form.vue';
const events = {
@@ -20,7 +23,9 @@ export default {
GlAlert,
GlDropdown,
GlDropdownItem,
+ GlDropdownHeader,
GlDropdownDivider,
+ GlSearchBoxByType,
GlModal,
GlLoadingIcon,
DuplicateDashboardForm,
@@ -44,6 +49,7 @@ export default {
alert: null,
loading: false,
form: {},
+ searchTerm: '',
};
},
computed: {
@@ -54,6 +60,17 @@ export default {
selectedDashboardText() {
return this.selectedDashboard.display_name;
},
+ filteredDashboards() {
+ return this.allDashboards.filter(({ display_name }) =>
+ display_name.toLowerCase().includes(this.searchTerm.toLowerCase()),
+ );
+ },
+ shouldShowNoMsgContainer() {
+ return this.filteredDashboards.length === 0;
+ },
+ okButtonText() {
+ return this.loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate');
+ },
},
methods: {
...mapActions('monitoringDashboard', ['duplicateSystemDashboard']),
@@ -95,45 +112,70 @@ export default {
};
</script>
<template>
- <gl-dropdown toggle-class="dropdown-menu-toggle" :text="selectedDashboardText">
- <gl-dropdown-item
- v-for="dashboard in allDashboards"
- :key="dashboard.path"
- :active="dashboard.path === selectedDashboard.path"
- active-class="is-active"
- @click="selectDashboard(dashboard)"
- >
- {{ dashboard.display_name || dashboard.path }}
- </gl-dropdown-item>
-
- <template v-if="isSystemDashboard">
+ <gl-dropdown
+ toggle-class="dropdown-menu-toggle"
+ menu-class="monitor-dashboard-dropdown-menu"
+ :text="selectedDashboardText"
+ >
+ <div class="d-flex flex-column overflow-hidden">
+ <gl-dropdown-header class="monitor-dashboard-dropdown-header text-center">{{
+ __('Dashboard')
+ }}</gl-dropdown-header>
<gl-dropdown-divider />
+ <gl-search-box-by-type
+ ref="monitorDashboardsDropdownSearch"
+ v-model="searchTerm"
+ class="m-2"
+ />
+ <div class="flex-fill overflow-auto">
+ <gl-dropdown-item
+ v-for="dashboard in filteredDashboards"
+ :key="dashboard.path"
+ :active="dashboard.path === selectedDashboard.path"
+ active-class="is-active"
+ @click="selectDashboard(dashboard)"
+ >
+ {{ dashboard.display_name || dashboard.path }}
+ </gl-dropdown-item>
+ </div>
- <gl-modal
- ref="duplicateDashboardModal"
- modal-id="duplicateDashboardModal"
- :title="s__('Metrics|Duplicate dashboard')"
- ok-variant="success"
- @ok="ok"
- @hide="hide"
+ <div
+ v-show="shouldShowNoMsgContainer"
+ ref="monitorDashboardsDropdownMsg"
+ class="text-secondary no-matches-message"
>
- <gl-alert v-if="alert" class="mb-3" variant="danger" @dismiss="alert = null">
- {{ alert }}
- </gl-alert>
- <duplicate-dashboard-form
- :dashboard="selectedDashboard"
- :default-branch="defaultBranch"
- @change="formChange"
- />
- <template #modal-ok>
- <gl-loading-icon v-if="loading" inline color="light" />
- {{ loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate') }}
- </template>
- </gl-modal>
+ {{ __('No matching results') }}
+ </div>
+
+ <template v-if="isSystemDashboard">
+ <gl-dropdown-divider />
+
+ <gl-modal
+ ref="duplicateDashboardModal"
+ modal-id="duplicateDashboardModal"
+ :title="s__('Metrics|Duplicate dashboard')"
+ ok-variant="success"
+ @ok="ok"
+ @hide="hide"
+ >
+ <gl-alert v-if="alert" class="mb-3" variant="danger" @dismiss="alert = null">
+ {{ alert }}
+ </gl-alert>
+ <duplicate-dashboard-form
+ :dashboard="selectedDashboard"
+ :default-branch="defaultBranch"
+ @change="formChange"
+ />
+ <template #modal-ok>
+ <gl-loading-icon v-if="loading" inline color="light" />
+ {{ okButtonText }}
+ </template>
+ </gl-modal>
- <gl-dropdown-item ref="duplicateDashboardItem" v-gl-modal="'duplicateDashboardModal'">
- {{ s__('Metrics|Duplicate dashboard') }}
- </gl-dropdown-item>
- </template>
+ <gl-dropdown-item ref="duplicateDashboardItem" v-gl-modal="'duplicateDashboardModal'">
+ {{ s__('Metrics|Duplicate dashboard') }}
+ </gl-dropdown-item>
+ </template>
+ </div>
</gl-dropdown>
</template>
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
deleted file mode 100644
index 0aa710b1b3a..00000000000
--- a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue
+++ /dev/null
@@ -1,180 +0,0 @@
-<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,
- isValidDate,
- getTimeWindow,
- stringToISODate,
- ISODateToString,
- truncateZerosInDateTime,
- isDateTimePickerInputValid,
-} from '~/monitoring/utils';
-
-import { timeWindows } from '~/monitoring/constants';
-
-const events = {
- apply: 'apply',
- invalid: 'invalid',
-};
-
-export default {
- components: {
- Icon,
- DateTimePickerInput,
- GlFormGroup,
- GlButton,
- GlDropdown,
- GlDropdownItem,
- },
- props: {
- start: {
- type: String,
- required: true,
- },
- end: {
- type: String,
- required: true,
- },
- timeWindows: {
- type: Object,
- required: false,
- default: () => timeWindows,
- },
- },
- data() {
- return {
- startDate: this.start,
- endDate: this.end,
- };
- },
- computed: {
- startInputValid() {
- return isValidDate(this.startDate);
- },
- endInputValid() {
- return isValidDate(this.endDate);
- },
- isValid() {
- return this.startInputValid && this.endInputValid;
- },
-
- startInput: {
- get() {
- return this.startInputValid ? this.formatDate(this.startDate) : this.startDate;
- },
- set(val) {
- // Attempt to set a formatted date if possible
- this.startDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
- },
- },
- endInput: {
- get() {
- return this.endInputValid ? this.formatDate(this.endDate) : this.endDate;
- },
- set(val) {
- // Attempt to set a formatted date if possible
- this.endDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
- },
- },
-
- timeWindowText() {
- const timeWindow = getTimeWindow({ start: this.start, end: this.end });
- if (timeWindow) {
- return this.timeWindows[timeWindow];
- } else if (isValidDate(this.start) && isValidDate(this.end)) {
- return sprintf(s__('%{start} to %{end}'), {
- start: this.formatDate(this.start),
- end: this.formatDate(this.end),
- });
- }
- return '';
- },
- },
- mounted() {
- // Validate on mounted, and trigger an update if needed
- if (!this.isValid) {
- this.$emit(events.invalid);
- }
- },
- methods: {
- formatDate(date) {
- return truncateZerosInDateTime(ISODateToString(date));
- },
- setTimeWindow(key) {
- const { start, end } = getTimeDiff(key);
- this.startDate = start;
- this.endDate = end;
-
- this.apply();
- },
- closeDropdown() {
- this.$refs.dropdown.hide();
- },
- apply() {
- this.$emit(events.apply, {
- start: this.startDate,
- end: this.endDate,
- });
- },
- },
-};
-</script>
-<template>
- <gl-dropdown
- ref="dropdown"
- :text="timeWindowText"
- 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="startInput"
- :label="__('From')"
- :state="startInputValid"
- />
- <date-time-picker-input
- id="custom-time-to"
- v-model="endInput"
- :label="__('To')"
- :state="endInputValid"
- />
- <gl-form-group>
- <gl-button @click="closeDropdown">{{ __('Cancel') }}</gl-button>
- <gl-button variant="success" :disabled="!isValid" @click="apply()">
- {{ __('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="value === timeWindowText"
- active-class="active"
- @click="setTimeWindow(key)"
- >
- <icon
- name="mobile-issue-close"
- class="align-bottom"
- :class="{ invisible: value !== timeWindowText }"
- />
- {{ value }}
- </gl-dropdown-item>
- </gl-form-group>
- </div>
- </gl-dropdown>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue
index 2f562071764..49188a7af8f 100644
--- a/app/assets/javascripts/monitoring/components/embed.vue
+++ b/app/assets/javascripts/monitoring/components/embed.vue
@@ -1,9 +1,9 @@
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
-import { getParameterValues, removeParams } from '~/lib/utils/url_utility';
-import { sidebarAnimationDuration } from '../constants';
-import { getTimeDiff } from '../utils';
+import { convertToFixedRange } from '~/lib/utils/datetime_range';
+import { timeRangeFromUrl, removeTimeRangeParams } from '../utils';
+import { sidebarAnimationDuration, defaultTimeRange } from '../constants';
let sidebarMutationObserver;
@@ -18,17 +18,9 @@ export default {
},
},
data() {
- const defaultRange = getTimeDiff();
- const start = getParameterValues('start', this.dashboardUrl)[0] || defaultRange.start;
- const end = getParameterValues('end', this.dashboardUrl)[0] || defaultRange.end;
-
- const params = {
- start,
- end,
- };
-
+ const timeRange = timeRangeFromUrl(this.dashboardUrl) || defaultTimeRange;
return {
- params,
+ timeRange: convertToFixedRange(timeRange),
elWidth: 0,
};
},
@@ -51,7 +43,9 @@ export default {
},
mounted() {
this.setInitialState();
- this.fetchMetricsData(this.params);
+ this.setTimeRange(this.timeRange);
+ this.fetchDashboard();
+
sidebarMutationObserver = new MutationObserver(this.onSidebarMutation);
sidebarMutationObserver.observe(document.querySelector('.layout-page'), {
attributes: true,
@@ -66,7 +60,8 @@ export default {
},
methods: {
...mapActions('monitoringDashboard', [
- 'fetchMetricsData',
+ 'setTimeRange',
+ 'fetchDashboard',
'setEndpoints',
'setFeatureFlags',
'setShowErrorBanner',
@@ -81,7 +76,7 @@ export default {
},
setInitialState() {
this.setEndpoints({
- dashboardEndpoint: removeParams(['start', 'end'], this.dashboardUrl),
+ dashboardEndpoint: removeTimeRangeParams(this.dashboardUrl),
});
this.setShowErrorBanner(false);
},
diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue
index ec6a41d0540..22fab1b03f2 100644
--- a/app/assets/javascripts/monitoring/components/panel_type.vue
+++ b/app/assets/javascripts/monitoring/components/panel_type.vue
@@ -1,6 +1,7 @@
<script>
import { mapState } from 'vuex';
-import _ from 'underscore';
+import { pickBy } from 'lodash';
+import invalidUrl from '~/lib/utils/invalid_url';
import {
GlDropdown,
GlDropdownItem,
@@ -14,14 +15,18 @@ import MonitorTimeSeriesChart from './charts/time_series.vue';
import MonitorAnomalyChart from './charts/anomaly.vue';
import MonitorSingleStatChart from './charts/single_stat.vue';
import MonitorHeatmapChart from './charts/heatmap.vue';
+import MonitorColumnChart from './charts/column.vue';
+import MonitorStackedColumnChart from './charts/stacked_column.vue';
import MonitorEmptyChart from './charts/empty_chart.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
-import { downloadCSVOptions, generateLinkToChartOptions } from '../utils';
+import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils';
export default {
components: {
MonitorSingleStatChart,
+ MonitorColumnChart,
MonitorHeatmapChart,
+ MonitorStackedColumnChart,
MonitorEmptyChart,
Icon,
GlDropdown,
@@ -54,8 +59,13 @@ export default {
default: 'panel-type-chart',
},
},
+ data() {
+ return {
+ zoomedTimeRange: null,
+ };
+ },
computed: {
- ...mapState('monitoringDashboard', ['deploymentData', 'projectPath']),
+ ...mapState('monitoringDashboard', ['deploymentData', 'projectPath', 'logsPath', 'timeRange']),
alertWidgetAvailable() {
return IS_EE && this.prometheusAlertsAvailable && this.alertsEndpoint && this.graphData;
},
@@ -66,6 +76,14 @@ export default {
this.graphData.metrics[0].result.length > 0
);
},
+ logsPathWithTimeRange() {
+ const timeRange = this.zoomedTimeRange || this.timeRange;
+
+ if (this.logsPath && this.logsPath !== invalidUrl && timeRange) {
+ return timeRangeToUrl(timeRange, this.logsPath);
+ }
+ return null;
+ },
csvText() {
const chartData = this.graphData.metrics[0].result[0].values;
const yLabel = this.graphData.y_label;
@@ -90,7 +108,7 @@ export default {
getGraphAlerts(queries) {
if (!this.allAlerts) return {};
const metricIdsForChart = queries.map(q => q.metricId);
- return _.pick(this.allAlerts, alert => metricIdsForChart.includes(alert.metricId));
+ return pickBy(this.allAlerts, alert => metricIdsForChart.includes(alert.metricId));
},
getGraphAlertValues(queries) {
return Object.values(this.getGraphAlerts(queries));
@@ -103,6 +121,10 @@ export default {
},
downloadCSVOptions,
generateLinkToChartOptions,
+
+ onDatazoom({ start, end }) {
+ this.zoomedTimeRange = { start, end };
+ },
},
};
</script>
@@ -114,16 +136,25 @@ export default {
<monitor-heatmap-chart
v-else-if="isPanelType('heatmap') && graphDataHasMetrics"
:graph-data="graphData"
- :container-width="dashboardWidth"
+ />
+ <monitor-column-chart
+ v-else-if="isPanelType('column') && graphDataHasMetrics"
+ :graph-data="graphData"
+ />
+ <monitor-stacked-column-chart
+ v-else-if="isPanelType('stacked-column') && graphDataHasMetrics"
+ :graph-data="graphData"
/>
<component
:is="monitorChartComponent"
v-else-if="graphDataHasMetrics"
+ ref="timeChart"
:graph-data="graphData"
:deployment-data="deploymentData"
:project-path="projectPath"
:thresholds="getGraphAlertValues(graphData.metrics)"
:group-id="groupId"
+ @datazoom="onDatazoom"
>
<div class="d-flex align-items-center">
<alert-widget
@@ -138,6 +169,7 @@ export default {
v-gl-tooltip
class="ml-auto mx-3"
toggle-class="btn btn-transparent border-0"
+ data-qa-selector="prometheus_widgets_dropdown"
:right="true"
:no-caret="true"
:title="__('More actions')"
@@ -145,6 +177,15 @@ export default {
<template slot="button-content">
<icon name="ellipsis_v" class="text-secondary" />
</template>
+
+ <gl-dropdown-item
+ v-if="logsPathWithTimeRange"
+ ref="viewLogsLink"
+ :href="logsPathWithTimeRange"
+ >
+ {{ s__('Metrics|View logs') }}
+ </gl-dropdown-item>
+
<gl-dropdown-item
v-track-event="downloadCSVOptions(graphData.title)"
:href="downloadCsv"
@@ -154,14 +195,18 @@ export default {
</gl-dropdown-item>
<gl-dropdown-item
v-if="clipboardText"
+ ref="copyChartLink"
v-track-event="generateLinkToChartOptions(clipboardText)"
- class="js-chart-link"
:data-clipboard-text="clipboardText"
@click="showToast(clipboardText)"
>
{{ __('Generate link to chart') }}
</gl-dropdown-item>
- <gl-dropdown-item v-if="alertWidgetAvailable" v-gl-modal="`alert-modal-${index}`">
+ <gl-dropdown-item
+ v-if="alertWidgetAvailable"
+ v-gl-modal="`alert-modal-${index}`"
+ data-qa-selector="alert_widget_menu_item"
+ >
{{ __('Alerts') }}
</gl-dropdown-item>
</gl-dropdown>
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index 398b45b9012..ddf6c9878df 100644
--- a/app/assets/javascripts/monitoring/constants.js
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -50,11 +50,6 @@ export const metricStates = {
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',
@@ -75,6 +70,13 @@ export const colorValues = {
anomalyAreaColor: '#1f78d1',
};
+export const chartColorValues = [
+ '#1f78d1', // $blue-500 (see variables.scss)
+ '#1aaa55', // $green-500
+ '#fc9403', // $orange-500
+ '#6d49cb', // $purple
+];
+
export const lineTypes = {
default: 'solid',
};
@@ -83,38 +85,41 @@ export const lineWidths = {
default: 2,
};
-export const timeWindows = {
- thirtyMinutes: __('30 minutes'),
- threeHours: __('3 hours'),
- eightHours: __('8 hours'),
- oneDay: __('1 day'),
- threeDays: __('3 days'),
- oneWeek: __('1 week'),
-};
-
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 = {
- thirtyMinutes: 60 * 30,
- threeHours: 60 * 60 * 3,
- eightHours: 60 * 60 * 8,
- oneDay: 60 * 60 * 24 * 1,
- threeDays: 60 * 60 * 24 * 3,
- oneWeek: 60 * 60 * 24 * 7 * 1,
-};
+export const timeRanges = [
+ {
+ label: __('30 minutes'),
+ duration: { seconds: 60 * 30 },
+ },
+ {
+ label: __('3 hours'),
+ duration: { seconds: 60 * 60 * 3 },
+ },
+ {
+ label: __('8 hours'),
+ duration: { seconds: 60 * 60 * 8 },
+ default: true,
+ },
+ {
+ label: __('1 day'),
+ duration: { seconds: 60 * 60 * 24 * 1 },
+ },
+ {
+ label: __('3 days'),
+ duration: { seconds: 60 * 60 * 24 * 3 },
+ },
+ {
+ label: __('1 week'),
+ duration: { seconds: 60 * 60 * 24 * 7 * 1 },
+ },
+ {
+ label: __('1 month'),
+ duration: { seconds: 60 * 60 * 24 * 30 },
+ },
+];
-export const timeWindowsKeyNames = Object.keys(secondsIn).reduce(
- (otherTimeWindows, timeWindow) => ({
- ...otherTimeWindows,
- [timeWindow]: timeWindow,
- }),
- {},
-);
+export const defaultTimeRange = timeRanges.find(tr => tr.default);
diff --git a/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql b/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql
new file mode 100644
index 00000000000..fd3a4348509
--- /dev/null
+++ b/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql
@@ -0,0 +1,10 @@
+query getEnvironments($projectPath: ID!, $search: String) {
+ project(fullPath: $projectPath) {
+ data: environments(search: $search) {
+ environments: nodes {
+ name
+ id
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 61cd8621902..8bb5047ef04 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -1,9 +1,12 @@
import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
+import { convertToFixedRange } from '~/lib/utils/datetime_range';
+import { gqClient, parseEnvironmentsResponse, removeLeadingSlash } from './utils';
import trackDashboardLoad from '../monitoring_tracking_helper';
+import getEnvironments from '../queries/getEnvironments.query.graphql';
import statusCodes from '../../lib/utils/http_status';
-import { backOff } from '../../lib/utils/common_utils';
+import { backOff, convertObjectPropsToCamelCase } from '../../lib/utils/common_utils';
import { s__, sprintf } from '../../locale';
import { PROMETHEUS_TIMEOUT } from '../constants';
@@ -30,6 +33,15 @@ export const setEndpoints = ({ commit }, endpoints) => {
commit(types.SET_ENDPOINTS, endpoints);
};
+export const setTimeRange = ({ commit }, timeRange) => {
+ commit(types.SET_TIME_RANGE, timeRange);
+};
+
+export const filterEnvironments = ({ commit, dispatch }, searchTerm) => {
+ commit(types.SET_ENVIRONMENTS_FILTER, searchTerm);
+ dispatch('fetchEnvironmentsData');
+};
+
export const setShowErrorBanner = ({ commit }, enabled) => {
commit(types.SET_SHOW_ERROR_BANNER, enabled);
};
@@ -40,6 +52,8 @@ export const requestMetricsDashboard = ({ commit }) => {
export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response, params }) => {
commit(types.SET_ALL_DASHBOARDS, response.all_dashboards);
commit(types.RECEIVE_METRICS_DATA_SUCCESS, response.dashboard);
+ commit(types.SET_ENDPOINTS, convertObjectPropsToCamelCase(response.metrics_data));
+
return dispatch('fetchPrometheusMetrics', params);
};
export const receiveMetricsDashboardFailure = ({ commit }, error) => {
@@ -50,24 +64,30 @@ export const receiveDeploymentsDataSuccess = ({ commit }, data) =>
commit(types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS, data);
export const receiveDeploymentsDataFailure = ({ commit }) =>
commit(types.RECEIVE_DEPLOYMENTS_DATA_FAILURE);
+export const requestEnvironmentsData = ({ commit }) => commit(types.REQUEST_ENVIRONMENTS_DATA);
export const receiveEnvironmentsDataSuccess = ({ commit }, data) =>
commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data);
export const receiveEnvironmentsDataFailure = ({ commit }) =>
commit(types.RECEIVE_ENVIRONMENTS_DATA_FAILURE);
-export const fetchData = ({ dispatch }, params) => {
- dispatch('fetchMetricsData', params);
+export const fetchData = ({ dispatch }) => {
+ dispatch('fetchDashboard');
dispatch('fetchDeploymentsData');
dispatch('fetchEnvironmentsData');
};
-export const fetchMetricsData = ({ dispatch }, params) => dispatch('fetchDashboard', params);
-
-export const fetchDashboard = ({ state, dispatch }, params) => {
+export const fetchDashboard = ({ state, dispatch }) => {
dispatch('requestMetricsDashboard');
+ const params = {};
+
+ if (state.timeRange) {
+ const { start, end } = convertToFixedRange(state.timeRange);
+ params.start = start;
+ params.end = end;
+ }
+
if (state.currentDashboard) {
- // eslint-disable-next-line no-param-reassign
params.dashboard = state.currentDashboard;
}
@@ -184,19 +204,26 @@ export const fetchDeploymentsData = ({ state, dispatch }) => {
};
export const fetchEnvironmentsData = ({ state, dispatch }) => {
- if (!state.environmentsEndpoint) {
- return Promise.resolve([]);
- }
- return axios
- .get(state.environmentsEndpoint)
- .then(resp => resp.data)
- .then(response => {
- if (!response || !response.environments) {
+ dispatch('requestEnvironmentsData');
+ return gqClient
+ .mutate({
+ mutation: getEnvironments,
+ variables: {
+ projectPath: removeLeadingSlash(state.projectPath),
+ search: state.environmentsSearchTerm,
+ },
+ })
+ .then(resp =>
+ parseEnvironmentsResponse(resp.data?.project?.data?.environments, state.projectPath),
+ )
+ .then(environments => {
+ if (!environments) {
createFlash(
s__('Metrics|There was an error fetching the environments data, please try again'),
);
}
- dispatch('receiveEnvironmentsDataSuccess', response.environments);
+
+ dispatch('receiveEnvironmentsDataSuccess', environments);
})
.catch(() => {
dispatch('receiveEnvironmentsDataFailure');
diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js
index a13157c6f87..3801149e49d 100644
--- a/app/assets/javascripts/monitoring/stores/getters.js
+++ b/app/assets/javascripts/monitoring/stores/getters.js
@@ -58,5 +58,18 @@ export const metricsWithData = state => groupKey => {
return res;
};
+/**
+ * Filter environments by names.
+ *
+ * This is used in the environments dropdown with searchable input.
+ *
+ * @param {Object} state
+ * @returns {Array} List of environments
+ */
+export const filteredEnvironments = state =>
+ state.environments.filter(env =>
+ env.name.toLowerCase().includes((state.environmentsSearchTerm || '').trim().toLowerCase()),
+ );
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js
index 74068e1d846..8873142accc 100644
--- a/app/assets/javascripts/monitoring/stores/mutation_types.js
+++ b/app/assets/javascripts/monitoring/stores/mutation_types.js
@@ -14,10 +14,12 @@ export const REQUEST_METRIC_RESULT = 'REQUEST_METRIC_RESULT';
export const RECEIVE_METRIC_RESULT_SUCCESS = 'RECEIVE_METRIC_RESULT_SUCCESS';
export const RECEIVE_METRIC_RESULT_FAILURE = 'RECEIVE_METRIC_RESULT_FAILURE';
-export const SET_TIME_WINDOW = 'SET_TIME_WINDOW';
+export const SET_TIME_RANGE = 'SET_TIME_RANGE';
export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS';
export const SET_ENDPOINTS = 'SET_ENDPOINTS';
export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE';
export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE';
export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER';
export const SET_PANEL_GROUP_METRICS = 'SET_PANEL_GROUP_METRICS';
+
+export const SET_ENVIRONMENTS_FILTER = 'SET_ENVIRONMENTS_FILTER';
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
index 506a30ae619..8bd53a24b61 100644
--- a/app/assets/javascripts/monitoring/stores/mutations.js
+++ b/app/assets/javascripts/monitoring/stores/mutations.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import pick from 'lodash/pick';
import { slugify } from '~/lib/utils/text_utility';
import * as types from './mutation_types';
import { normalizeMetric, normalizeQueryResult } from './utils';
@@ -123,10 +124,15 @@ export default {
[types.RECEIVE_DEPLOYMENTS_DATA_FAILURE](state) {
state.deploymentData = [];
},
+ [types.REQUEST_ENVIRONMENTS_DATA](state) {
+ state.environmentsLoading = true;
+ },
[types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS](state, environments) {
+ state.environmentsLoading = false;
state.environments = environments;
},
[types.RECEIVE_ENVIRONMENTS_DATA_FAILURE](state) {
+ state.environmentsLoading = false;
state.environments = [];
},
@@ -169,15 +175,22 @@ export default {
state: emptyStateFromError(error),
});
},
-
- [types.SET_ENDPOINTS](state, endpoints) {
- state.metricsEndpoint = endpoints.metricsEndpoint;
- state.environmentsEndpoint = endpoints.environmentsEndpoint;
- state.deploymentsEndpoint = endpoints.deploymentsEndpoint;
- state.dashboardEndpoint = endpoints.dashboardEndpoint;
- state.dashboardsEndpoint = endpoints.dashboardsEndpoint;
- state.currentDashboard = endpoints.currentDashboard;
- state.projectPath = endpoints.projectPath;
+ [types.SET_ENDPOINTS](state, endpoints = {}) {
+ const endpointKeys = [
+ 'metricsEndpoint',
+ 'deploymentsEndpoint',
+ 'dashboardEndpoint',
+ 'dashboardsEndpoint',
+ 'currentDashboard',
+ 'projectPath',
+ 'logsPath',
+ ];
+ Object.entries(pick(endpoints, endpointKeys)).forEach(([key, value]) => {
+ state[key] = value;
+ });
+ },
+ [types.SET_TIME_RANGE](state, timeRange) {
+ state.timeRange = timeRange;
},
[types.SET_GETTING_STARTED_EMPTY_STATE](state) {
state.emptyState = 'gettingStarted';
@@ -196,4 +209,7 @@ export default {
const panelGroup = state.dashboard.panel_groups.find(pg => payload.key === pg.key);
panelGroup.panels = payload.panels;
},
+ [types.SET_ENVIRONMENTS_FILTER](state, searchTerm) {
+ state.environmentsSearchTerm = searchTerm;
+ },
};
diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js
index ee8a85ea222..a2050f8e893 100644
--- a/app/assets/javascripts/monitoring/stores/state.js
+++ b/app/assets/javascripts/monitoring/stores/state.js
@@ -1,21 +1,31 @@
import invalidUrl from '~/lib/utils/invalid_url';
export default () => ({
+ // API endpoints
metricsEndpoint: null,
- environmentsEndpoint: null,
deploymentsEndpoint: null,
dashboardEndpoint: invalidUrl,
+
+ // Dashboard request parameters
+ timeRange: null,
+ currentDashboard: null,
+
+ // Dashboard data
emptyState: 'gettingStarted',
showEmptyState: true,
showErrorBanner: true,
-
dashboard: {
panel_groups: [],
},
+ allDashboards: [],
+ // Other project data
deploymentData: [],
environments: [],
- allDashboards: [],
- currentDashboard: null,
+ environmentsSearchTerm: '',
+ environmentsLoading: false,
+
+ // GitLab paths to other pages
projectPath: null,
+ logsPath: invalidUrl,
});
diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js
index 3300d2032d0..cd586c6af3e 100644
--- a/app/assets/javascripts/monitoring/stores/utils.js
+++ b/app/assets/javascripts/monitoring/stores/utils.js
@@ -1,8 +1,46 @@
-import _ from 'underscore';
+import { omit } from 'lodash';
+import createGqClient, { fetchPolicies } from '~/lib/graphql';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+
+export const gqClient = createGqClient(
+ {},
+ {
+ fetchPolicy: fetchPolicies.NO_CACHE,
+ },
+);
export const uniqMetricsId = metric => `${metric.metric_id}_${metric.id}`;
/**
+ * Project path has a leading slash that doesn't work well
+ * with project full path resolver here
+ * https://gitlab.com/gitlab-org/gitlab/blob/5cad4bd721ab91305af4505b2abc92b36a56ad6b/app/graphql/resolvers/full_path_resolver.rb#L10
+ *
+ * @param {String} str String with leading slash
+ * @returns {String}
+ */
+export const removeLeadingSlash = str => (str || '').replace(/^\/+/, '');
+
+/**
+ * GraphQL environments API returns only id and name.
+ * For the environments dropdown we need metrics_path.
+ * This method parses the results and add neccessart attrs
+ *
+ * @param {Array} response Environments API result
+ * @param {String} projectPath Current project path
+ * @returns {Array}
+ */
+export const parseEnvironmentsResponse = (response = [], projectPath) =>
+ (response || []).map(env => {
+ const id = getIdFromGraphQLId(env.id);
+ return {
+ ...env,
+ id,
+ metrics_path: `${projectPath}/environments/${id}/metrics`,
+ };
+ });
+
+/**
* 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
@@ -11,7 +49,7 @@ export const uniqMetricsId = metric => `${metric.metric_id}_${metric.id}`;
*/
export const normalizeMetric = (metric = {}) =>
- _.omit(
+ omit(
{
...metric,
metric_id: uniqMetricsId(metric),
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index c824d6d4ddb..b2fa44835e6 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -1,67 +1,9 @@
-import dateformat from 'dateformat';
-import { secondsIn, dateTimePickerRegex, dateFormats } from './constants';
-import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
-
-export const getTimeDiff = timeWindow => {
- const end = Math.floor(Date.now() / 1000); // convert milliseconds to seconds
- const difference = secondsIn[timeWindow] || secondsIn.eightHours;
- const start = end - difference;
-
- return {
- 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 (new Date(end) - new Date(start) === secondsToMilliseconds(value)) {
- return timeRange;
- }
- return acc;
- }, 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 (e) {
- 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);
+import { queryToObject, mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
+import {
+ timeRangeParamNames,
+ timeRangeFromParams,
+ timeRangeToParams,
+} from '~/lib/utils/datetime_range';
/**
* This method is used to validate if the graph data format for a chart component
@@ -158,4 +100,36 @@ export const graphDataValidatorForAnomalyValues = graphData => {
);
};
+/**
+ * Returns a time range from the current URL params
+ *
+ * @returns {Object|null} The time range defined by the
+ * current URL, reading from search query or `window.location.search`.
+ * Returns `null` if no parameters form a time range.
+ */
+export const timeRangeFromUrl = (search = window.location.search) => {
+ const params = queryToObject(search);
+ return timeRangeFromParams(params);
+};
+
+/**
+ * Returns a URL with no time range based on the current URL.
+ *
+ * @param {String} New URL
+ */
+export const removeTimeRangeParams = (url = window.location.href) =>
+ removeParams(timeRangeParamNames, url);
+
+/**
+ * Returns a URL for the a different time range based on the
+ * current URL and a time range.
+ *
+ * @param {String} New URL
+ */
+export const timeRangeToUrl = (timeRange, url = window.location.href) => {
+ const toUrl = removeTimeRangeParams(url);
+ const params = timeRangeToParams(timeRange);
+ return mergeUrlParams(params, toUrl);
+};
+
export default {};
diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js
index 622db360d1f..2580f8e86b1 100644
--- a/app/assets/javascripts/mr_notes/init_notes.js
+++ b/app/assets/javascripts/mr_notes/init_notes.js
@@ -4,6 +4,7 @@ import { mapActions, mapState, mapGetters } from 'vuex';
import store from 'ee_else_ce/mr_notes/stores';
import notesApp from '../notes/components/notes_app.vue';
import discussionKeyboardNavigator from '../notes/components/discussion_keyboard_navigator.vue';
+import initWidget from '../vue_merge_request_widget';
export default () => {
// eslint-disable-next-line no-new
@@ -32,11 +33,22 @@ export default () => {
...mapState({
activeTab: state => state.page.activeTab,
}),
+ isShowTabActive() {
+ return this.activeTab === 'show';
+ },
},
watch: {
discussionTabCounter() {
this.updateDiscussionTabCounter();
},
+ isShowTabActive: {
+ handler(newVal) {
+ if (newVal) {
+ initWidget();
+ }
+ },
+ immediate: true,
+ },
},
created() {
this.setActiveTab(window.mrTabs.getCurrentAction());
@@ -57,19 +69,17 @@ export default () => {
},
},
render(createElement) {
- const isDiffView = this.activeTab === 'diffs';
-
// NOTE: Even though `discussionKeyboardNavigator` is added to the `notes-app`,
// it adds a global key listener so it works on the diffs tab as well.
// If we create a single Vue app for all of the MR tabs, we should move this
// up the tree, to the root.
- return createElement(discussionKeyboardNavigator, { props: { isDiffView } }, [
+ return createElement(discussionKeyboardNavigator, [
createElement('notes-app', {
props: {
noteableData: this.noteableData,
notesData: this.notesData,
userData: this.currentUserData,
- shouldShow: this.activeTab === 'show',
+ shouldShow: this.isShowTabActive,
helpPagePath: this.helpPagePath,
},
}),
diff --git a/app/assets/javascripts/mr_popover/constants.js b/app/assets/javascripts/mr_popover/constants.js
index c13c417cc18..352bc635293 100644
--- a/app/assets/javascripts/mr_popover/constants.js
+++ b/app/assets/javascripts/mr_popover/constants.js
@@ -3,6 +3,7 @@ import { __ } from '~/locale';
export const mrStates = {
merged: 'merged',
closed: 'closed',
+ open: 'open',
};
export const humanMRStates = {
diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js
index c301c304409..3cc95168ba1 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, consistent-return, camelcase */
+/* eslint-disable func-names, consistent-return */
import $ from 'jquery';
import { __ } from '../locale';
@@ -270,14 +270,14 @@ export default class BranchGraph {
stroke: 'none',
});
- const avatar_box_x = this.offsetX + this.unitSpace * this.mspace + 10;
- const avatar_box_y = y - 10;
+ const avatarBoxX = this.offsetX + this.unitSpace * this.mspace + 10;
+ const avatarBoxY = y - 10;
- r.rect(avatar_box_x, avatar_box_y, 20, 20).attr({
+ r.rect(avatarBoxX, avatarBoxY, 20, 20).attr({
stroke: this.colors[commit.space],
'stroke-width': 2,
});
- r.image(commit.author.icon, avatar_box_x, avatar_box_y, 20, 20);
+ r.image(commit.author.icon, avatarBoxX, avatarBoxY, 20, 20);
return r
.text(this.offsetX + this.unitSpace * this.mspace + 35, y, commit.message.split('\n')[0])
.attr({
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 4195ea6425f..b3b189c1114 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-restricted-properties, camelcase,
+/* eslint-disable no-restricted-properties, babel/camelcase,
no-unused-expressions, default-case,
consistent-return, no-alert, no-param-reassign, no-else-return,
no-shadow, no-useless-escape,
@@ -11,11 +11,11 @@ old_notes_spec.js is the spec for the legacy, jQuery notes application. It has n
*/
import $ from 'jquery';
-import _ from 'underscore';
+import { escape, uniqueId } from 'lodash';
import Cookies from 'js-cookie';
import Autosize from 'autosize';
import 'jquery.caret'; // required by at.js
-import 'at.js';
+import '@gitlab/at.js';
import Vue from 'vue';
import { GlSkeletonLoading } from '@gitlab/ui';
import AjaxCache from '~/lib/utils/ajax_cache';
@@ -1449,7 +1449,7 @@ export default class Notes {
return {
// eslint-disable-next-line no-jquery/no-serialize
formData: $form.serialize(),
- formContent: _.escape(content),
+ formContent: escape(content),
formAction: $form.attr('action'),
formContentOriginal: content,
};
@@ -1516,18 +1516,16 @@ export default class Notes {
`<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry">
<div class="timeline-entry-inner">
<div class="timeline-icon">
- <a href="/${_.escape(currentUsername)}">
+ <a href="/${escape(currentUsername)}">
<img class="avatar s40" src="${currentUserAvatar}" />
</a>
</div>
<div class="timeline-content ${discussionClass}">
<div class="note-header">
<div class="note-header-info">
- <a href="/${_.escape(currentUsername)}">
- <span class="d-none d-sm-inline-block bold">${_.escape(
- currentUsername,
- )}</span>
- <span class="note-headline-light">${_.escape(currentUsername)}</span>
+ <a href="/${escape(currentUsername)}">
+ <span class="d-none d-sm-inline-block bold">${escape(currentUsername)}</span>
+ <span class="note-headline-light">${escape(currentUsername)}</span>
</a>
</div>
</div>
@@ -1541,8 +1539,8 @@ export default class Notes {
</li>`,
);
- $tempNote.find('.d-none.d-sm-inline-block').text(_.escape(currentUserFullname));
- $tempNote.find('.note-headline-light').text(`@${_.escape(currentUsername)}`);
+ $tempNote.find('.d-none.d-sm-inline-block').text(escape(currentUserFullname));
+ $tempNote.find('.note-headline-light').text(`@${escape(currentUsername)}`);
return $tempNote;
}
@@ -1627,7 +1625,7 @@ export default class Notes {
// Show placeholder note
if (tempFormContent) {
- noteUniqueId = _.uniqueId('tempNote_');
+ noteUniqueId = uniqueId('tempNote_');
$notesContainer.append(
this.createPlaceholderNote({
formContent: tempFormContent,
@@ -1642,7 +1640,7 @@ export default class Notes {
// Show placeholder system note
if (hasQuickActions) {
- systemNoteUniqueId = _.uniqueId('tempSystemNote_');
+ systemNoteUniqueId = uniqueId('tempSystemNote_');
$notesContainer.append(
this.createPlaceholderSystemNote({
formContent: this.getQuickActionDescription(
@@ -1825,7 +1823,7 @@ export default class Notes {
})
.catch(() => {
// Submission failed, revert back to original note
- $noteBodyText.html(_.escape(cachedNoteBodyText));
+ $noteBodyText.html(escape(cachedNoteBodyText));
$editingNote.removeClass('being-posted fade-in');
$editingNote.find('.fa.fa-spinner').remove();
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 4ca32b9b005..9a809b71a58 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -1,7 +1,7 @@
<script>
import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex';
-import _ from 'underscore';
+import { isEmpty } from 'lodash';
import Autosize from 'autosize';
import { __, sprintf } from '~/locale';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
@@ -161,7 +161,7 @@ export default {
'toggleStateButtonLoading',
]),
setIsSubmitButtonDisabled(note, isSubmitting) {
- if (!_.isEmpty(note) && !isSubmitting) {
+ if (!isEmpty(note) && !isSubmitting) {
this.isSubmitButtonDisabled = false;
} else {
this.isSubmitButtonDisabled = true;
diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue
index 4c9075912ee..50d224a2f08 100644
--- a/app/assets/javascripts/notes/components/diff_discussion_header.vue
+++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue
@@ -1,6 +1,6 @@
<script>
import { mapActions } from 'vuex';
-import _ from 'underscore';
+import { escape } from 'lodash';
import { s__, __, sprintf } from '~/locale';
import { truncateSha } from '~/lib/utils/text_utility';
@@ -45,7 +45,7 @@ export default {
return this.notes.length > 1 ? this.lastNote.created_at : null;
},
headerText() {
- const linkStart = `<a href="${_.escape(this.discussion.discussion_path)}">`;
+ const linkStart = `<a href="${escape(this.discussion.discussion_path)}">`;
const linkEnd = '</a>';
const { commit_id: commitId } = this.discussion;
diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue
index fad1bc67be7..8ab31ef3448 100644
--- a/app/assets/javascripts/notes/components/discussion_actions.vue
+++ b/app/assets/javascripts/notes/components/discussion_actions.vue
@@ -73,7 +73,7 @@ export default {
v-if="discussion.resolvable && shouldShowJumpToNextDiscussion"
class="btn-group discussion-actions ml-sm-2"
>
- <jump-to-next-discussion-button @onClick="$emit('jumpToNextDiscussion')" />
+ <jump-to-next-discussion-button />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index 98f1f385e9b..70e22db364b 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -1,5 +1,5 @@
<script>
-import { mapActions, mapGetters } from 'vuex';
+import { mapGetters } from 'vuex';
import { GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import discussionNavigation from '../mixins/discussion_navigation';
@@ -17,9 +17,7 @@ export default {
'getUserData',
'getNoteableData',
'resolvableDiscussionsCount',
- 'firstUnresolvedDiscussionId',
'unresolvedDiscussionsCount',
- 'getDiscussion',
]),
isLoggedIn() {
return this.getUserData.id;
@@ -37,16 +35,6 @@ export default {
return this.resolvableDiscussionsCount - this.unresolvedDiscussionsCount;
},
},
- methods: {
- ...mapActions(['expandDiscussion']),
- jumpToFirstUnresolvedDiscussion() {
- const diffTab = window.mrTabs.currentAction === 'diffs';
- const discussionId =
- this.firstUnresolvedDiscussionId(diffTab) || this.firstUnresolvedDiscussionId();
- const firstDiscussion = this.getDiscussion(discussionId);
- this.jumpToDiscussion(firstDiscussion);
- },
- },
};
</script>
@@ -83,9 +71,9 @@ export default {
<div v-if="isLoggedIn && !allResolved" class="btn-group btn-group-sm" role="group">
<button
v-gl-tooltip
- title="Jump to first unresolved thread"
+ title="Jump to next unresolved thread"
class="btn btn-default discussion-next-btn"
- @click="jumpToFirstUnresolvedDiscussion"
+ @click="jumpToNextDiscussion"
>
<icon name="comment-next" />
</button>
diff --git a/app/assets/javascripts/notes/components/discussion_filter_note.vue b/app/assets/javascripts/notes/components/discussion_filter_note.vue
index 889731df180..8dc4b43d69a 100644
--- a/app/assets/javascripts/notes/components/discussion_filter_note.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter_note.vue
@@ -38,12 +38,12 @@ export default {
<icon name="comment" />
</div>
<div class="timeline-content">
- <div v-html="timelineContent"></div>
+ <div ref="timelineContent" v-html="timelineContent"></div>
<div class="discussion-filter-actions mt-2">
- <gl-button variant="default" @click="selectFilter(0)">
+ <gl-button ref="showAllActivity" variant="default" @click="selectFilter(0)">
{{ __('Show all activity') }}
</gl-button>
- <gl-button variant="default" @click="selectFilter(1)">
+ <gl-button ref="showComments" variant="default" @click="selectFilter(1)">
{{ __('Show comments only') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue b/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue
index f87ca097b40..630d4fd89b1 100644
--- a/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue
+++ b/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue
@@ -1,6 +1,7 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import icon from '~/vue_shared/components/icon.vue';
+import discussionNavigation from '../mixins/discussion_navigation';
export default {
name: 'JumpToNextDiscussionButton',
@@ -10,6 +11,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [discussionNavigation],
};
</script>
@@ -19,8 +21,8 @@ export default {
ref="button"
v-gl-tooltip
class="btn btn-default discussion-next-btn"
- :title="s__('MergeRequests|Jump to next unresolved discussion')"
- @click="$emit('onClick')"
+ :title="s__('MergeRequests|Jump to next unresolved thread')"
+ @click="jumpToNextDiscussion"
>
<icon name="comment-next" />
</button>
diff --git a/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue b/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue
index 7d742fbfeee..2dc222d08f9 100644
--- a/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue
+++ b/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue
@@ -1,53 +1,18 @@
<script>
/* global Mousetrap */
import 'mousetrap';
-import { mapGetters, mapActions } from 'vuex';
import discussionNavigation from '~/notes/mixins/discussion_navigation';
export default {
mixins: [discussionNavigation],
- props: {
- isDiffView: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- currentDiscussionId: null,
- };
- },
- computed: {
- ...mapGetters([
- 'nextUnresolvedDiscussionId',
- 'previousUnresolvedDiscussionId',
- 'getDiscussion',
- ]),
- },
mounted() {
- Mousetrap.bind('n', () => this.jumpToNextDiscussion());
- Mousetrap.bind('p', () => this.jumpToPreviousDiscussion());
+ Mousetrap.bind('n', this.jumpToNextDiscussion);
+ Mousetrap.bind('p', this.jumpToPreviousDiscussion);
},
beforeDestroy() {
Mousetrap.unbind('n');
Mousetrap.unbind('p');
},
- methods: {
- ...mapActions(['expandDiscussion']),
- jumpToNextDiscussion() {
- const nextId = this.nextUnresolvedDiscussionId(this.currentDiscussionId, this.isDiffView);
- const nextDiscussion = this.getDiscussion(nextId);
- this.jumpToDiscussion(nextDiscussion);
- this.currentDiscussionId = nextId;
- },
- jumpToPreviousDiscussion() {
- const prevId = this.previousUnresolvedDiscussionId(this.currentDiscussionId, this.isDiffView);
- const prevDiscussion = this.getDiscussion(prevId);
- this.jumpToDiscussion(prevDiscussion);
- this.currentDiscussionId = prevId;
- },
- },
render() {
return this.$slots.default;
},
diff --git a/app/assets/javascripts/notes/components/note_attachment.vue b/app/assets/javascripts/notes/components/note_attachment.vue
index b6d8c831e2e..72f9a4c7e74 100644
--- a/app/assets/javascripts/notes/components/note_attachment.vue
+++ b/app/assets/javascripts/notes/components/note_attachment.vue
@@ -12,11 +12,23 @@ export default {
<template>
<div class="note-attachment">
- <a v-if="attachment.image" :href="attachment.url" target="_blank" rel="noopener noreferrer">
+ <a
+ v-if="attachment.image"
+ ref="attachmentImage"
+ :href="attachment.url"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
<img :src="attachment.url" class="note-image-attach" />
</a>
<div class="attachment">
- <a v-if="attachment.url" :href="attachment.url" target="_blank" rel="noopener noreferrer">
+ <a
+ v-if="attachment.url"
+ ref="attachmentUrl"
+ :href="attachment.url"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
<i class="fa fa-paperclip" aria-hidden="true"> </i> {{ attachment.filename }}
</a>
</div>
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index e4f09492d9c..16351baedb7 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -63,13 +63,13 @@ export default {
<template>
<div class="note-header-info">
- <div v-if="includeToggle" class="discussion-actions">
+ <div v-if="includeToggle" ref="discussionActions" class="discussion-actions">
<button
class="note-action-button discussion-toggle-button js-vue-toggle-button"
type="button"
@click="handleToggle"
>
- <i :class="toggleChevronClass" class="fa" aria-hidden="true"></i>
+ <i ref="chevronIcon" :class="toggleChevronClass" class="fa" aria-hidden="true"></i>
{{ __('Toggle thread') }}
</button>
</div>
@@ -90,10 +90,11 @@ export default {
<span class="note-headline-light note-headline-meta">
<span class="system-note-message"> <slot></slot> </span>
<template v-if="createdAt">
- <span class="system-note-separator">
+ <span ref="actionText" class="system-note-separator">
<template v-if="actionText">{{ actionText }}</template>
</span>
<a
+ ref="noteTimestamp"
:href="noteTimestampLink"
class="note-timestamp system-note-separator"
@click="updateTargetNoteHash"
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 3462ee72dd3..189ff88feb3 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -14,7 +14,6 @@ import noteForm from './note_form.vue';
import diffWithNote from './diff_with_note.vue';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
-import discussionNavigation from '../mixins/discussion_navigation';
import eventHub from '../event_hub';
import DiscussionNotes from './discussion_notes.vue';
import DiscussionActions from './discussion_actions.vue';
@@ -35,7 +34,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [noteable, resolvable, discussionNavigation, diffLineNoteFormMixin],
+ mixins: [noteable, resolvable, diffLineNoteFormMixin],
props: {
discussion: {
type: Object,
@@ -79,12 +78,8 @@ export default {
'convertedDisscussionIds',
'getNoteableData',
'userCanReply',
- 'nextUnresolvedDiscussionId',
- 'unresolvedDiscussionsCount',
- 'hasUnresolvedDiscussions',
'showJumpToNextDiscussion',
'getUserData',
- 'getDiscussion',
]),
currentUser() {
return this.getUserData;
@@ -152,7 +147,6 @@ export default {
'saveNote',
'removePlaceholderNotes',
'toggleResolveNote',
- 'expandDiscussion',
'removeConvertedDiscussion',
]),
showReplyForm() {
@@ -219,15 +213,6 @@ export default {
callback(err);
});
},
- jumpToNextDiscussion() {
- const nextId = this.nextUnresolvedDiscussionId(
- this.discussion.id,
- this.discussionsByDiffOrder,
- );
- const nextDiscussion = this.getDiscussion(nextId);
-
- this.jumpToDiscussion(nextDiscussion);
- },
deleteNoteHandler(note) {
this.$emit('noteDeleted', this.discussion, note);
},
@@ -294,7 +279,6 @@ export default {
:should-show-jump-to-next-discussion="shouldShowJumpToNextDiscussion"
@showReplyForm="showReplyForm"
@resolve="resolveHandler"
- @jumpToNextDiscussion="jumpToNextDiscussion"
/>
<div v-if="isReplying" class="avatar-note-form-holder">
<user-avatar-link
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index b3dae69d0bc..dea782683f2 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -1,7 +1,7 @@
<script>
import $ from 'jquery';
import { mapGetters, mapActions } from 'vuex';
-import { escape } from 'underscore';
+import { escape } from 'lodash';
import draftMixin from 'ee_else_ce/notes/mixins/draft';
import { truncateSha } from '~/lib/utils/text_utility';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index be2adb07526..762228dd138 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -14,7 +14,7 @@ import placeholderSystemNote from '../../vue_shared/components/notes/placeholder
import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue';
import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
import { __ } from '~/locale';
-import initUserPopovers from '../../user_popovers';
+import initUserPopovers from '~/user_popovers';
export default {
name: 'NotesApp',
diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
index f1b0b12bdce..dd132d4f608 100644
--- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue
+++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
@@ -1,5 +1,5 @@
<script>
-import _ from 'underscore';
+import { uniqBy } from 'lodash';
import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -27,7 +27,7 @@ export default {
uniqueAuthors() {
const authors = this.replies.map(reply => reply.author || {});
- return _.uniq(authors, author => author.username);
+ return uniqBy(authors, author => author.username);
},
className() {
return this.collapsed ? 'collapsed' : 'expanded';
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
index 68c117183a1..e9a81bc9553 100644
--- a/app/assets/javascripts/notes/constants.js
+++ b/app/assets/javascripts/notes/constants.js
@@ -18,6 +18,7 @@ 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 TIME_DIFFERENCE_VALUE = 10;
export const NOTEABLE_TYPE_MAPPING = {
Issue: ISSUE_NOTEABLE_TYPE,
diff --git a/app/assets/javascripts/notes/mixins/description_version_history.js b/app/assets/javascripts/notes/mixins/description_version_history.js
index 12d80f3faa2..66e6685cfd8 100644
--- a/app/assets/javascripts/notes/mixins/description_version_history.js
+++ b/app/assets/javascripts/notes/mixins/description_version_history.js
@@ -3,10 +3,12 @@
export default {
computed: {
canSeeDescriptionVersion() {},
+ canDeleteDescriptionVersion() {},
shouldShowDescriptionVersion() {},
descriptionVersionToggleIcon() {},
},
methods: {
toggleDescriptionVersion() {},
+ deleteDescriptionVersion() {},
},
};
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index 94ca01e44cc..e5066695403 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -1,8 +1,21 @@
+import { mapGetters, mapActions, mapState } from 'vuex';
import { scrollToElement } from '~/lib/utils/common_utils';
import eventHub from '../../notes/event_hub';
export default {
+ computed: {
+ ...mapGetters([
+ 'nextUnresolvedDiscussionId',
+ 'previousUnresolvedDiscussionId',
+ 'getDiscussion',
+ ]),
+ ...mapState({
+ currentDiscussionId: state => state.notes.currentDiscussionId,
+ }),
+ },
methods: {
+ ...mapActions(['expandDiscussion', 'setCurrentDiscussionId']),
+
diffsJump(id) {
const selector = `ul.notes[data-discussion-id="${id}"]`;
@@ -58,5 +71,21 @@ export default {
}
}
},
+
+ jumpToNextDiscussion() {
+ this.handleDiscussionJump(this.nextUnresolvedDiscussionId);
+ },
+
+ jumpToPreviousDiscussion() {
+ this.handleDiscussionJump(this.previousUnresolvedDiscussionId);
+ },
+
+ handleDiscussionJump(fn) {
+ const isDiffView = window.mrTabs.currentAction === 'diffs';
+ const targetId = fn(this.currentDiscussionId, isDiffView);
+ const discussion = this.getDiscussion(targetId);
+ this.jumpToDiscussion(discussion);
+ this.setCurrentDiscussionId(targetId);
+ },
},
};
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 9bd245c094d..594e3a14d56 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -491,20 +491,66 @@ export const convertToDiscussion = ({ commit }, noteId) =>
export const removeConvertedDiscussion = ({ commit }, noteId) =>
commit(types.REMOVE_CONVERTED_DISCUSSION, noteId);
-export const fetchDescriptionVersion = (_, { endpoint, startingVersion }) => {
+export const setCurrentDiscussionId = ({ commit }, discussionId) =>
+ commit(types.SET_CURRENT_DISCUSSION_ID, discussionId);
+
+export const fetchDescriptionVersion = ({ dispatch }, { endpoint, startingVersion }) => {
let requestUrl = endpoint;
if (startingVersion) {
requestUrl = mergeUrlParams({ start_version_id: startingVersion }, requestUrl);
}
+ dispatch('requestDescriptionVersion');
return axios
.get(requestUrl)
- .then(res => res.data)
- .catch(() => {
+ .then(res => {
+ dispatch('receiveDescriptionVersion', res.data);
+ })
+ .catch(error => {
+ dispatch('receiveDescriptionVersionError', error);
Flash(__('Something went wrong while fetching description changes. Please try again.'));
});
};
+export const requestDescriptionVersion = ({ commit }) => {
+ commit(types.REQUEST_DESCRIPTION_VERSION);
+};
+export const receiveDescriptionVersion = ({ commit }, descriptionVersion) => {
+ commit(types.RECEIVE_DESCRIPTION_VERSION, descriptionVersion);
+};
+export const receiveDescriptionVersionError = ({ commit }, error) => {
+ commit(types.RECEIVE_DESCRIPTION_VERSION_ERROR, error);
+};
+
+export const softDeleteDescriptionVersion = ({ dispatch }, { endpoint, startingVersion }) => {
+ let requestUrl = endpoint;
+
+ if (startingVersion) {
+ requestUrl = mergeUrlParams({ start_version_id: startingVersion }, requestUrl);
+ }
+ dispatch('requestDeleteDescriptionVersion');
+
+ return axios
+ .delete(requestUrl)
+ .then(() => {
+ dispatch('receiveDeleteDescriptionVersion');
+ })
+ .catch(error => {
+ dispatch('receiveDeleteDescriptionVersionError', error);
+ Flash(__('Something went wrong while deleting description changes. Please try again.'));
+ });
+};
+
+export const requestDeleteDescriptionVersion = ({ commit }) => {
+ commit(types.REQUEST_DELETE_DESCRIPTION_VERSION);
+};
+export const receiveDeleteDescriptionVersion = ({ commit }) => {
+ commit(types.RECEIVE_DELETE_DESCRIPTION_VERSION, __('Deleted'));
+};
+export const receiveDeleteDescriptionVersionError = ({ commit }, error) => {
+ commit(types.RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR, error);
+};
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/notes/stores/collapse_utils.js b/app/assets/javascripts/notes/stores/collapse_utils.js
index 3cdcc7a05b8..d94fc626a3f 100644
--- a/app/assets/javascripts/notes/stores/collapse_utils.js
+++ b/app/assets/javascripts/notes/stores/collapse_utils.js
@@ -1,4 +1,4 @@
-import { DESCRIPTION_TYPE } from '../constants';
+import { DESCRIPTION_TYPE, TIME_DIFFERENCE_VALUE } from '../constants';
/**
* Checks the time difference between two notes from their 'created_at' dates
@@ -45,7 +45,11 @@ export const collapseSystemNotes = notes => {
const timeDifferenceMinutes = getTimeDifferenceMinutes(lastDescriptionSystemNote, note);
// are they less than 10 minutes apart from the same user?
- if (timeDifferenceMinutes > 10 || note.author.id !== lastDescriptionSystemNote.author.id) {
+ if (
+ timeDifferenceMinutes > TIME_DIFFERENCE_VALUE ||
+ note.author.id !== lastDescriptionSystemNote.author.id ||
+ lastDescriptionSystemNote.description_version_deleted
+ ) {
// update the previous system note
lastDescriptionSystemNote = note;
lastDescriptionSystemNoteIndex = acc.length;
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index 3d0ec8cd3a7..4f8ff8240b2 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -1,4 +1,4 @@
-import _ from 'underscore';
+import { flattenDeep } from 'lodash';
import * as constants from '../constants';
import { collapseSystemNotes } from './collapse_utils';
@@ -50,7 +50,7 @@ const isLastNote = (note, state) =>
!note.system && state.userData && note.author && note.author.id === state.userData.id;
export const getCurrentUserLastNote = state =>
- _.flatten(reverseNotes(state.discussions).map(note => reverseNotes(note.notes))).find(el =>
+ flattenDeep(reverseNotes(state.discussions).map(note => reverseNotes(note.notes))).find(el =>
isLastNote(el, state),
);
@@ -59,7 +59,6 @@ export const getDiscussionLastNote = state => discussion =>
export const unresolvedDiscussionsCount = state => state.unresolvedDiscussionsCount;
export const resolvableDiscussionsCount = state => state.resolvableDiscussionsCount;
-export const hasUnresolvedDiscussions = state => state.hasUnresolvedDiscussions;
export const showJumpToNextDiscussion = (state, getters) => (mode = 'discussion') => {
const orderedDiffs =
diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js
index 6168aeae35d..0e991f2f4f0 100644
--- a/app/assets/javascripts/notes/stores/modules/index.js
+++ b/app/assets/javascripts/notes/stores/modules/index.js
@@ -8,11 +8,13 @@ export default () => ({
convertedDisscussionIds: [],
targetNoteHash: null,
lastFetchedAt: null,
+ currentDiscussionId: null,
// View layer
isToggleStateButtonLoading: false,
isNotesFetched: false,
isLoading: true,
+ isLoadingDescriptionVersion: false,
// holds endpoints and permissions provided through haml
notesData: {
@@ -26,7 +28,7 @@ export default () => ({
commentsDisabled: false,
resolvableDiscussionsCount: 0,
unresolvedDiscussionsCount: 0,
- hasUnresolvedDiscussions: false,
+ descriptionVersion: null,
},
actions,
getters,
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index 796370920bb..6554aee0d5b 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -25,8 +25,17 @@ export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION';
export const EXPAND_DISCUSSION = 'EXPAND_DISCUSSION';
export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION';
export const UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS = 'UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS';
+export const SET_CURRENT_DISCUSSION_ID = 'SET_CURRENT_DISCUSSION_ID';
// Issue
export const CLOSE_ISSUE = 'CLOSE_ISSUE';
export const REOPEN_ISSUE = 'REOPEN_ISSUE';
export const TOGGLE_STATE_BUTTON_LOADING = 'TOGGLE_STATE_BUTTON_LOADING';
+
+// Description version
+export const REQUEST_DESCRIPTION_VERSION = 'REQUEST_DESCRIPTION_VERSION';
+export const RECEIVE_DESCRIPTION_VERSION = 'RECEIVE_DESCRIPTION_VERSION';
+export const RECEIVE_DESCRIPTION_VERSION_ERROR = 'RECEIVE_DESCRIPTION_VERSION_ERROR';
+export const REQUEST_DELETE_DESCRIPTION_VERSION = 'REQUEST_DELETE_DESCRIPTION_VERSION';
+export const RECEIVE_DELETE_DESCRIPTION_VERSION = 'RECEIVE_DELETE_DESCRIPTION_VERSION';
+export const RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR = 'RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index e70f0238316..d32a88e4c71 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -267,7 +267,6 @@ export default {
discussion.resolvable &&
discussion.notes.some(note => note.resolvable && !note.resolved),
).length;
- state.hasUnresolvedDiscussions = state.unresolvedDiscussionsCount > 1;
},
[types.CONVERT_TO_DISCUSSION](state, discussionId) {
@@ -281,4 +280,29 @@ export default {
convertedDisscussionIds.splice(convertedDisscussionIds.indexOf(discussionId), 1);
Object.assign(state, { convertedDisscussionIds });
},
+
+ [types.SET_CURRENT_DISCUSSION_ID](state, discussionId) {
+ state.currentDiscussionId = discussionId;
+ },
+
+ [types.REQUEST_DESCRIPTION_VERSION](state) {
+ state.isLoadingDescriptionVersion = true;
+ },
+ [types.RECEIVE_DESCRIPTION_VERSION](state, descriptionVersion) {
+ state.isLoadingDescriptionVersion = false;
+ state.descriptionVersion = descriptionVersion;
+ },
+ [types.RECEIVE_DESCRIPTION_VERSION_ERROR](state) {
+ state.isLoadingDescriptionVersion = false;
+ },
+ [types.REQUEST_DELETE_DESCRIPTION_VERSION](state) {
+ state.isLoadingDescriptionVersion = true;
+ },
+ [types.RECEIVE_DELETE_DESCRIPTION_VERSION](state, descriptionVersion) {
+ state.isLoadingDescriptionVersion = false;
+ state.descriptionVersion = descriptionVersion;
+ },
+ [types.RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR](state) {
+ state.isLoadingDescriptionVersion = false;
+ },
};
diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js
index dcd226795a6..fa27994f598 100644
--- a/app/assets/javascripts/notifications_form.js
+++ b/app/assets/javascripts/notifications_form.js
@@ -26,7 +26,7 @@ export default class NotificationsForm {
.addClass('is-loading')
.find('.custom-notification-event-loading')
.removeClass('fa-check')
- .addClass('fa-spin fa-spinner')
+ .addClass('spinner align-middle')
.removeClass('is-done');
}
@@ -41,12 +41,12 @@ export default class NotificationsForm {
if (data.saved) {
$parent
.find('.custom-notification-event-loading')
- .toggleClass('fa-spin fa-spinner fa-check is-done');
+ .toggleClass('spinner fa-check is-done align-middle');
setTimeout(() => {
$parent
.removeClass('is-loading')
.find('.custom-notification-event-loading')
- .toggleClass('fa-spin fa-spinner fa-check is-done');
+ .toggleClass('spinner fa-check is-done align-middle');
}, 2000);
}
})
diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js
index 089dedd14cb..78b7e29ae53 100644
--- a/app/assets/javascripts/pages/admin/application_settings/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/index.js
@@ -3,9 +3,7 @@ import projectSelect from '~/project_select';
import selfMonitor from '~/self_monitor';
document.addEventListener('DOMContentLoaded', () => {
- if (gon.features && gon.features.selfMonitoringProject) {
- selfMonitor();
- }
+ selfMonitor();
// Initialize expandable settings panels
initSettingsPanels();
projectSelect();
diff --git a/app/assets/javascripts/pages/admin/application_settings/usage_ping_payload.js b/app/assets/javascripts/pages/admin/application_settings/usage_ping_payload.js
index 9a1bc46bf4a..95f4ba28b42 100644
--- a/app/assets/javascripts/pages/admin/application_settings/usage_ping_payload.js
+++ b/app/assets/javascripts/pages/admin/application_settings/usage_ping_payload.js
@@ -26,18 +26,18 @@ export default class UsagePingPayload {
requestPayload() {
if (this.isInserted) return this.showPayload();
- this.spinner.classList.add('d-inline');
+ this.spinner.classList.add('d-inline-flex');
return axios
.get(this.container.dataset.endpoint, {
responseType: 'text',
})
.then(({ data }) => {
- this.spinner.classList.remove('d-inline');
+ this.spinner.classList.remove('d-inline-flex');
this.insertPayload(data);
})
.catch(() => {
- this.spinner.classList.remove('d-inline');
+ this.spinner.classList.remove('d-inline-flex');
flash(__('Error fetching usage ping data.'));
});
}
diff --git a/app/assets/javascripts/pages/admin/serverless/domains/index.js b/app/assets/javascripts/pages/admin/serverless/domains/index.js
new file mode 100644
index 00000000000..5be466886a5
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/serverless/domains/index.js
@@ -0,0 +1,19 @@
+import initSettingsPanels from '~/settings_panels';
+
+document.addEventListener('DOMContentLoaded', () => {
+ // Initialize expandable settings panels
+ initSettingsPanels();
+
+ const domainCard = document.querySelector('.js-domain-cert-show');
+ const domainForm = document.querySelector('.js-domain-cert-inputs');
+ const domainReplaceButton = document.querySelector('.js-domain-cert-replace-btn');
+ const domainSubmitButton = document.querySelector('.js-serverless-domain-submit');
+
+ if (domainReplaceButton && domainCard && domainForm) {
+ domainReplaceButton.addEventListener('click', () => {
+ domainCard.classList.add('hidden');
+ domainForm.classList.remove('hidden');
+ domainSubmitButton.removeAttribute('disabled');
+ });
+ }
+});
diff --git a/app/assets/javascripts/pages/groups/registry/repositories/index.js b/app/assets/javascripts/pages/groups/registry/repositories/index.js
index 635513afd95..47fea2be189 100644
--- a/app/assets/javascripts/pages/groups/registry/repositories/index.js
+++ b/app/assets/javascripts/pages/groups/registry/repositories/index.js
@@ -1,3 +1,9 @@
-import initRegistryImages from '~/registry/list';
+import initRegistryImages from '~/registry/list/index';
+import registryExplorer from '~/registry/explorer/index';
-document.addEventListener('DOMContentLoaded', initRegistryImages);
+document.addEventListener('DOMContentLoaded', () => {
+ initRegistryImages();
+ const { attachMainComponent, attachBreadcrumb } = registryExplorer();
+ attachBreadcrumb();
+ attachMainComponent();
+});
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index aee67899ca2..caf9a8c0b64 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -30,4 +30,9 @@ document.addEventListener('DOMContentLoaded', () => {
}
GpgBadges.fetch();
+
+ if (gon.features?.codeNavigation) {
+ // eslint-disable-next-line promise/catch-or-return
+ import('~/code_navigation').then(m => m.default());
+ }
});
diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js
index 314519ee442..803f4e37705 100644
--- a/app/assets/javascripts/pages/projects/graphs/charts/index.js
+++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js
@@ -1,49 +1,15 @@
-import $ from 'jquery';
-import Chart from 'chart.js';
-import _ from 'underscore';
-
-import { barChartOptions, pieChartOptions } from '~/lib/utils/chart_utils';
+import Vue from 'vue';
+import { __ } from '~/locale';
+import { GlColumnChart } from '@gitlab/ui/dist/charts';
+import SeriesDataMixin from './series_data_mixin';
document.addEventListener('DOMContentLoaded', () => {
- const projectChartData = JSON.parse(document.getElementById('projectChartData').innerHTML);
-
- const barChart = (selector, data) => {
- // get selector by context
- const ctx = selector.get(0).getContext('2d');
- // pointing parent container to make chart.js inherit its width
- const container = $(selector).parent();
- selector.attr('width', $(container).width());
-
- // Scale fonts if window width lower than 768px (iPad portrait)
- const shouldAdjustFontSize = window.innerWidth < 768;
- return new Chart(ctx, {
- type: 'bar',
- data,
- options: barChartOptions(shouldAdjustFontSize),
- });
- };
+ const languagesContainer = document.getElementById('js-languages-chart');
+ const monthContainer = document.getElementById('js-month-chart');
+ const weekdayContainer = document.getElementById('js-weekday-chart');
+ const hourContainer = document.getElementById('js-hour-chart');
- const pieChart = (context, data) => {
- const options = pieChartOptions();
-
- return new Chart(context, {
- type: 'pie',
- data,
- options,
- });
- };
-
- const chartData = data => ({
- labels: Object.keys(data),
- datasets: [
- {
- backgroundColor: 'rgba(220,220,220,0.5)',
- borderColor: 'rgba(220,220,220,1)',
- borderWidth: 1,
- data: _.values(data),
- },
- ],
- });
+ const LANGUAGE_CHART_HEIGHT = 300;
const reorderWeekDays = (weekDays, firstDayOfWeek = 0) => {
if (firstDayOfWeek === 0) {
@@ -60,28 +26,115 @@ document.addEventListener('DOMContentLoaded', () => {
}, {});
};
- const hourData = chartData(projectChartData.hour);
- barChart($('#hour-chart'), hourData);
-
- const weekDays = reorderWeekDays(projectChartData.weekDays, gon.first_day_of_week);
- const dayData = chartData(weekDays);
- barChart($('#weekday-chart'), dayData);
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: languagesContainer,
+ components: {
+ GlColumnChart,
+ },
+ data() {
+ return {
+ chartData: JSON.parse(languagesContainer.dataset.chartData),
+ };
+ },
+ computed: {
+ seriesData() {
+ return { full: this.chartData.map(d => [d.label, d.value]) };
+ },
+ },
+ render(h) {
+ return h(GlColumnChart, {
+ props: {
+ data: this.seriesData,
+ xAxisTitle: __('Used programming language'),
+ yAxisTitle: __('Percentage'),
+ xAxisType: 'category',
+ },
+ attrs: {
+ height: LANGUAGE_CHART_HEIGHT,
+ },
+ });
+ },
+ });
- const monthData = chartData(projectChartData.month);
- barChart($('#month-chart'), monthData);
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: monthContainer,
+ components: {
+ GlColumnChart,
+ },
+ mixins: [SeriesDataMixin],
+ data() {
+ return {
+ chartData: JSON.parse(monthContainer.dataset.chartData),
+ };
+ },
+ render(h) {
+ return h(GlColumnChart, {
+ props: {
+ data: this.seriesData,
+ xAxisTitle: __('Day of month'),
+ yAxisTitle: __('No. of commits'),
+ xAxisType: 'category',
+ },
+ });
+ },
+ });
- const data = {
- datasets: [
- {
- data: projectChartData.languages.map(x => x.value),
- backgroundColor: projectChartData.languages.map(x => x.color),
- hoverBackgroundColor: projectChartData.languages.map(x => x.highlight),
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: weekdayContainer,
+ components: {
+ GlColumnChart,
+ },
+ data() {
+ return {
+ chartData: JSON.parse(weekdayContainer.dataset.chartData),
+ };
+ },
+ computed: {
+ seriesData() {
+ const weekDays = reorderWeekDays(this.chartData, gon.first_day_of_week);
+ const data = Object.keys(weekDays).reduce((acc, key) => {
+ acc.push([key, weekDays[key]]);
+ return acc;
+ }, []);
+ return { full: data };
},
- ],
- labels: projectChartData.languages.map(x => x.label),
- };
- const ctx = $('#languages-chart')
- .get(0)
- .getContext('2d');
- pieChart(ctx, data);
+ },
+ render(h) {
+ return h(GlColumnChart, {
+ props: {
+ data: this.seriesData,
+ xAxisTitle: __('Weekday'),
+ yAxisTitle: __('No. of commits'),
+ xAxisType: 'category',
+ },
+ });
+ },
+ });
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: hourContainer,
+ components: {
+ GlColumnChart,
+ },
+ mixins: [SeriesDataMixin],
+ data() {
+ return {
+ chartData: JSON.parse(hourContainer.dataset.chartData),
+ };
+ },
+ render(h) {
+ return h(GlColumnChart, {
+ props: {
+ data: this.seriesData,
+ xAxisTitle: __('Hour (UTC)'),
+ yAxisTitle: __('No. of commits'),
+ xAxisType: 'category',
+ },
+ });
+ },
+ });
});
diff --git a/app/assets/javascripts/pages/projects/graphs/charts/series_data_mixin.js b/app/assets/javascripts/pages/projects/graphs/charts/series_data_mixin.js
new file mode 100644
index 00000000000..941427a1ac3
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/graphs/charts/series_data_mixin.js
@@ -0,0 +1,11 @@
+export default {
+ computed: {
+ seriesData() {
+ const data = Object.keys(this.chartData).reduce((acc, key) => {
+ acc.push([key, this.chartData[key]]);
+ return acc;
+ }, []);
+ return { full: data };
+ },
+ },
+};
diff --git a/app/assets/javascripts/pages/projects/init_blob.js b/app/assets/javascripts/pages/projects/init_blob.js
index bd8afa2d5ba..5eb0d323266 100644
--- a/app/assets/javascripts/pages/projects/init_blob.js
+++ b/app/assets/javascripts/pages/projects/init_blob.js
@@ -11,7 +11,7 @@ export default () => {
// eslint-disable-next-line no-new
new BlobLinePermalinkUpdater(
document.querySelector('#blob-content-holder'),
- '.diff-line-num[data-line-number]',
+ '.diff-line-num[data-line-number], .diff-line-num[data-line-number] *',
document.querySelectorAll('.js-data-file-blob-permalink-url, .js-blob-blame-link'),
);
@@ -25,6 +25,7 @@ export default () => {
new ShortcutsBlob({
skipResetBindings: true,
fileBlobPermalinkUrl,
+ fileBlobPermalinkUrlElement,
});
new BlobForkSuggestion({
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 1f8befc07c8..c4cc667710a 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
@@ -7,7 +7,6 @@ import initPipelines from '~/commit/pipelines/pipelines_bundle';
import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle';
import initSourcegraph from '~/sourcegraph';
import initPopover from '~/mr_tabs_popover';
-import initWidget from '../../../vue_merge_request_widget';
export default function() {
new ZenMode(); // eslint-disable-line no-new
@@ -20,7 +19,6 @@ export default function() {
new ShortcutsIssuable(true); // eslint-disable-line no-new
handleLocationHash();
howToMerge();
- initWidget();
initSourcegraph();
const tabHighlightEl = document.querySelector('.js-tabs-feature-highlight');
diff --git a/app/assets/javascripts/pages/projects/pipelines/charts/index.js b/app/assets/javascripts/pages/projects/pipelines/charts/index.js
index 9fa580d2ba9..d77b84a3b24 100644
--- a/app/assets/javascripts/pages/projects/pipelines/charts/index.js
+++ b/app/assets/javascripts/pages/projects/pipelines/charts/index.js
@@ -1,83 +1,3 @@
-import $ from 'jquery';
-import Chart from 'chart.js';
+import initProjectPipelinesChartsApp from '~/projects/pipelines/charts/index';
-import { barChartOptions, lineChartOptions } from '~/lib/utils/chart_utils';
-
-const SUCCESS_LINE_COLOR = '#1aaa55';
-
-const TOTAL_LINE_COLOR = '#707070';
-
-const buildChart = (chartScope, shouldAdjustFontSize) => {
- const data = {
- labels: chartScope.labels,
- datasets: [
- {
- backgroundColor: SUCCESS_LINE_COLOR,
- borderColor: SUCCESS_LINE_COLOR,
- pointBackgroundColor: SUCCESS_LINE_COLOR,
- pointBorderColor: '#fff',
- data: chartScope.successValues,
- fill: 'origin',
- },
- {
- backgroundColor: TOTAL_LINE_COLOR,
- borderColor: TOTAL_LINE_COLOR,
- pointBackgroundColor: TOTAL_LINE_COLOR,
- pointBorderColor: '#EEE',
- data: chartScope.totalValues,
- fill: '-1',
- },
- ],
- };
- const ctx = $(`#${chartScope.scope}Chart`)
- .get(0)
- .getContext('2d');
-
- return new Chart(ctx, {
- type: 'line',
- data,
- options: lineChartOptions({
- width: ctx.canvas.width,
- numberOfPoints: chartScope.totalValues.length,
- shouldAdjustFontSize,
- }),
- });
-};
-
-const buildBarChart = (chartTimesData, shouldAdjustFontSize) => {
- const data = {
- labels: chartTimesData.labels,
- datasets: [
- {
- backgroundColor: 'rgba(220,220,220,0.5)',
- borderColor: 'rgba(220,220,220,1)',
- borderWidth: 1,
- barValueSpacing: 1,
- barDatasetSpacing: 1,
- data: chartTimesData.values,
- },
- ],
- };
- return new Chart(
- $('#build_timesChart')
- .get(0)
- .getContext('2d'),
- {
- type: 'bar',
- data,
- options: barChartOptions(shouldAdjustFontSize),
- },
- );
-};
-
-document.addEventListener('DOMContentLoaded', () => {
- const chartTimesData = JSON.parse(document.getElementById('pipelinesTimesChartsData').innerHTML);
- const chartsData = JSON.parse(document.getElementById('pipelinesChartsData').innerHTML);
-
- // Scale fonts if window width lower than 768px (iPad portrait)
- const shouldAdjustFontSize = window.innerWidth < 768;
-
- buildBarChart(chartTimesData, shouldAdjustFontSize);
-
- chartsData.forEach(scope => buildChart(scope, shouldAdjustFontSize));
-});
+document.addEventListener('DOMContentLoaded', initProjectPipelinesChartsApp);
diff --git a/app/assets/javascripts/pages/projects/pipelines/init_pipelines.js b/app/assets/javascripts/pages/projects/pipelines/init_pipelines.js
index ba4ae04ab3d..ade6908c4a5 100644
--- a/app/assets/javascripts/pages/projects/pipelines/init_pipelines.js
+++ b/app/assets/javascripts/pages/projects/pipelines/init_pipelines.js
@@ -1,6 +1,18 @@
import Pipelines from '~/pipelines';
export default () => {
+ const mergeRequestListToggle = document.querySelector('.js-toggle-mr-list');
+ const truncatedMergeRequestList = document.querySelector('.js-truncated-mr-list');
+ const fullMergeRequestList = document.querySelector('.js-full-mr-list');
+
+ if (mergeRequestListToggle) {
+ mergeRequestListToggle.addEventListener('click', e => {
+ e.preventDefault();
+ truncatedMergeRequestList.classList.toggle('hide');
+ fullMergeRequestList.classList.toggle('hide');
+ });
+ }
+
const { controllerAction } = document.querySelector('.js-pipeline-container').dataset;
const pipelineStatusUrl = `${document
.querySelector('.js-pipeline-tab-link a')
diff --git a/app/assets/javascripts/pages/projects/registry/repositories/index.js b/app/assets/javascripts/pages/projects/registry/repositories/index.js
index 59310b3f76f..47fea2be189 100644
--- a/app/assets/javascripts/pages/projects/registry/repositories/index.js
+++ b/app/assets/javascripts/pages/projects/registry/repositories/index.js
@@ -1,3 +1,9 @@
import initRegistryImages from '~/registry/list/index';
+import registryExplorer from '~/registry/explorer/index';
-document.addEventListener('DOMContentLoaded', initRegistryImages);
+document.addEventListener('DOMContentLoaded', () => {
+ initRegistryImages();
+ const { attachMainComponent, attachBreadcrumb } = registryExplorer();
+ attachBreadcrumb();
+ attachMainComponent();
+});
diff --git a/app/assets/javascripts/pages/projects/releases/edit/index.js b/app/assets/javascripts/pages/projects/releases/edit/index.js
index 98ec196fc37..efa059dcd6d 100644
--- a/app/assets/javascripts/pages/projects/releases/edit/index.js
+++ b/app/assets/javascripts/pages/projects/releases/edit/index.js
@@ -1,5 +1,5 @@
import ZenMode from '~/zen_mode';
-import initEditRelease from '~/releases/detail';
+import initEditRelease from '~/releases/mount_edit';
document.addEventListener('DOMContentLoaded', () => {
new ZenMode(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/releases/index/index.js b/app/assets/javascripts/pages/projects/releases/index/index.js
index 6402023149f..24c9cd528b3 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/list';
+import initReleases from '~/releases/mount_index';
document.addEventListener('DOMContentLoaded', initReleases);
diff --git a/app/assets/javascripts/pages/projects/services/edit/index.js b/app/assets/javascripts/pages/projects/services/edit/index.js
index ba4b271f09e..2d77f2686f7 100644
--- a/app/assets/javascripts/pages/projects/services/edit/index.js
+++ b/app/assets/javascripts/pages/projects/services/edit/index.js
@@ -1,5 +1,6 @@
import IntegrationSettingsForm from '~/integrations/integration_settings_form';
import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics';
+import initAlertsSettings from '~/alerts_service_settings';
document.addEventListener('DOMContentLoaded', () => {
const prometheusSettingsWrapper = document.querySelector('.js-prometheus-metrics-monitoring');
@@ -10,4 +11,6 @@ document.addEventListener('DOMContentLoaded', () => {
const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring');
prometheusMetrics.loadActiveMetrics();
}
+
+ initAlertsSettings(document.querySelector('.js-alerts-service-settings'));
});
diff --git a/app/assets/javascripts/pages/projects/wikis/wikis.js b/app/assets/javascripts/pages/projects/wikis/wikis.js
index 80b62859134..6b02a074abf 100644
--- a/app/assets/javascripts/pages/projects/wikis/wikis.js
+++ b/app/assets/javascripts/pages/projects/wikis/wikis.js
@@ -40,7 +40,7 @@ export default class Wikis {
// Replace hyphens with spaces
if (title) title = title.replace(/-+/g, ' ');
- const newCommitMessage = sprintf(this.commitMessageI18n, { pageTitle: title });
+ const newCommitMessage = sprintf(this.commitMessageI18n, { pageTitle: title }, false);
this.commitMessageInput.value = newCommitMessage;
}
diff --git a/app/assets/javascripts/pages/registrations/welcome/index.js b/app/assets/javascripts/pages/registrations/welcome/index.js
deleted file mode 100644
index 2d555fa7977..00000000000
--- a/app/assets/javascripts/pages/registrations/welcome/index.js
+++ /dev/null
@@ -1,7 +0,0 @@
-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/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
index 693125f8a38..4f645e511f9 100644
--- a/app/assets/javascripts/pages/users/activity_calendar.js
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -18,7 +18,7 @@ const firstDayOfWeekChoices = Object.freeze({
const LOADING_HTML = `
<div class="text-center">
- <i class="fa fa-spinner fa-spin user-calendar-activities-loading"></i>
+ <div class="spinner spinner-md"></div>
</div>
`;
diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js
index 4ac4efec45d..dafd800099c 100644
--- a/app/assets/javascripts/pages/users/user_tabs.js
+++ b/app/assets/javascripts/pages/users/user_tabs.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import axios from '~/lib/utils/axios_utils';
import Activities from '~/activities';
import { localTimeAgo } from '~/lib/utils/datetime_utility';
@@ -56,10 +57,8 @@ import UserOverviewBlock from './user_overview_block';
* </div>
* </div>
*
- * <div class="loading-status">
- * <div class="loading">
- * Loading Animation
- * </div>
+ * <div class="loading">
+ * Loading Animation
* </div>
*/
@@ -209,7 +208,7 @@ export default class UserTabs {
loadActivityCalendar() {
const $calendarWrap = this.$parentEl.find('.tab-pane.active .user-calendar');
- if (!$calendarWrap.length) return;
+ if (!$calendarWrap.length || bp.getBreakpointSize() === 'xs') return;
const calendarPath = $calendarWrap.data('calendarPath');
@@ -241,7 +240,7 @@ export default class UserTabs {
}
toggleLoading(status) {
- return this.$parentEl.find('.loading-status .loading').toggleClass('hide', !status);
+ return this.$parentEl.find('.loading').toggleClass('hide', !status);
}
setCurrentAction(source) {
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 4dc6e51d2fc..6a836adba01 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -1,5 +1,4 @@
<script>
-import _ from 'underscore';
import { GlLoadingIcon } from '@gitlab/ui';
import StageColumnComponent from './stage_column_component.vue';
import GraphMixin from '../../mixins/graph_component_mixin';
@@ -70,7 +69,7 @@ export default {
expandedTriggeredBy() {
return (
this.pipeline.triggered_by &&
- _.isArray(this.pipeline.triggered_by) &&
+ Array.isArray(this.pipeline.triggered_by) &&
this.pipeline.triggered_by.find(el => el.isExpanded)
);
},
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 db7714808fd..3d3dabbdf22 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -1,5 +1,5 @@
<script>
-import _ from 'underscore';
+import { isEmpty, escape as esc } from 'lodash';
import stageColumnMixin from '../../mixins/stage_column_mixin';
import JobItem from './job_item.vue';
import JobGroupDropdown from './job_group_dropdown.vue';
@@ -39,12 +39,12 @@ export default {
},
computed: {
hasAction() {
- return !_.isEmpty(this.action);
+ return !isEmpty(this.action);
},
},
methods: {
groupId(group) {
- return `ci-badge-${_.escape(group.name)}`;
+ return `ci-badge-${esc(group.name)}`;
},
pipelineActionRequestComplete() {
this.$emit('refreshPipelineGraph');
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index 726bba7f9f4..2a3d022c5cd 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -1,6 +1,7 @@
<script>
-import { GlLoadingIcon, GlModal } from '@gitlab/ui';
-import ciHeader from '../../vue_shared/components/header_ci_component.vue';
+import { GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui';
+import ciHeader from '~/vue_shared/components/header_ci_component.vue';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
import eventHub from '../event_hub';
import { __ } from '~/locale';
@@ -12,6 +13,10 @@ export default {
ciHeader,
GlLoadingIcon,
GlModal,
+ LoadingButton,
+ },
+ directives: {
+ GlModal: GlModalDirective,
},
props: {
pipeline: {
@@ -25,7 +30,9 @@ export default {
},
data() {
return {
- actions: this.getActions(),
+ isCanceling: false,
+ isRetrying: false,
+ isDeleting: false,
};
},
@@ -43,67 +50,18 @@ export default {
},
},
- watch: {
- pipeline() {
- this.actions = this.getActions();
- },
- },
-
methods: {
- onActionClicked(action) {
- if (action.modal) {
- this.$root.$emit('bv::show::modal', action.modal);
- } else {
- this.postAction(action);
- }
+ cancelPipeline() {
+ this.isCanceling = true;
+ eventHub.$emit('headerPostAction', this.pipeline.cancel_path);
},
- postAction(action) {
- const index = this.actions.indexOf(action);
-
- this.$set(this.actions[index], 'isLoading', true);
-
- eventHub.$emit('headerPostAction', action);
+ retryPipeline() {
+ this.isRetrying = true;
+ eventHub.$emit('headerPostAction', this.pipeline.retry_path);
},
deletePipeline() {
- const index = this.actions.findIndex(action => action.modal === DELETE_MODAL_ID);
-
- this.$set(this.actions[index], 'isLoading', true);
-
- eventHub.$emit('headerDeleteAction', this.actions[index]);
- },
-
- getActions() {
- const actions = [];
-
- if (this.pipeline.retry_path) {
- actions.push({
- label: __('Retry'),
- path: this.pipeline.retry_path,
- cssClass: 'js-retry-button btn btn-inverted-secondary',
- isLoading: false,
- });
- }
-
- if (this.pipeline.cancel_path) {
- actions.push({
- label: __('Cancel running'),
- path: this.pipeline.cancel_path,
- cssClass: 'js-btn-cancel-pipeline btn btn-danger',
- isLoading: false,
- });
- }
-
- if (this.pipeline.delete_path) {
- actions.push({
- label: __('Delete'),
- path: this.pipeline.delete_path,
- modal: DELETE_MODAL_ID,
- cssClass: 'js-btn-delete-pipeline btn btn-danger btn-inverted',
- isLoading: false,
- });
- }
-
- return actions;
+ this.isDeleting = true;
+ eventHub.$emit('headerDeleteAction', this.pipeline.delete_path);
},
},
DELETE_MODAL_ID,
@@ -117,10 +75,38 @@ export default {
:item-id="pipeline.id"
:time="pipeline.created_at"
:user="pipeline.user"
- :actions="actions"
item-name="Pipeline"
- @actionClicked="onActionClicked"
- />
+ >
+ <loading-button
+ v-if="pipeline.retry_path"
+ :loading="isRetrying"
+ :disabled="isRetrying"
+ class="js-retry-button btn btn-inverted-secondary"
+ container-class="d-inline"
+ :label="__('Retry')"
+ @click="retryPipeline()"
+ />
+
+ <loading-button
+ v-if="pipeline.cancel_path"
+ :loading="isCanceling"
+ :disabled="isCanceling"
+ class="js-btn-cancel-pipeline btn btn-danger"
+ container-class="d-inline"
+ :label="__('Cancel running')"
+ @click="cancelPipeline()"
+ />
+
+ <loading-button
+ v-if="pipeline.delete_path"
+ v-gl-modal="$options.DELETE_MODAL_ID"
+ :loading="isDeleting"
+ :disabled="isDeleting"
+ class="js-btn-delete-pipeline btn btn-danger btn-inverted"
+ container-class="d-inline"
+ :label="__('Delete')"
+ />
+ </ci-header>
<gl-loading-icon v-if="isLoading" :size="2" class="prepend-top-default append-bottom-default" />
diff --git a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue
index 6ca96bbba5e..f604edd8859 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue
@@ -1,5 +1,5 @@
<script>
-import _ from 'underscore';
+import { isEmpty } from 'lodash';
import { GlLink } from '@gitlab/ui';
import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
@@ -43,7 +43,7 @@ export default {
);
},
hasRef() {
- return !_.isEmpty(this.pipeline.ref);
+ return !isEmpty(this.pipeline.ref);
},
},
methods: {
diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue
index 743c3ea271d..0c9d242f509 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue
@@ -1,11 +1,11 @@
<script>
import { GlLink, GlTooltipDirective } from '@gitlab/ui';
-import _ from 'underscore';
+import { escape } from 'lodash';
import { __, sprintf } from '~/locale';
import popover from '~/vue_shared/directives/popover';
const popoverTitle = sprintf(
- _.escape(
+ escape(
__(
`This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}`,
),
@@ -49,7 +49,7 @@ export default {
href="${this.autoDevopsHelpPath}"
target="_blank"
rel="noopener noreferrer nofollow">
- ${_.escape(__('Learn more about Auto DevOps'))}
+ ${escape(__('Learn more about Auto DevOps'))}
</a>`,
};
},
diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue
index d730ef41b1a..accd6bf71f4 100644
--- a/app/assets/javascripts/pipelines/components/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines.vue
@@ -1,5 +1,5 @@
<script>
-import _ from 'underscore';
+import { isEqual } from 'lodash';
import { __, sprintf, s__ } from '../../locale';
import createFlash from '../../flash';
import PipelinesService from '../services/pipelines_service';
@@ -218,7 +218,7 @@ export default {
successCallback(resp) {
// Because we are polling & the user is interacting verify if the response received
// matches the last request made
- if (_.isEqual(resp.config.params, this.requestData)) {
+ if (isEqual(resp.config.params, this.requestData)) {
this.store.storeCount(resp.data.count);
this.store.storePagination(resp.headers);
this.setCommonData(resp.data.pipelines);
diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
index afb8439511f..e25f8ab4790 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
@@ -75,9 +75,9 @@ export default {
* This field needs a lot of verification, because of different possible cases:
*
* 1. person who is an author of a commit might be a GitLab user
- * 2. if person who is an author of a commit is a GitLab user he/she can have a GitLab avatar
- * 3. If GitLab user does not have avatar he/she might have a Gravatar
- * 4. If committer is not a GitLab User he/she can have a Gravatar
+ * 2. if person who is an author of a commit is a GitLab user, they can have a GitLab avatar
+ * 3. If GitLab user does not have avatar they might have a Gravatar
+ * 4. If committer is not a GitLab User they can have a Gravatar
* 5. We do not have consistent API object in this case
* 6. We should improve API and the code
*
@@ -93,17 +93,17 @@ export default {
// 1. person who is an author of a commit might be a GitLab user
if (this.pipeline.commit.author) {
// 2. if person who is an author of a commit is a GitLab user
- // he/she can have a GitLab avatar
+ // they can have a GitLab avatar
if (this.pipeline.commit.author.avatar_url) {
commitAuthorInformation = this.pipeline.commit.author;
- // 3. If GitLab user does not have avatar he/she might have a Gravatar
+ // 3. If GitLab user does not have avatar, they might have a Gravatar
} else if (this.pipeline.commit.author_gravatar_url) {
commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, {
avatar_url: this.pipeline.commit.author_gravatar_url,
});
}
- // 4. If committer is not a GitLab User he/she can have a Gravatar
+ // 4. If committer is not a GitLab User, they can have a Gravatar
} else {
commitAuthorInformation = {
avatar_url: this.pipeline.commit.author_gravatar_url,
@@ -331,6 +331,7 @@ export default {
:loading="isRetrying"
:disabled="isRetrying"
container-class="js-pipelines-retry-button btn btn-default btn-retry"
+ data-qa-selector="pipeline_retry_button"
@click="handleRetryClick"
>
<icon name="repeat" />
diff --git a/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js
index f383a4b3368..53b7a174517 100644
--- a/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js
+++ b/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js
@@ -1,4 +1,4 @@
-import _ from 'underscore';
+import { escape } from 'lodash';
export default {
props: {
@@ -18,7 +18,7 @@ export default {
},
methods: {
capitalizeStageName(name) {
- const escapedName = _.escape(name);
+ const escapedName = escape(name);
return escapedName.charAt(0).toUpperCase() + escapedName.slice(1);
},
isFirstColumn(index) {
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 c76869d90d5..1d9366f26df 100644
--- a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js
+++ b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js
@@ -59,7 +59,7 @@ export default {
},
requestRefreshPipelineGraph() {
// When an action is clicked
- // (wether in the dropdown or in the main nodes, we refresh the big graph)
+ // (whether 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.')));
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index c874c4c6fdd..d9192d3d76b 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -10,6 +10,7 @@ import pipelineHeader from './components/header_component.vue';
import eventHub from './event_hub';
import TestReports from './components/test_reports/test_reports.vue';
import testReportsStore from './stores/test_reports';
+import axios from '~/lib/utils/axios_utils';
Vue.use(Translate);
@@ -70,16 +71,16 @@ export default () => {
eventHub.$off('headerDeleteAction', this.deleteAction);
},
methods: {
- postAction(action) {
+ postAction(path) {
this.mediator.service
- .postAction(action.path)
+ .postAction(path)
.then(() => this.mediator.refreshPipeline())
.catch(() => Flash(__('An error occurred while making the request.')));
},
- deleteAction(action) {
+ deleteAction(path) {
this.mediator.stopPipelinePoll();
this.mediator.service
- .deleteAction(action.path)
+ .deleteAction(path)
.then(({ request }) => redirectTo(setUrlFragment(request.responseURL, 'delete_success')))
.catch(() => Flash(__('An error occurred while deleting the pipeline.')));
},
@@ -98,8 +99,26 @@ export default () => {
window.gon && window.gon.features && window.gon.features.junitPipelineView;
if (testReportsEnabled) {
+ const fetchReportsAction = 'fetchReports';
testReportsStore.dispatch('setEndpoint', dataset.testReportEndpoint);
- testReportsStore.dispatch('fetchReports');
+
+ const tabsElmement = document.querySelector('.pipelines-tabs');
+ const isTestTabActive = Boolean(
+ document.querySelector('.pipelines-tabs > li > a.test-tab.active'),
+ );
+
+ if (isTestTabActive) {
+ testReportsStore.dispatch(fetchReportsAction);
+ } else {
+ const tabClickHandler = e => {
+ if (e.target.className === 'test-tab') {
+ testReportsStore.dispatch(fetchReportsAction);
+ tabsElmement.removeEventListener('click', tabClickHandler);
+ }
+ };
+
+ tabsElmement.addEventListener('click', tabClickHandler);
+ }
// eslint-disable-next-line no-new
new Vue({
@@ -111,5 +130,12 @@ export default () => {
return createElement('test-reports');
},
});
+
+ axios
+ .get(dataset.testReportsCountEndpoint)
+ .then(({ data }) => {
+ document.querySelector('.js-test-report-badge-counter').innerHTML = data.total_count;
+ })
+ .catch(() => {});
}
};
diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js
index 441c9f3c25f..69e3579a3c7 100644
--- a/app/assets/javascripts/pipelines/stores/pipeline_store.js
+++ b/app/assets/javascripts/pipelines/stores/pipeline_store.js
@@ -1,5 +1,4 @@
import Vue from 'vue';
-import _ from 'underscore';
export default class PipelineStore {
constructor() {
@@ -61,7 +60,7 @@ export default class PipelineStore {
Vue.set(newPipeline, 'isLoading', false);
if (newPipeline.triggered_by) {
- if (!_.isArray(newPipeline.triggered_by)) {
+ if (!Array.isArray(newPipeline.triggered_by)) {
Object.assign(newPipeline, { triggered_by: [newPipeline.triggered_by] });
}
this.parseTriggeredByPipelines(oldPipeline, newPipeline.triggered_by[0]);
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
new file mode 100644
index 00000000000..4dc1c512689
--- /dev/null
+++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
@@ -0,0 +1,145 @@
+<script>
+import dateFormat from 'dateformat';
+import { __, sprintf } from '~/locale';
+import { GlColumnChart } from '@gitlab/ui/dist/charts';
+import { getDateInPast } from '~/lib/utils/datetime_utility';
+import StatisticsList from './statistics_list.vue';
+import PipelinesAreaChart from './pipelines_area_chart.vue';
+import {
+ CHART_CONTAINER_HEIGHT,
+ INNER_CHART_HEIGHT,
+ X_AXIS_LABEL_ROTATION,
+ X_AXIS_TITLE_OFFSET,
+ CHART_DATE_FORMAT,
+ ONE_WEEK_AGO_DAYS,
+ ONE_MONTH_AGO_DAYS,
+} from '../constants';
+
+export default {
+ components: {
+ StatisticsList,
+ GlColumnChart,
+ PipelinesAreaChart,
+ },
+ props: {
+ counts: {
+ type: Object,
+ required: true,
+ },
+ timesChartData: {
+ type: Object,
+ required: true,
+ },
+ lastWeekChartData: {
+ type: Object,
+ required: true,
+ },
+ lastMonthChartData: {
+ type: Object,
+ required: true,
+ },
+ lastYearChartData: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ timesChartTransformedData: {
+ full: this.mergeLabelsAndValues(this.timesChartData.labels, this.timesChartData.values),
+ },
+ };
+ },
+ computed: {
+ areaCharts() {
+ const { lastWeek, lastMonth, lastYear } = this.$options.chartTitles;
+
+ return [
+ this.buildAreaChartData(lastWeek, this.lastWeekChartData),
+ this.buildAreaChartData(lastMonth, this.lastMonthChartData),
+ this.buildAreaChartData(lastYear, this.lastYearChartData),
+ ];
+ },
+ },
+ methods: {
+ mergeLabelsAndValues(labels, values) {
+ return labels.map((label, index) => [label, values[index]]);
+ },
+ buildAreaChartData(title, data) {
+ const { labels, totals, success } = data;
+
+ return {
+ title,
+ data: [
+ {
+ name: 'all',
+ data: this.mergeLabelsAndValues(labels, totals),
+ },
+ {
+ name: 'success',
+ data: this.mergeLabelsAndValues(labels, success),
+ },
+ ],
+ };
+ },
+ },
+ chartContainerHeight: CHART_CONTAINER_HEIGHT,
+ timesChartOptions: {
+ height: INNER_CHART_HEIGHT,
+ xAxis: {
+ axisLabel: {
+ rotate: X_AXIS_LABEL_ROTATION,
+ },
+ nameGap: X_AXIS_TITLE_OFFSET,
+ },
+ },
+ get chartTitles() {
+ const today = dateFormat(new Date(), CHART_DATE_FORMAT);
+ const pastDate = timeScale =>
+ dateFormat(getDateInPast(new Date(), timeScale), CHART_DATE_FORMAT);
+ return {
+ lastWeek: sprintf(__('Pipelines for last week (%{oneWeekAgo} - %{today})'), {
+ oneWeekAgo: pastDate(ONE_WEEK_AGO_DAYS),
+ today,
+ }),
+ lastMonth: sprintf(__('Pipelines for last month (%{oneMonthAgo} - %{today})'), {
+ oneMonthAgo: pastDate(ONE_MONTH_AGO_DAYS),
+ today,
+ }),
+ lastYear: __('Pipelines for last year'),
+ };
+ },
+};
+</script>
+<template>
+ <div>
+ <h4 class="my-4">{{ s__('PipelineCharts|Overall statistics') }}</h4>
+ <div class="row">
+ <div class="col-md-6">
+ <statistics-list :counts="counts" />
+ </div>
+ <div class="col-md-6">
+ <strong>
+ {{ __('Duration for the last 30 commits') }}
+ </strong>
+ <gl-column-chart
+ :height="$options.chartContainerHeight"
+ :option="$options.timesChartOptions"
+ :data="timesChartTransformedData"
+ :y-axis-title="__('Minutes')"
+ :x-axis-title="__('Commit')"
+ x-axis-type="category"
+ />
+ </div>
+ </div>
+ <hr />
+ <h4 class="my-4">{{ __('Pipelines charts') }}</h4>
+ <pipelines-area-chart
+ v-for="(chart, index) in areaCharts"
+ :key="index"
+ :chart-data="chart.data"
+ >
+ {{ chart.title }}
+ </pipelines-area-chart>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipelines_area_chart.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipelines_area_chart.vue
new file mode 100644
index 00000000000..d701f238a2e
--- /dev/null
+++ b/app/assets/javascripts/projects/pipelines/charts/components/pipelines_area_chart.vue
@@ -0,0 +1,46 @@
+<script>
+import { GlAreaChart } from '@gitlab/ui/dist/charts';
+import { s__ } from '~/locale';
+import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
+import { CHART_CONTAINER_HEIGHT } from '../constants';
+
+export default {
+ components: {
+ GlAreaChart,
+ ResizableChartContainer,
+ },
+ props: {
+ chartData: {
+ type: Array,
+ required: true,
+ },
+ },
+ areaChartOptions: {
+ xAxis: {
+ name: s__('Pipeline|Date'),
+ type: 'category',
+ },
+ yAxis: {
+ name: s__('Pipeline|Pipelines'),
+ },
+ },
+ chartContainerHeight: CHART_CONTAINER_HEIGHT,
+};
+</script>
+<template>
+ <div class="prepend-top-default">
+ <p>
+ <slot></slot>
+ </p>
+ <resizable-chart-container>
+ <gl-area-chart
+ slot-scope="{ width }"
+ :width="width"
+ :height="$options.chartContainerHeight"
+ :data="chartData"
+ :include-legend-avg-max="false"
+ :option="$options.areaChartOptions"
+ />
+ </resizable-chart-container>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue b/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue
new file mode 100644
index 00000000000..cd9e464c5ac
--- /dev/null
+++ b/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue
@@ -0,0 +1,30 @@
+<script>
+export default {
+ props: {
+ counts: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <ul>
+ <li>
+ <span>{{ s__('PipelineCharts|Total:') }}</span>
+ <strong>{{ n__('1 pipeline', '%d pipelines', counts.total) }}</strong>
+ </li>
+ <li>
+ <span>{{ s__('PipelineCharts|Successful:') }}</span>
+ <strong>{{ n__('1 pipeline', '%d pipelines', counts.success) }}</strong>
+ </li>
+ <li>
+ <span>{{ s__('PipelineCharts|Failed:') }}</span>
+ <strong>{{ n__('1 pipeline', '%d pipelines', counts.failed) }}</strong>
+ </li>
+ <li>
+ <span>{{ s__('PipelineCharts|Success ratio:') }}</span>
+ <strong>{{ counts.successRatio }}%</strong>
+ </li>
+ </ul>
+</template>
diff --git a/app/assets/javascripts/projects/pipelines/charts/constants.js b/app/assets/javascripts/projects/pipelines/charts/constants.js
new file mode 100644
index 00000000000..5dbe3c01100
--- /dev/null
+++ b/app/assets/javascripts/projects/pipelines/charts/constants.js
@@ -0,0 +1,13 @@
+export const CHART_CONTAINER_HEIGHT = 300;
+
+export const INNER_CHART_HEIGHT = 200;
+
+export const X_AXIS_LABEL_ROTATION = 45;
+
+export const X_AXIS_TITLE_OFFSET = 60;
+
+export const ONE_WEEK_AGO_DAYS = 7;
+
+export const ONE_MONTH_AGO_DAYS = 31;
+
+export const CHART_DATE_FORMAT = 'dd mmm';
diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js
new file mode 100644
index 00000000000..4ae2b729200
--- /dev/null
+++ b/app/assets/javascripts/projects/pipelines/charts/index.js
@@ -0,0 +1,67 @@
+import Vue from 'vue';
+import ProjectPipelinesCharts from './components/app.vue';
+
+export default () => {
+ const el = document.querySelector('#js-project-pipelines-charts-app');
+ const {
+ countsFailed,
+ countsSuccess,
+ countsTotal,
+ successRatio,
+ timesChartLabels,
+ timesChartValues,
+ lastWeekChartLabels,
+ lastWeekChartTotals,
+ lastWeekChartSuccess,
+ lastMonthChartLabels,
+ lastMonthChartTotals,
+ lastMonthChartSuccess,
+ lastYearChartLabels,
+ lastYearChartTotals,
+ lastYearChartSuccess,
+ } = el.dataset;
+
+ const parseAreaChartData = (labels, totals, success) => ({
+ labels: JSON.parse(labels),
+ totals: JSON.parse(totals),
+ success: JSON.parse(success),
+ });
+
+ return new Vue({
+ el,
+ name: 'ProjectPipelinesChartsApp',
+ components: {
+ ProjectPipelinesCharts,
+ },
+ render: createElement =>
+ createElement(ProjectPipelinesCharts, {
+ props: {
+ counts: {
+ failed: countsFailed,
+ success: countsSuccess,
+ total: countsTotal,
+ successRatio,
+ },
+ timesChartData: {
+ labels: JSON.parse(timesChartLabels),
+ values: JSON.parse(timesChartValues),
+ },
+ lastWeekChartData: parseAreaChartData(
+ lastWeekChartLabels,
+ lastWeekChartTotals,
+ lastWeekChartSuccess,
+ ),
+ lastMonthChartData: parseAreaChartData(
+ lastMonthChartLabels,
+ lastMonthChartTotals,
+ lastMonthChartSuccess,
+ ),
+ lastYearChartData: parseAreaChartData(
+ lastYearChartLabels,
+ lastYearChartTotals,
+ lastYearChartSuccess,
+ ),
+ },
+ }),
+ });
+};
diff --git a/app/assets/javascripts/registry/explorer/components/group_empty_state.vue b/app/assets/javascripts/registry/explorer/components/group_empty_state.vue
new file mode 100644
index 00000000000..a29a9bd23c3
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/group_empty_state.vue
@@ -0,0 +1,39 @@
+<script>
+import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
+import { mapState } from 'vuex';
+
+export default {
+ name: 'GroupEmptyState',
+ components: {
+ GlEmptyState,
+ GlSprintf,
+ GlLink,
+ },
+ computed: {
+ ...mapState(['config']),
+ },
+};
+</script>
+<template>
+ <gl-empty-state
+ :title="s__('ContainerRegistry|There are no container images available in this group')"
+ :svg-path="config.noContainersImage"
+ class="container-message"
+ >
+ <template #description>
+ <p class="js-no-container-images-text">
+ <gl-sprintf
+ :message="
+ 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}`,
+ )
+ "
+ >
+ <template #docLink="{content}">
+ <gl-link :href="config.helpPagePath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/components/project_empty_state.vue b/app/assets/javascripts/registry/explorer/components/project_empty_state.vue
new file mode 100644
index 00000000000..53853b4b9fb
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/project_empty_state.vue
@@ -0,0 +1,113 @@
+<script>
+import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { mapState } from 'vuex';
+
+export default {
+ name: 'ProjectEmptyState',
+ components: {
+ ClipboardButton,
+ GlEmptyState,
+ GlSprintf,
+ GlLink,
+ },
+ computed: {
+ ...mapState(['config']),
+ dockerBuildCommand() {
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
+ return `docker build -t ${this.config.repositoryUrl} .`;
+ },
+ dockerPushCommand() {
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
+ return `docker push ${this.config.repositoryUrl}`;
+ },
+ dockerLoginCommand() {
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
+ return `docker login ${this.config.registryHostUrlWithPort}`;
+ },
+ },
+};
+</script>
+<template>
+ <gl-empty-state
+ :title="s__('ContainerRegistry|There are no container images stored for this project')"
+ :svg-path="config.noContainersImage"
+ class="container-message"
+ >
+ <template #description>
+ <p class="js-no-container-images-text">
+ <gl-sprintf
+ :message="
+ s__(`ContainerRegistry|With the Container Registry, every project can have its own space to
+ store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`)
+ "
+ >
+ <template #docLink="{content}">
+ <gl-link :href="config.helpPagePath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <h5>{{ s__('ContainerRegistry|Quick Start') }}</h5>
+ <p class="js-not-logged-in-to-registry-text">
+ <gl-sprintf
+ :message="
+ 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.`)
+ "
+ >
+ <template #twofaDocLink="{content}">
+ <gl-link :href="config.twoFactorAuthHelpLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ <template #personalAccessTokensDocLink="{content}">
+ <gl-link :href="config.personalAccessTokensHelpLink" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </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/explorer/components/registry_breadcrumb.vue b/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue
new file mode 100644
index 00000000000..f51948da8cc
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue
@@ -0,0 +1,59 @@
+<script>
+import { initial, first, last } from 'lodash';
+
+export default {
+ props: {
+ crumbs: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ rootRoute() {
+ return this.$router.options.routes.find(r => r.meta.root);
+ },
+ isRootRoute() {
+ return this.$route.name === this.rootRoute.name;
+ },
+ rootCrumbs() {
+ return initial(this.crumbs);
+ },
+ divider() {
+ const { classList, tagName, innerHTML } = first(this.crumbs).querySelector('svg');
+ return { classList: [...classList], tagName, innerHTML };
+ },
+ lastCrumb() {
+ const { children } = last(this.crumbs);
+ const { tagName, classList } = first(children);
+ return {
+ tagName,
+ classList: [...classList],
+ text: this.$route.meta.nameGenerator(this.$route),
+ path: { to: this.$route.name },
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <ul>
+ <li
+ v-for="(crumb, index) in rootCrumbs"
+ :key="index"
+ :class="crumb.classList"
+ v-html="crumb.innerHTML"
+ ></li>
+ <li v-if="!isRootRoute">
+ <router-link ref="rootRouteLink" :to="rootRoute.path">
+ {{ rootRoute.meta.nameGenerator(rootRoute) }}
+ </router-link>
+ <component :is="divider.tagName" :class="divider.classList" v-html="divider.innerHTML" />
+ </li>
+ <li>
+ <component :is="lastCrumb.tagName" ref="lastCrumb" :class="lastCrumb.classList">
+ <router-link ref="childRouteLink" :to="lastCrumb.path">{{ lastCrumb.text }}</router-link>
+ </component>
+ </li>
+ </ul>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/constants.js b/app/assets/javascripts/registry/explorer/constants.js
new file mode 100644
index 00000000000..bb311157627
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/constants.js
@@ -0,0 +1,32 @@
+import { __ } from '~/locale';
+
+export const FETCH_IMAGES_LIST_ERROR_MESSAGE = __(
+ 'Something went wrong while fetching the packages list.',
+);
+export const FETCH_TAGS_LIST_ERROR_MESSAGE = __(
+ 'Something went wrong while fetching the tags list.',
+);
+
+export const DELETE_IMAGE_ERROR_MESSAGE = __('Something went wrong while deleting the image.');
+export const DELETE_IMAGE_SUCCESS_MESSAGE = __('Image deleted successfully');
+export const DELETE_TAG_ERROR_MESSAGE = __('Something went wrong while deleting the tag.');
+export const DELETE_TAG_SUCCESS_MESSAGE = __('Tag deleted successfully');
+export const DELETE_TAGS_ERROR_MESSAGE = __('Something went wrong while deleting the tags.');
+export const DELETE_TAGS_SUCCESS_MESSAGE = __('Tags deleted successfully');
+
+export const DEFAULT_PAGE = 1;
+export const DEFAULT_PAGE_SIZE = 10;
+
+export const GROUP_PAGE_TYPE = 'groups';
+
+export const LIST_KEY_TAG = 'name';
+export const LIST_KEY_IMAGE_ID = 'short_revision';
+export const LIST_KEY_SIZE = 'total_size';
+export const LIST_KEY_LAST_UPDATED = 'created_at';
+export const LIST_KEY_ACTIONS = 'actions';
+export const LIST_KEY_CHECKBOX = 'checkbox';
+
+export const LIST_LABEL_TAG = __('Tag');
+export const LIST_LABEL_IMAGE_ID = __('Image ID');
+export const LIST_LABEL_SIZE = __('Size');
+export const LIST_LABEL_LAST_UPDATED = __('Last Updated');
diff --git a/app/assets/javascripts/registry/explorer/index.js b/app/assets/javascripts/registry/explorer/index.js
new file mode 100644
index 00000000000..a36978303c6
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/index.js
@@ -0,0 +1,58 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import RegistryExplorer from './pages/index.vue';
+import RegistryBreadcrumb from './components/registry_breadcrumb.vue';
+import { createStore } from './stores';
+import createRouter from './router';
+
+Vue.use(Translate);
+
+export default () => {
+ const el = document.getElementById('js-container-registry');
+
+ if (!el) {
+ return null;
+ }
+
+ const { endpoint } = el.dataset;
+
+ const store = createStore();
+ const router = createRouter(endpoint, store);
+ store.dispatch('setInitialState', el.dataset);
+
+ const attachMainComponent = () =>
+ new Vue({
+ el,
+ store,
+ router,
+ components: {
+ RegistryExplorer,
+ },
+ render(createElement) {
+ return createElement('registry-explorer');
+ },
+ });
+
+ const attachBreadcrumb = () => {
+ const breadCrumbEl = document.querySelector('nav .js-breadcrumbs-list');
+ const crumbs = [...document.querySelectorAll('.js-breadcrumbs-list li')];
+ return new Vue({
+ el: breadCrumbEl,
+ store,
+ router,
+ components: {
+ RegistryBreadcrumb,
+ },
+ render(createElement) {
+ return createElement('registry-breadcrumb', {
+ class: breadCrumbEl.className,
+ props: {
+ crumbs,
+ },
+ });
+ },
+ });
+ };
+
+ return { attachBreadcrumb, attachMainComponent };
+};
diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue
new file mode 100644
index 00000000000..bc613db8672
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/pages/details.vue
@@ -0,0 +1,333 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import {
+ GlTable,
+ GlFormCheckbox,
+ GlButton,
+ GlIcon,
+ GlTooltipDirective,
+ GlPagination,
+ GlModal,
+ GlLoadingIcon,
+ GlSprintf,
+ GlEmptyState,
+ GlResizeObserverDirective,
+} from '@gitlab/ui';
+import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
+import { n__, s__ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import Tracking from '~/tracking';
+import { decodeAndParse } from '../utils';
+import {
+ LIST_KEY_TAG,
+ LIST_KEY_IMAGE_ID,
+ LIST_KEY_SIZE,
+ LIST_KEY_LAST_UPDATED,
+ LIST_KEY_ACTIONS,
+ LIST_KEY_CHECKBOX,
+ LIST_LABEL_TAG,
+ LIST_LABEL_IMAGE_ID,
+ LIST_LABEL_SIZE,
+ LIST_LABEL_LAST_UPDATED,
+} from '../constants';
+
+export default {
+ components: {
+ GlTable,
+ GlFormCheckbox,
+ GlButton,
+ GlIcon,
+ ClipboardButton,
+ GlPagination,
+ GlModal,
+ GlLoadingIcon,
+ GlSprintf,
+ GlEmptyState,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ GlResizeObserver: GlResizeObserverDirective,
+ },
+ mixins: [timeagoMixin, Tracking.mixin()],
+ data() {
+ return {
+ selectedItems: [],
+ itemsToBeDeleted: [],
+ selectAllChecked: false,
+ modalDescription: null,
+ isDesktop: true,
+ };
+ },
+ computed: {
+ ...mapState(['tags', 'tagsPagination', 'isLoading', 'config']),
+ imageName() {
+ const { name } = decodeAndParse(this.$route.params.id);
+ return name;
+ },
+ fields() {
+ return [
+ { key: LIST_KEY_CHECKBOX, label: '' },
+ { key: LIST_KEY_TAG, label: LIST_LABEL_TAG },
+ { key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID },
+ { key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE },
+ { key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED },
+ { key: LIST_KEY_ACTIONS, label: '' },
+ ].filter(f => f.key !== LIST_KEY_CHECKBOX || this.isDesktop);
+ },
+ isMultiDelete() {
+ return this.itemsToBeDeleted.length > 1;
+ },
+ tracking() {
+ return {
+ label: this.isMultiDelete ? 'bulk_registry_tag_delete' : 'registry_tag_delete',
+ };
+ },
+ modalAction() {
+ return n__(
+ 'ContainerRegistry|Remove tag',
+ 'ContainerRegistry|Remove tags',
+ this.isMultiDelete ? this.itemsToBeDeleted.length : 1,
+ );
+ },
+ currentPage: {
+ get() {
+ return this.tagsPagination.page;
+ },
+ set(page) {
+ this.requestTagsList({ pagination: { page }, id: this.$route.params.id });
+ },
+ },
+ },
+ methods: {
+ ...mapActions(['requestTagsList', 'requestDeleteTag', 'requestDeleteTags']),
+ setModalDescription(itemIndex = -1) {
+ if (itemIndex === -1) {
+ this.modalDescription = {
+ message: s__(`ContainerRegistry|You are about to remove %{item} tags. Are you sure?`),
+ item: this.itemsToBeDeleted.length,
+ };
+ } else {
+ const { path } = this.tags[itemIndex];
+
+ this.modalDescription = {
+ message: s__(`ContainerRegistry|You are about to remove %{item}. Are you sure?`),
+ item: path,
+ };
+ }
+ },
+ formatSize(size) {
+ return numberToHumanSize(size);
+ },
+ layers(layers) {
+ return layers ? n__('%d layer', '%d layers', layers) : '';
+ },
+ onSelectAllChange() {
+ if (this.selectAllChecked) {
+ this.deselectAll();
+ } else {
+ this.selectAll();
+ }
+ },
+ selectAll() {
+ this.selectedItems = this.tags.map((x, index) => index);
+ this.selectAllChecked = true;
+ },
+ deselectAll() {
+ this.selectedItems = [];
+ this.selectAllChecked = false;
+ },
+ updateSelectedItems(index) {
+ const delIndex = this.selectedItems.findIndex(x => x === index);
+
+ if (delIndex > -1) {
+ this.selectedItems.splice(delIndex, 1);
+ this.selectAllChecked = false;
+ } else {
+ this.selectedItems.push(index);
+
+ if (this.selectedItems.length === this.tags.length) {
+ this.selectAllChecked = true;
+ }
+ }
+ },
+ deleteSingleItem(index) {
+ this.setModalDescription(index);
+ this.itemsToBeDeleted = [index];
+ this.track('click_button');
+ this.$refs.deleteModal.show();
+ },
+ deleteMultipleItems() {
+ this.itemsToBeDeleted = [...this.selectedItems];
+ if (this.selectedItems.length === 1) {
+ this.setModalDescription(this.itemsToBeDeleted[0]);
+ } else if (this.selectedItems.length > 1) {
+ this.setModalDescription();
+ }
+ this.track('click_button');
+ this.$refs.deleteModal.show();
+ },
+ handleSingleDelete(itemToDelete) {
+ this.itemsToBeDeleted = [];
+ this.requestDeleteTag({ tag: itemToDelete, params: this.$route.params.id });
+ },
+ handleMultipleDelete() {
+ const { itemsToBeDeleted } = this;
+ this.itemsToBeDeleted = [];
+ this.selectedItems = [];
+
+ this.requestDeleteTags({
+ ids: itemsToBeDeleted.map(x => this.tags[x].name),
+ params: this.$route.params.id,
+ });
+ },
+ onDeletionConfirmed() {
+ this.track('confirm_delete');
+ if (this.isMultiDelete) {
+ this.handleMultipleDelete();
+ } else {
+ const index = this.itemsToBeDeleted[0];
+ this.handleSingleDelete(this.tags[index]);
+ }
+ },
+ handleResize() {
+ this.isDesktop = GlBreakpointInstance.isDesktop();
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ v-gl-resize-observer="handleResize"
+ class="my-3 position-absolute w-100 slide-enter-to-element"
+ >
+ <div class="d-flex my-3 align-items-center">
+ <h4>
+ <gl-sprintf :message="s__('ContainerRegistry|%{imageName} tags')">
+ <template #imageName>
+ {{ imageName }}
+ </template>
+ </gl-sprintf>
+ </h4>
+ </div>
+ <gl-loading-icon v-if="isLoading" />
+ <template v-else-if="tags.length > 0">
+ <gl-table :items="tags" :fields="fields" :stacked="!isDesktop">
+ <template v-if="isDesktop" #head(checkbox)>
+ <gl-form-checkbox
+ ref="mainCheckbox"
+ :checked="selectAllChecked"
+ @change="onSelectAllChange"
+ />
+ </template>
+ <template #head(actions)>
+ <gl-button
+ ref="bulkDeleteButton"
+ v-gl-tooltip
+ :disabled="!selectedItems || selectedItems.length === 0"
+ class="float-right"
+ variant="danger"
+ :title="s__('ContainerRegistry|Remove selected tags')"
+ :aria-label="s__('ContainerRegistry|Remove selected tags')"
+ @click="deleteMultipleItems()"
+ >
+ <gl-icon name="remove" />
+ </gl-button>
+ </template>
+
+ <template #cell(checkbox)="{index}">
+ <gl-form-checkbox
+ ref="rowCheckbox"
+ class="js-row-checkbox"
+ :checked="selectedItems.includes(index)"
+ @change="updateSelectedItems(index)"
+ />
+ </template>
+ <template #cell(name)="{item}">
+ <span ref="rowName">
+ {{ item.name }}
+ </span>
+ <clipboard-button
+ v-if="item.location"
+ ref="rowClipboardButton"
+ :title="item.location"
+ :text="item.location"
+ css-class="btn-default btn-transparent btn-clipboard"
+ />
+ </template>
+ <template #cell(short_revision)="{value}">
+ <span ref="rowShortRevision">
+ {{ value }}
+ </span>
+ </template>
+ <template #cell(total_size)="{item}">
+ <span ref="rowSize">
+ {{ formatSize(item.total_size) }}
+ <template v-if="item.total_size && item.layers">
+ &middot;
+ </template>
+ {{ layers(item.layers) }}
+ </span>
+ </template>
+ <template #cell(created_at)="{value}">
+ <span ref="rowTime">
+ {{ timeFormatted(value) }}
+ </span>
+ </template>
+ <template #cell(actions)="{index, item}">
+ <gl-button
+ ref="singleDeleteButton"
+ :title="s__('ContainerRegistry|Remove tag')"
+ :aria-label="s__('ContainerRegistry|Remove tag')"
+ :disabled="!item.destroy_path"
+ variant="danger"
+ :class="['js-delete-registry float-right btn-inverted btn-border-color btn-icon']"
+ @click="deleteSingleItem(index)"
+ >
+ <gl-icon name="remove" />
+ </gl-button>
+ </template>
+ </gl-table>
+ <gl-pagination
+ ref="pagination"
+ v-model="currentPage"
+ :per-page="tagsPagination.perPage"
+ :total-items="tagsPagination.total"
+ align="center"
+ class="w-100"
+ />
+ <gl-modal
+ ref="deleteModal"
+ modal-id="delete-tag-modal"
+ ok-variant="danger"
+ @ok="onDeletionConfirmed"
+ @cancel="track('cancel_delete')"
+ >
+ <template #modal-title>{{ modalAction }}</template>
+ <template #modal-ok>{{ modalAction }}</template>
+ <p v-if="modalDescription">
+ <gl-sprintf :message="modalDescription.message">
+ <template #item>
+ <b>{{ modalDescription.item }}</b>
+ </template>
+ </gl-sprintf>
+ </p>
+ </gl-modal>
+ </template>
+ <gl-empty-state
+ v-else
+ :title="s__('ContainerRegistry|This image has no active tags')"
+ :svg-path="config.noContainersImage"
+ :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>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/pages/index.vue b/app/assets/javascripts/registry/explorer/pages/index.vue
new file mode 100644
index 00000000000..deefbfc40e0
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/pages/index.vue
@@ -0,0 +1,11 @@
+<script>
+export default {};
+</script>
+
+<template>
+ <div class="position-relative">
+ <transition name="slide">
+ <router-view />
+ </transition>
+ </div>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue
new file mode 100644
index 00000000000..1dbc7cc2242
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/pages/list.vue
@@ -0,0 +1,214 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import {
+ GlLoadingIcon,
+ GlEmptyState,
+ GlPagination,
+ GlTooltipDirective,
+ GlButton,
+ GlIcon,
+ GlModal,
+ GlSprintf,
+ GlLink,
+} from '@gitlab/ui';
+import Tracking from '~/tracking';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import ProjectEmptyState from '../components/project_empty_state.vue';
+import GroupEmptyState from '../components/group_empty_state.vue';
+
+export default {
+ name: 'RegistryListApp',
+ components: {
+ GlEmptyState,
+ GlLoadingIcon,
+ GlPagination,
+ ProjectEmptyState,
+ GroupEmptyState,
+ ClipboardButton,
+ GlButton,
+ GlIcon,
+ GlModal,
+ GlSprintf,
+ GlLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [Tracking.mixin()],
+ data() {
+ return {
+ itemToDelete: {},
+ };
+ },
+ computed: {
+ ...mapState(['config', 'isLoading', 'images', 'pagination']),
+ tracking() {
+ return {
+ label: 'registry_repository_delete',
+ };
+ },
+ currentPage: {
+ get() {
+ return this.pagination.page;
+ },
+ set(page) {
+ this.requestImagesList({ page });
+ },
+ },
+ },
+ methods: {
+ ...mapActions(['requestImagesList', 'requestDeleteImage']),
+ deleteImage(item) {
+ // This event is already tracked in the system and so the name must be kept to aggregate the data
+ this.track('click_button');
+ this.itemToDelete = item;
+ this.$refs.deleteModal.show();
+ },
+ handleDeleteRepository() {
+ this.track('confirm_delete');
+ this.requestDeleteImage(this.itemToDelete.destroy_path);
+ this.itemToDelete = {};
+ },
+ encodeListItem(item) {
+ const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id });
+ return window.btoa(params);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="position-absolute w-100 slide-enter-from-element">
+ <gl-empty-state
+ v-if="config.characterError"
+ :title="s__('ContainerRegistry|Docker connection error')"
+ :svg-path="config.containersErrorImage"
+ >
+ <template #description>
+ <p>
+ <gl-sprintf
+ :message="
+ s__(`ContainerRegistry|We are having trouble connecting to Docker, which could be due to an
+ issue with your project name or path.
+ %{docLinkStart}More Information%{docLinkEnd}`)
+ "
+ >
+ <template #docLink="{content}">
+ <gl-link :href="`${config.helpPagePath}#docker-connection-error`" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </template>
+ </gl-empty-state>
+
+ <template v-else>
+ <gl-loading-icon v-if="isLoading" size="md" class="prepend-top-16" />
+
+ <template v-else>
+ <div v-if="images.length" ref="imagesList">
+ <h4>{{ s__('ContainerRegistry|Container Registry') }}</h4>
+ <p>
+ <gl-sprintf
+ :message="
+ 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}`)
+ "
+ >
+ <template #docLink="{content}">
+ <gl-link :href="config.helpPagePath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <div class="d-flex flex-column">
+ <div
+ v-for="(listItem, index) in images"
+ :key="index"
+ ref="rowItem"
+ :class="[
+ 'd-flex justify-content-between align-items-center py-2 border-bottom',
+ { 'border-top': index === 0 },
+ ]"
+ >
+ <div>
+ <router-link
+ ref="detailsLink"
+ :to="{ name: 'details', params: { id: encodeListItem(listItem) } }"
+ >
+ {{ listItem.path }}
+ </router-link>
+ <clipboard-button
+ v-if="listItem.location"
+ ref="clipboardButton"
+ :text="listItem.location"
+ :title="listItem.location"
+ css-class="btn-default btn-transparent btn-clipboard"
+ />
+ </div>
+ <div
+ v-gl-tooltip="{ disabled: listItem.destroy_path }"
+ class="d-none d-sm-block"
+ :title="
+ s__(
+ 'ContainerRegistry|Missing or insufficient permission, delete button disabled',
+ )
+ "
+ >
+ <gl-button
+ ref="deleteImageButton"
+ v-gl-tooltip
+ :disabled="!listItem.destroy_path"
+ :title="s__('ContainerRegistry|Remove repository')"
+ :aria-label="s__('ContainerRegistry|Remove repository')"
+ class="btn-inverted"
+ variant="danger"
+ @click="deleteImage(listItem)"
+ >
+ <gl-icon name="remove" />
+ </gl-button>
+ </div>
+ </div>
+ </div>
+ <gl-pagination
+ v-model="currentPage"
+ :per-page="pagination.perPage"
+ :total-items="pagination.total"
+ align="center"
+ class="w-100 mt-2"
+ />
+ </div>
+ <template v-else>
+ <project-empty-state v-if="!config.isGroupPage" />
+ <group-empty-state v-else />
+ </template>
+ </template>
+
+ <gl-modal
+ ref="deleteModal"
+ modal-id="delete-image-modal"
+ ok-variant="danger"
+ @ok="handleDeleteRepository"
+ @cancel="track('cancel_delete')"
+ >
+ <template #modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template>
+ <p>
+ <gl-sprintf
+ :message=" s__(
+ 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.',
+ ),"
+ >
+ <template #title>
+ <b>{{ itemToDelete.path }}</b>
+ </template>
+ </gl-sprintf>
+ </p>
+ <template #modal-ok>{{ __('Remove') }}</template>
+ </gl-modal>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/router.js b/app/assets/javascripts/registry/explorer/router.js
new file mode 100644
index 00000000000..7e4c3d28623
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/router.js
@@ -0,0 +1,44 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import { s__ } from '~/locale';
+import List from './pages/list.vue';
+import Details from './pages/details.vue';
+import { decodeAndParse } from './utils';
+
+Vue.use(VueRouter);
+
+export default function createRouter(base, store) {
+ const router = new VueRouter({
+ base,
+ mode: 'history',
+ routes: [
+ {
+ name: 'list',
+ path: '/',
+ component: List,
+ meta: {
+ nameGenerator: () => s__('ContainerRegistry|Container Registry'),
+ root: true,
+ },
+ beforeEnter: (to, from, next) => {
+ store.dispatch('requestImagesList');
+ next();
+ },
+ },
+ {
+ name: 'details',
+ path: '/:id',
+ component: Details,
+ meta: {
+ nameGenerator: route => decodeAndParse(route.params.id).name,
+ },
+ beforeEnter: (to, from, next) => {
+ store.dispatch('requestTagsList', { params: to.params.id });
+ next();
+ },
+ },
+ ],
+ });
+
+ return router;
+}
diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js
new file mode 100644
index 00000000000..86d00d4fca9
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/stores/actions.js
@@ -0,0 +1,117 @@
+import axios from '~/lib/utils/axios_utils';
+import createFlash from '~/flash';
+import * as types from './mutation_types';
+import {
+ FETCH_IMAGES_LIST_ERROR_MESSAGE,
+ DEFAULT_PAGE,
+ DEFAULT_PAGE_SIZE,
+ FETCH_TAGS_LIST_ERROR_MESSAGE,
+ DELETE_TAG_SUCCESS_MESSAGE,
+ DELETE_TAG_ERROR_MESSAGE,
+ DELETE_TAGS_SUCCESS_MESSAGE,
+ DELETE_TAGS_ERROR_MESSAGE,
+ DELETE_IMAGE_ERROR_MESSAGE,
+ DELETE_IMAGE_SUCCESS_MESSAGE,
+} from '../constants';
+import { decodeAndParse } from '../utils';
+
+export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data);
+
+export const receiveImagesListSuccess = ({ commit }, { data, headers }) => {
+ commit(types.SET_IMAGES_LIST_SUCCESS, data);
+ commit(types.SET_PAGINATION, headers);
+};
+
+export const receiveTagsListSuccess = ({ commit }, { data, headers }) => {
+ commit(types.SET_TAGS_LIST_SUCCESS, data);
+ commit(types.SET_TAGS_PAGINATION, headers);
+};
+
+export const requestImagesList = ({ commit, dispatch, state }, pagination = {}) => {
+ commit(types.SET_MAIN_LOADING, true);
+ const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination;
+
+ return axios
+ .get(state.config.endpoint, { params: { page, per_page: perPage } })
+ .then(({ data, headers }) => {
+ dispatch('receiveImagesListSuccess', { data, headers });
+ })
+ .catch(() => {
+ createFlash(FETCH_IMAGES_LIST_ERROR_MESSAGE);
+ })
+ .finally(() => {
+ commit(types.SET_MAIN_LOADING, false);
+ });
+};
+
+export const requestTagsList = ({ commit, dispatch }, { pagination = {}, params }) => {
+ commit(types.SET_MAIN_LOADING, true);
+ const { tags_path } = decodeAndParse(params);
+
+ const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination;
+ return axios
+ .get(tags_path, { params: { page, per_page: perPage } })
+ .then(({ data, headers }) => {
+ dispatch('receiveTagsListSuccess', { data, headers });
+ })
+ .catch(() => {
+ createFlash(FETCH_TAGS_LIST_ERROR_MESSAGE);
+ })
+ .finally(() => {
+ commit(types.SET_MAIN_LOADING, false);
+ });
+};
+
+export const requestDeleteTag = ({ commit, dispatch, state }, { tag, params }) => {
+ commit(types.SET_MAIN_LOADING, true);
+ return axios
+ .delete(tag.destroy_path)
+ .then(() => {
+ createFlash(DELETE_TAG_SUCCESS_MESSAGE, 'success');
+ dispatch('requestTagsList', { pagination: state.tagsPagination, params });
+ })
+ .catch(() => {
+ createFlash(DELETE_TAG_ERROR_MESSAGE);
+ })
+ .finally(() => {
+ commit(types.SET_MAIN_LOADING, false);
+ });
+};
+
+export const requestDeleteTags = ({ commit, dispatch, state }, { ids, params }) => {
+ commit(types.SET_MAIN_LOADING, true);
+ const { id } = decodeAndParse(params);
+ const url = `/${state.config.projectPath}/registry/repository/${id}/tags/bulk_destroy`;
+
+ return axios
+ .delete(url, { params: { ids } })
+ .then(() => {
+ createFlash(DELETE_TAGS_SUCCESS_MESSAGE, 'success');
+ dispatch('requestTagsList', { pagination: state.tagsPagination, params });
+ })
+ .catch(() => {
+ createFlash(DELETE_TAGS_ERROR_MESSAGE);
+ })
+ .finally(() => {
+ commit(types.SET_MAIN_LOADING, false);
+ });
+};
+
+export const requestDeleteImage = ({ commit, dispatch, state }, destroyPath) => {
+ commit(types.SET_MAIN_LOADING, true);
+
+ return axios
+ .delete(destroyPath)
+ .then(() => {
+ dispatch('requestImagesList', { pagination: state.pagination });
+ createFlash(DELETE_IMAGE_SUCCESS_MESSAGE, 'success');
+ })
+ .catch(() => {
+ createFlash(DELETE_IMAGE_ERROR_MESSAGE);
+ })
+ .finally(() => {
+ commit(types.SET_MAIN_LOADING, false);
+ });
+};
+
+export default () => {};
diff --git a/app/assets/javascripts/releases/detail/store/index.js b/app/assets/javascripts/registry/explorer/stores/index.js
index e8623a49356..91a35aac149 100644
--- a/app/assets/javascripts/releases/detail/store/index.js
+++ b/app/assets/javascripts/registry/explorer/stores/index.js
@@ -6,9 +6,11 @@ import state from './state';
Vue.use(Vuex);
-export default () =>
+export const createStore = () =>
new Vuex.Store({
+ state,
actions,
mutations,
- state,
});
+
+export default createStore();
diff --git a/app/assets/javascripts/registry/explorer/stores/mutation_types.js b/app/assets/javascripts/registry/explorer/stores/mutation_types.js
new file mode 100644
index 00000000000..92b747dffc5
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/stores/mutation_types.js
@@ -0,0 +1,7 @@
+export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
+
+export const SET_IMAGES_LIST_SUCCESS = 'SET_PACKAGE_LIST_SUCCESS';
+export const SET_PAGINATION = 'SET_PAGINATION';
+export const SET_MAIN_LOADING = 'SET_MAIN_LOADING';
+export const SET_TAGS_PAGINATION = 'SET_TAGS_PAGINATION';
+export const SET_TAGS_LIST_SUCCESS = 'SET_TAGS_LIST_SUCCESS';
diff --git a/app/assets/javascripts/registry/explorer/stores/mutations.js b/app/assets/javascripts/registry/explorer/stores/mutations.js
new file mode 100644
index 00000000000..a2c6a11de20
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/stores/mutations.js
@@ -0,0 +1,33 @@
+import * as types from './mutation_types';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+
+export default {
+ [types.SET_INITIAL_STATE](state, config) {
+ state.config = {
+ ...config,
+ isGroupPage: config.isGroupPage !== undefined,
+ };
+ },
+
+ [types.SET_IMAGES_LIST_SUCCESS](state, images) {
+ state.images = images;
+ },
+
+ [types.SET_TAGS_LIST_SUCCESS](state, tags) {
+ state.tags = tags;
+ },
+
+ [types.SET_MAIN_LOADING](state, isLoading) {
+ state.isLoading = isLoading;
+ },
+
+ [types.SET_PAGINATION](state, headers) {
+ const normalizedHeaders = normalizeHeaders(headers);
+ state.pagination = parseIntPagination(normalizedHeaders);
+ },
+
+ [types.SET_TAGS_PAGINATION](state, headers) {
+ const normalizedHeaders = normalizeHeaders(headers);
+ state.tagsPagination = parseIntPagination(normalizedHeaders);
+ },
+};
diff --git a/app/assets/javascripts/registry/explorer/stores/state.js b/app/assets/javascripts/registry/explorer/stores/state.js
new file mode 100644
index 00000000000..91a378f139b
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/stores/state.js
@@ -0,0 +1,8 @@
+export default () => ({
+ isLoading: false,
+ config: {},
+ images: [],
+ tags: [],
+ pagination: {},
+ tagsPagination: {},
+});
diff --git a/app/assets/javascripts/registry/explorer/utils.js b/app/assets/javascripts/registry/explorer/utils.js
new file mode 100644
index 00000000000..b1df87c6993
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/utils.js
@@ -0,0 +1,2 @@
+// eslint-disable-next-line import/prefer-default-export
+export const decodeAndParse = param => JSON.parse(window.atob(param));
diff --git a/app/assets/javascripts/registry/list/index.js b/app/assets/javascripts/registry/list/index.js
index 3d0ff327b42..e8e54fda169 100644
--- a/app/assets/javascripts/registry/list/index.js
+++ b/app/assets/javascripts/registry/list/index.js
@@ -4,14 +4,20 @@ import Translate from '~/vue_shared/translate';
Vue.use(Translate);
-export default () =>
- new Vue({
- el: '#js-vue-registry-images',
+export default () => {
+ const el = document.getElementById('js-vue-registry-images');
+
+ if (!el) {
+ return null;
+ }
+
+ return new Vue({
+ el,
components: {
registryApp,
},
data() {
- const { dataset } = document.querySelector(this.$options.el);
+ const { dataset } = el;
return {
registryData: {
endpoint: dataset.endpoint,
@@ -35,3 +41,4 @@ export default () =>
});
},
});
+};
diff --git a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue
index ca495cd2eca..87e65d354bb 100644
--- a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue
+++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue
@@ -1,20 +1,31 @@
<script>
-import { mapState, mapActions } from 'vuex';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { mapActions, mapState } from 'vuex';
+import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+import { FETCH_SETTINGS_ERROR_MESSAGE } from '../../shared/constants';
+
import SettingsForm from './settings_form.vue';
export default {
components: {
- GlLoadingIcon,
SettingsForm,
+ GlAlert,
+ GlSprintf,
+ GlLink,
+ },
+ i18n: {
+ unavailableFeatureText: s__(
+ 'ContainerRegistry|Currently, the Container Registry tag expiration feature is not available for projects created before GitLab version 12.8. For updates and more information, visit Issue %{linkStart}#196124%{linkEnd}',
+ ),
},
computed: {
- ...mapState({
- isLoading: 'isLoading',
- }),
+ ...mapState(['isDisabled']),
},
mounted() {
- this.fetchSettings();
+ this.fetchSettings().catch(() =>
+ this.$toast.show(FETCH_SETTINGS_ERROR_MESSAGE, { type: 'error' }),
+ );
},
methods: {
...mapActions(['fetchSettings']),
@@ -37,7 +48,17 @@ export default {
}}
</li>
</ul>
- <gl-loading-icon v-if="isLoading" ref="loading-icon" size="xl" />
- <settings-form v-else ref="settings-form" />
+ <settings-form v-if="!isDisabled" />
+ <gl-alert v-else :dismissible="false">
+ <p>
+ <gl-sprintf :message="$options.i18n.unavailableFeatureText">
+ <template #link="{content}">
+ <gl-link href="https://gitlab.com/gitlab-org/gitlab/issues/196124" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </gl-alert>
</div>
</template>
diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue
index 457bf35daab..cab3c7fff85 100644
--- a/app/assets/javascripts/registry/settings/components/settings_form.vue
+++ b/app/assets/javascripts/registry/settings/components/settings_form.vue
@@ -1,175 +1,95 @@
<script>
-import { mapActions, mapState } from 'vuex';
-import { GlFormGroup, GlToggle, GlFormSelect, GlFormTextarea, GlButton, GlCard } from '@gitlab/ui';
-import { s__, __, sprintf } from '~/locale';
-import { NAME_REGEX_LENGTH } from '../constants';
+import { mapActions, mapState, mapGetters } from 'vuex';
+import { GlCard, GlButton, GlLoadingIcon } from '@gitlab/ui';
+import Tracking from '~/tracking';
+import {
+ UPDATE_SETTINGS_ERROR_MESSAGE,
+ UPDATE_SETTINGS_SUCCESS_MESSAGE,
+} from '../../shared/constants';
import { mapComputed } from '~/vuex_shared/bindings';
+import ExpirationPolicyFields from '../../shared/components/expiration_policy_fields.vue';
export default {
components: {
- GlFormGroup,
- GlToggle,
- GlFormSelect,
- GlFormTextarea,
- GlButton,
GlCard,
+ GlButton,
+ GlLoadingIcon,
+ ExpirationPolicyFields,
},
+ mixins: [Tracking.mixin()],
labelsConfig: {
cols: 3,
align: 'right',
},
+ data() {
+ return {
+ tracking: {
+ label: 'docker_container_retention_and_expiration_policies',
+ },
+ formIsValid: true,
+ };
+ },
computed: {
- ...mapState(['formOptions']),
- ...mapComputed(
- [
- 'enabled',
- { key: 'cadence', getter: 'getCadence' },
- { key: 'older_than', getter: 'getOlderThan' },
- { key: 'keep_n', getter: 'getKeepN' },
- 'name_regex',
- ],
- 'updateSettings',
- 'settings',
- ),
- policyEnabledText() {
- return this.enabled ? __('enabled') : __('disabled');
- },
- toggleDescriptionText() {
- return sprintf(
- s__('ContainerRegistry|Docker tag expiration policy is %{toggleStatus}'),
- {
- toggleStatus: `<strong>${this.policyEnabledText}</strong>`,
- },
- false,
- );
+ ...mapState(['formOptions', 'isLoading']),
+ ...mapGetters({ isEdited: 'getIsEdited' }),
+ ...mapComputed([{ key: 'settings', getter: 'getSettings' }], 'updateSettings'),
+ isSubmitButtonDisabled() {
+ return !this.formIsValid || this.isLoading;
},
- regexHelpText() {
- return sprintf(
- s__(
- 'ContainerRegistry|Wildcards such as %{codeStart}*-stable%{codeEnd} or %{codeStart}production/*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}',
- ),
- {
- codeStart: '<code>',
- codeEnd: '</code>',
- },
- false,
- );
- },
- nameRegexPlaceholder() {
- return '.*';
- },
- nameRegexState() {
- return this.name_regex ? this.name_regex.length <= NAME_REGEX_LENGTH : null;
- },
- formIsInvalid() {
- return this.nameRegexState === false;
+ isCancelButtonDisabled() {
+ return !this.isEdited || this.isLoading;
},
},
methods: {
...mapActions(['resetSettings', 'saveSettings']),
+ reset() {
+ this.track('reset_form');
+ this.resetSettings();
+ },
+ submit() {
+ this.track('submit_form');
+ this.saveSettings()
+ .then(() => this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' }))
+ .catch(() => this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' }));
+ },
},
};
</script>
<template>
- <form ref="form-element" @submit.prevent="saveSettings" @reset.prevent="resetSettings">
+ <form ref="form-element" @submit.prevent="submit" @reset.prevent="reset">
<gl-card>
<template #header>
{{ s__('ContainerRegistry|Tag expiration policy') }}
</template>
- <template>
- <gl-form-group
- id="expiration-policy-toggle-group"
- :label-cols="$options.labelsConfig.cols"
- :label-align="$options.labelsConfig.align"
- label-for="expiration-policy-toggle"
- :label="s__('ContainerRegistry|Expiration policy:')"
- >
- <div class="d-flex align-items-start">
- <gl-toggle id="expiration-policy-toggle" v-model="enabled" />
- <span class="mb-2 ml-1 lh-2" v-html="toggleDescriptionText"></span>
- </div>
- </gl-form-group>
-
- <gl-form-group
- id="expiration-policy-interval-group"
- :label-cols="$options.labelsConfig.cols"
- :label-align="$options.labelsConfig.align"
- label-for="expiration-policy-interval"
- :label="s__('ContainerRegistry|Expiration interval:')"
- >
- <gl-form-select id="expiration-policy-interval" v-model="older_than" :disabled="!enabled">
- <option v-for="option in formOptions.olderThan" :key="option.key" :value="option.key">
- {{ option.label }}
- </option>
- </gl-form-select>
- </gl-form-group>
-
- <gl-form-group
- id="expiration-policy-schedule-group"
- :label-cols="$options.labelsConfig.cols"
- :label-align="$options.labelsConfig.align"
- label-for="expiration-policy-schedule"
- :label="s__('ContainerRegistry|Expiration schedule:')"
- >
- <gl-form-select id="expiration-policy-schedule" v-model="cadence" :disabled="!enabled">
- <option v-for="option in formOptions.cadence" :key="option.key" :value="option.key">
- {{ option.label }}
- </option>
- </gl-form-select>
- </gl-form-group>
-
- <gl-form-group
- id="expiration-policy-latest-group"
- :label-cols="$options.labelsConfig.cols"
- :label-align="$options.labelsConfig.align"
- label-for="expiration-policy-latest"
- :label="s__('ContainerRegistry|Number of tags to retain:')"
- >
- <gl-form-select id="expiration-policy-latest" v-model="keep_n" :disabled="!enabled">
- <option v-for="option in formOptions.keepN" :key="option.key" :value="option.key">
- {{ option.label }}
- </option>
- </gl-form-select>
- </gl-form-group>
-
- <gl-form-group
- id="expiration-policy-name-matching-group"
- :label-cols="$options.labelsConfig.cols"
- :label-align="$options.labelsConfig.align"
- label-for="expiration-policy-name-matching"
- :label="s__('ContainerRegistry|Expire Docker tags that match this regex:')"
- :state="nameRegexState"
- :invalid-feedback="
- s__('ContainerRegistry|The value of this input should be less than 255 characters')
- "
- >
- <gl-form-textarea
- id="expiration-policy-name-matching"
- v-model="name_regex"
- :placeholder="nameRegexPlaceholder"
- :state="nameRegexState"
- :disabled="!enabled"
- trim
- />
- <template #description>
- <span ref="regex-description" v-html="regexHelpText"></span>
- </template>
- </gl-form-group>
+ <template #default>
+ <expiration-policy-fields
+ v-model="settings"
+ :form-options="formOptions"
+ :is-loading="isLoading"
+ @validated="formIsValid = true"
+ @invalidated="formIsValid = false"
+ />
</template>
<template #footer>
<div class="d-flex justify-content-end">
- <gl-button ref="cancel-button" type="reset" class="mr-2 d-block">{{
- __('Cancel')
- }}</gl-button>
+ <gl-button
+ ref="cancel-button"
+ type="reset"
+ class="mr-2 d-block"
+ :disabled="isCancelButtonDisabled"
+ >
+ {{ __('Cancel') }}
+ </gl-button>
<gl-button
ref="save-button"
type="submit"
- :disabled="formIsInvalid"
+ :disabled="isSubmitButtonDisabled"
variant="success"
- class="d-block"
+ class="d-flex justify-content-center align-items-center js-no-auto-disable"
>
{{ __('Save expiration policy') }}
+ <gl-loading-icon v-if="isLoading" class="ml-2" />
</gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js
index 927b6059884..6ae1dbb72c4 100644
--- a/app/assets/javascripts/registry/settings/registry_settings_bundle.js
+++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js
@@ -1,8 +1,10 @@
import Vue from 'vue';
+import { GlToast } from '@gitlab/ui';
import Translate from '~/vue_shared/translate';
import store from './store/';
import RegistrySettingsApp from './components/registry_settings_app.vue';
+Vue.use(GlToast);
Vue.use(Translate);
export default () => {
diff --git a/app/assets/javascripts/registry/settings/store/actions.js b/app/assets/javascripts/registry/settings/store/actions.js
index 5e46d564121..d0379d05164 100644
--- a/app/assets/javascripts/registry/settings/store/actions.js
+++ b/app/assets/javascripts/registry/settings/store/actions.js
@@ -1,18 +1,16 @@
import Api from '~/api';
-import createFlash from '~/flash';
-import {
- FETCH_SETTINGS_ERROR_MESSAGE,
- UPDATE_SETTINGS_ERROR_MESSAGE,
- UPDATE_SETTINGS_SUCCESS_MESSAGE,
-} from '../constants';
import * as types from './mutation_types';
export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data);
export const updateSettings = ({ commit }, data) => commit(types.UPDATE_SETTINGS, data);
export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING);
-export const receiveSettingsSuccess = ({ commit }, data = {}) => commit(types.SET_SETTINGS, data);
-export const receiveSettingsError = () => createFlash(FETCH_SETTINGS_ERROR_MESSAGE);
-export const updateSettingsError = () => createFlash(UPDATE_SETTINGS_ERROR_MESSAGE);
+export const receiveSettingsSuccess = ({ commit }, data) => {
+ if (data) {
+ commit(types.SET_SETTINGS, data);
+ } else {
+ commit(types.SET_IS_DISABLED, true);
+ }
+};
export const resetSettings = ({ commit }) => commit(types.RESET_SETTINGS);
export const fetchSettings = ({ dispatch, state }) => {
@@ -21,7 +19,6 @@ export const fetchSettings = ({ dispatch, state }) => {
.then(({ data: { container_expiration_policy } }) =>
dispatch('receiveSettingsSuccess', container_expiration_policy),
)
- .catch(() => dispatch('receiveSettingsError'))
.finally(() => dispatch('toggleLoading'));
};
@@ -30,11 +27,9 @@ export const saveSettings = ({ dispatch, state }) => {
return Api.updateProject(state.projectId, {
container_expiration_policy_attributes: state.settings,
})
- .then(({ data: { container_expiration_policy } }) => {
- dispatch('receiveSettingsSuccess', container_expiration_policy);
- createFlash(UPDATE_SETTINGS_SUCCESS_MESSAGE, 'success');
- })
- .catch(() => dispatch('updateSettingsError'))
+ .then(({ data: { container_expiration_policy } }) =>
+ dispatch('receiveSettingsSuccess', container_expiration_policy),
+ )
.finally(() => dispatch('toggleLoading'));
};
diff --git a/app/assets/javascripts/registry/settings/store/getters.js b/app/assets/javascripts/registry/settings/store/getters.js
index fc32a9f08e4..639becebeec 100644
--- a/app/assets/javascripts/registry/settings/store/getters.js
+++ b/app/assets/javascripts/registry/settings/store/getters.js
@@ -1,8 +1,21 @@
-import { findDefaultOption } from '../utils';
+import { isEqual } from 'lodash';
+import { findDefaultOption } from '../../shared/utils';
export const getCadence = state =>
state.settings.cadence || findDefaultOption(state.formOptions.cadence);
+
export const getKeepN = state =>
state.settings.keep_n || findDefaultOption(state.formOptions.keepN);
+
export const getOlderThan = state =>
state.settings.older_than || findDefaultOption(state.formOptions.olderThan);
+
+export const getSettings = (state, getters) => ({
+ enabled: state.settings.enabled,
+ cadence: getters.getCadence,
+ older_than: getters.getOlderThan,
+ keep_n: getters.getKeepN,
+ name_regex: state.settings.name_regex,
+});
+
+export const getIsEdited = state => !isEqual(state.original, state.settings);
diff --git a/app/assets/javascripts/registry/settings/store/mutation_types.js b/app/assets/javascripts/registry/settings/store/mutation_types.js
index db499ffa761..2d071567c1f 100644
--- a/app/assets/javascripts/registry/settings/store/mutation_types.js
+++ b/app/assets/javascripts/registry/settings/store/mutation_types.js
@@ -3,3 +3,4 @@ export const UPDATE_SETTINGS = 'UPDATE_SETTINGS';
export const TOGGLE_LOADING = 'TOGGLE_LOADING';
export const SET_SETTINGS = 'SET_SETTINGS';
export const RESET_SETTINGS = 'RESET_SETTINGS';
+export const SET_IS_DISABLED = 'SET_IS_DISABLED';
diff --git a/app/assets/javascripts/registry/settings/store/mutations.js b/app/assets/javascripts/registry/settings/store/mutations.js
index 25a67cc6973..f562137db1a 100644
--- a/app/assets/javascripts/registry/settings/store/mutations.js
+++ b/app/assets/javascripts/registry/settings/store/mutations.js
@@ -9,13 +9,16 @@ export default {
olderThan: JSON.parse(initialState.olderThanOptions),
};
},
- [types.UPDATE_SETTINGS](state, settings) {
- state.settings = { ...state.settings, ...settings };
+ [types.UPDATE_SETTINGS](state, data) {
+ state.settings = { ...state.settings, ...data.settings };
},
[types.SET_SETTINGS](state, settings) {
state.settings = settings;
state.original = Object.freeze(settings);
},
+ [types.SET_IS_DISABLED](state, isDisabled) {
+ state.isDisabled = isDisabled;
+ },
[types.RESET_SETTINGS](state) {
state.settings = { ...state.original };
},
diff --git a/app/assets/javascripts/registry/settings/store/state.js b/app/assets/javascripts/registry/settings/store/state.js
index 50c882e1839..582e18e5465 100644
--- a/app/assets/javascripts/registry/settings/store/state.js
+++ b/app/assets/javascripts/registry/settings/store/state.js
@@ -8,6 +8,10 @@ export default () => ({
*/
isLoading: false,
/*
+ * Boolean to determine if the user is allowed to interact with the form
+ */
+ isDisabled: false,
+ /*
* This contains the data shown and manipulated in the UI
* Has the following structure:
* {
diff --git a/app/assets/javascripts/registry/settings/utils.js b/app/assets/javascripts/registry/settings/utils.js
deleted file mode 100644
index 75af401e96d..00000000000
--- a/app/assets/javascripts/registry/settings/utils.js
+++ /dev/null
@@ -1,6 +0,0 @@
-export const findDefaultOption = options => {
- const item = options.find(o => o.default);
- return item ? item.key : null;
-};
-
-export default () => {};
diff --git a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
new file mode 100644
index 00000000000..3e212f09e35
--- /dev/null
+++ b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
@@ -0,0 +1,197 @@
+<script>
+import { uniqueId } from 'lodash';
+import { GlFormGroup, GlToggle, GlFormSelect, GlFormTextarea, GlSprintf } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import { NAME_REGEX_LENGTH } from '../constants';
+import { mapComputedToEvent } from '../utils';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlToggle,
+ GlFormSelect,
+ GlFormTextarea,
+ GlSprintf,
+ },
+ props: {
+ formOptions: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ value: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ labelCols: {
+ type: [Number, String],
+ required: false,
+ default: 3,
+ },
+ labelAlign: {
+ type: String,
+ required: false,
+ default: 'right',
+ },
+ },
+ nameRegexPlaceholder: '.*',
+ selectList: [
+ {
+ name: 'expiration-policy-interval',
+ label: s__('ContainerRegistry|Expiration interval:'),
+ model: 'older_than',
+ optionKey: 'olderThan',
+ },
+ {
+ name: 'expiration-policy-schedule',
+ label: s__('ContainerRegistry|Expiration schedule:'),
+ model: 'cadence',
+ optionKey: 'cadence',
+ },
+ {
+ name: 'expiration-policy-latest',
+ label: s__('ContainerRegistry|Number of tags to retain:'),
+ model: 'keep_n',
+ optionKey: 'keepN',
+ },
+ ],
+ data() {
+ return {
+ uniqueId: uniqueId(),
+ };
+ },
+ computed: {
+ ...mapComputedToEvent(['enabled', 'cadence', 'older_than', 'keep_n', 'name_regex'], 'value'),
+ policyEnabledText() {
+ return this.enabled ? __('enabled') : __('disabled');
+ },
+ nameRegexState() {
+ return this.name_regex ? this.name_regex.length <= NAME_REGEX_LENGTH : null;
+ },
+ fieldsValidity() {
+ return this.nameRegexState !== false;
+ },
+ isFormElementDisabled() {
+ return !this.enabled || this.isLoading;
+ },
+ },
+ watch: {
+ fieldsValidity: {
+ immediate: true,
+ handler(valid) {
+ if (valid) {
+ this.$emit('validated');
+ } else {
+ this.$emit('invalidated');
+ }
+ },
+ },
+ },
+ methods: {
+ idGenerator(id) {
+ return `${id}_${this.uniqueId}`;
+ },
+ updateModel(value, key) {
+ this[key] = value;
+ },
+ },
+};
+</script>
+
+<template>
+ <div ref="form-elements" class="lh-2">
+ <gl-form-group
+ :id="idGenerator('expiration-policy-toggle-group')"
+ :label-cols="labelCols"
+ :label-align="labelAlign"
+ :label-for="idGenerator('expiration-policy-toggle')"
+ :label="s__('ContainerRegistry|Expiration policy:')"
+ >
+ <div class="d-flex align-items-start">
+ <gl-toggle
+ :id="idGenerator('expiration-policy-toggle')"
+ v-model="enabled"
+ :disabled="isLoading"
+ />
+ <span class="mb-2 ml-1 lh-2">
+ <gl-sprintf
+ :message="s__('ContainerRegistry|Docker tag expiration policy is %{toggleStatus}')"
+ >
+ <template #toggleStatus>
+ <strong>{{ policyEnabledText }}</strong>
+ </template>
+ </gl-sprintf>
+ </span>
+ </div>
+ </gl-form-group>
+
+ <gl-form-group
+ v-for="select in $options.selectList"
+ :id="idGenerator(`${select.name}-group`)"
+ :key="select.name"
+ :label-cols="labelCols"
+ :label-align="labelAlign"
+ :label-for="idGenerator(select.name)"
+ :label="select.label"
+ >
+ <gl-form-select
+ :id="idGenerator(select.name)"
+ :value="value[select.model]"
+ :disabled="isFormElementDisabled"
+ @input="updateModel($event, select.model)"
+ >
+ <option
+ v-for="option in formOptions[select.optionKey]"
+ :key="option.key"
+ :value="option.key"
+ >
+ {{ option.label }}
+ </option>
+ </gl-form-select>
+ </gl-form-group>
+
+ <gl-form-group
+ :id="idGenerator('expiration-policy-name-matching-group')"
+ :label-cols="labelCols"
+ :label-align="labelAlign"
+ :label-for="idGenerator('expiration-policy-name-matching')"
+ :label="
+ s__('ContainerRegistry|Docker tags with names matching this regex pattern will expire:')
+ "
+ :state="nameRegexState"
+ :invalid-feedback="
+ s__('ContainerRegistry|The value of this input should be less than 255 characters')
+ "
+ >
+ <gl-form-textarea
+ :id="idGenerator('expiration-policy-name-matching')"
+ v-model="name_regex"
+ :placeholder="$options.nameRegexPlaceholder"
+ :state="nameRegexState"
+ :disabled="isFormElementDisabled"
+ trim
+ />
+ <template #description>
+ <span ref="regex-description">
+ <gl-sprintf
+ :message="
+ s__(
+ 'ContainerRegistry|Wildcards such as %{codeStart}.*-stable%{codeEnd} or %{codeStart}production/.*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}',
+ )
+ "
+ >
+ <template #code="{content}">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+ </gl-form-group>
+ </div>
+</template>
diff --git a/app/assets/javascripts/registry/settings/constants.js b/app/assets/javascripts/registry/shared/constants.js
index c0dac466b29..c0dac466b29 100644
--- a/app/assets/javascripts/registry/settings/constants.js
+++ b/app/assets/javascripts/registry/shared/constants.js
diff --git a/app/assets/javascripts/registry/shared/utils.js b/app/assets/javascripts/registry/shared/utils.js
new file mode 100644
index 00000000000..d85a3ad28c2
--- /dev/null
+++ b/app/assets/javascripts/registry/shared/utils.js
@@ -0,0 +1,19 @@
+export const findDefaultOption = options => {
+ const item = options.find(o => o.default);
+ return item ? item.key : null;
+};
+
+export const mapComputedToEvent = (list, root) => {
+ const result = {};
+ list.forEach(e => {
+ result[e] = {
+ get() {
+ return this[root][e];
+ },
+ set(value) {
+ this.$emit('input', { ...this[root], [e]: value });
+ },
+ };
+ });
+ return result;
+};
diff --git a/app/assets/javascripts/releases/detail/components/app.vue b/app/assets/javascripts/releases/components/app_edit.vue
index 073cfcd7694..bdc2b3abb8c 100644
--- a/app/assets/javascripts/releases/detail/components/app.vue
+++ b/app/assets/javascripts/releases/components/app_edit.vue
@@ -7,7 +7,7 @@ import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
export default {
- name: 'ReleaseDetailApp',
+ name: 'ReleaseEditApp',
components: {
GlFormInput,
GlFormGroup,
@@ -18,7 +18,7 @@ export default {
autofocusonshow,
},
computed: {
- ...mapState([
+ ...mapState('detail', [
'isFetchingRelease',
'fetchError',
'markdownDocsPath',
@@ -42,7 +42,7 @@ export default {
);
},
tagName() {
- return this.$store.state.release.tagName;
+ return this.$store.state.detail.release.tagName;
},
tagNameHintText() {
return sprintf(
@@ -60,7 +60,7 @@ export default {
},
releaseTitle: {
get() {
- return this.$store.state.release.name;
+ return this.$store.state.detail.release.name;
},
set(title) {
this.updateReleaseTitle(title);
@@ -68,7 +68,7 @@ export default {
},
releaseNotes: {
get() {
- return this.$store.state.release.description;
+ return this.$store.state.detail.release.description;
},
set(notes) {
this.updateReleaseNotes(notes);
@@ -79,7 +79,7 @@ export default {
this.fetchRelease();
},
methods: {
- ...mapActions([
+ ...mapActions('detail', [
'fetchRelease',
'updateRelease',
'updateReleaseTitle',
diff --git a/app/assets/javascripts/releases/list/components/app.vue b/app/assets/javascripts/releases/components/app_index.vue
index eb63e709ebd..f602c9fdda2 100644
--- a/app/assets/javascripts/releases/list/components/app.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -32,7 +32,7 @@ export default {
},
},
computed: {
- ...mapState(['isLoading', 'releases', 'hasError', 'pageInfo']),
+ ...mapState('list', ['isLoading', 'releases', 'hasError', 'pageInfo']),
shouldRenderEmptyState() {
return !this.releases.length && !this.hasError && !this.isLoading;
},
@@ -47,7 +47,7 @@ export default {
});
},
methods: {
- ...mapActions(['fetchReleases']),
+ ...mapActions('list', ['fetchReleases']),
onChangePage(page) {
historyPushState(buildUrlWithCurrentLocation(`?page=${page}`));
this.fetchReleases({ page, projectId: this.projectId });
diff --git a/app/assets/javascripts/releases/list/components/evidence_block.vue b/app/assets/javascripts/releases/components/evidence_block.vue
index d9abd195fee..d9abd195fee 100644
--- a/app/assets/javascripts/releases/list/components/evidence_block.vue
+++ b/app/assets/javascripts/releases/components/evidence_block.vue
diff --git a/app/assets/javascripts/releases/list/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue
index d924b5795f0..e6bb5325120 100644
--- a/app/assets/javascripts/releases/list/components/release_block.vue
+++ b/app/assets/javascripts/releases/components/release_block.vue
@@ -1,9 +1,11 @@
<script>
import _ from 'underscore';
+import $ from 'jquery';
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';
+import '~/behaviors/markdown/render_gfm';
import EvidenceBlock from './evidence_block.vue';
import ReleaseBlockAssets from './release_block_assets.vue';
import ReleaseBlockFooter from './release_block_footer.vue';
@@ -65,7 +67,10 @@ export default {
return Boolean(this.glFeatures.releaseIssueSummary && !_.isEmpty(this.release.milestones));
},
},
+
mounted() {
+ this.renderGFM();
+
const hash = getLocationHash();
if (hash && slugify(hash) === this.id) {
this.isHighlighted = true;
@@ -76,6 +81,11 @@ export default {
scrollToElement(this.$el);
}
},
+ methods: {
+ renderGFM() {
+ $(this.$refs['gfm-content']).renderGFM();
+ },
+ },
};
</script>
<template>
@@ -91,7 +101,7 @@ export default {
<release-block-assets v-if="shouldRenderAssets" :assets="assets" />
<evidence-block v-if="hasEvidence && shouldShowEvidence" :release="release" />
- <div class="card-text prepend-top-default">
+ <div ref="gfm-content" class="card-text prepend-top-default">
<div v-html="release.description_html"></div>
</div>
</div>
diff --git a/app/assets/javascripts/releases/list/components/release_block_assets.vue b/app/assets/javascripts/releases/components/release_block_assets.vue
index e840bc90d68..06b7f97a8de 100644
--- a/app/assets/javascripts/releases/list/components/release_block_assets.vue
+++ b/app/assets/javascripts/releases/components/release_block_assets.vue
@@ -52,7 +52,7 @@ export default {
>
<icon name="doc-code" class="align-top append-right-4" />
{{ __('Source code') }}
- <icon name="arrow-down" />
+ <icon name="chevron-down" />
</button>
<div class="js-sources-dropdown dropdown-menu">
diff --git a/app/assets/javascripts/releases/list/components/release_block_author.vue b/app/assets/javascripts/releases/components/release_block_author.vue
index ff6b00d8221..e7075d4d67a 100644
--- a/app/assets/javascripts/releases/list/components/release_block_author.vue
+++ b/app/assets/javascripts/releases/components/release_block_author.vue
@@ -27,7 +27,7 @@ export default {
<template>
<div class="d-flex">
- <gl-sprintf message="by %{user}">
+ <gl-sprintf :message="__('by %{user}')">
<template #user>
<user-avatar-link
class="prepend-left-4"
diff --git a/app/assets/javascripts/releases/list/components/release_block_footer.vue b/app/assets/javascripts/releases/components/release_block_footer.vue
index 8533fc17ffd..8533fc17ffd 100644
--- a/app/assets/javascripts/releases/list/components/release_block_footer.vue
+++ b/app/assets/javascripts/releases/components/release_block_footer.vue
diff --git a/app/assets/javascripts/releases/list/components/release_block_header.vue b/app/assets/javascripts/releases/components/release_block_header.vue
index 9c5dcf2a709..b459418aef2 100644
--- a/app/assets/javascripts/releases/list/components/release_block_header.vue
+++ b/app/assets/javascripts/releases/components/release_block_header.vue
@@ -19,8 +19,11 @@ export default {
},
},
computed: {
- shouldShowEditButton() {
- return Boolean(this.release._links && this.release._links.edit_url);
+ editLink() {
+ return this.release._links?.edit_url;
+ },
+ selfLink() {
+ return this.release._links?.self;
},
},
};
@@ -29,17 +32,20 @@ export default {
<template>
<div class="card-header d-flex align-items-center bg-white pr-0">
<h2 class="card-title my-2 mr-auto gl-font-size-20">
- {{ release.name }}
+ <gl-link v-if="selfLink" :href="selfLink" class="font-size-inherit">
+ {{ release.name }}
+ </gl-link>
+ <template v-else>{{ release.name }}</template>
<gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{
__('Upcoming Release')
}}</gl-badge>
</h2>
<gl-link
- v-if="shouldShowEditButton"
+ v-if="editLink"
v-gl-tooltip
class="btn btn-default append-right-10 js-edit-button ml-2"
:title="__('Edit this release')"
- :href="release._links.edit_url"
+ :href="editLink"
>
<icon name="pencil" />
</gl-link>
diff --git a/app/assets/javascripts/releases/list/components/release_block_metadata.vue b/app/assets/javascripts/releases/components/release_block_metadata.vue
index f0aad594062..f0aad594062 100644
--- a/app/assets/javascripts/releases/list/components/release_block_metadata.vue
+++ b/app/assets/javascripts/releases/components/release_block_metadata.vue
diff --git a/app/assets/javascripts/releases/list/components/release_block_milestone_info.vue b/app/assets/javascripts/releases/components/release_block_milestone_info.vue
index d3e354d6157..d3e354d6157 100644
--- a/app/assets/javascripts/releases/list/components/release_block_milestone_info.vue
+++ b/app/assets/javascripts/releases/components/release_block_milestone_info.vue
diff --git a/app/assets/javascripts/releases/list/components/release_block_milestones.vue b/app/assets/javascripts/releases/components/release_block_milestones.vue
index a3dff75b828..a3dff75b828 100644
--- a/app/assets/javascripts/releases/list/components/release_block_milestones.vue
+++ b/app/assets/javascripts/releases/components/release_block_milestones.vue
diff --git a/app/assets/javascripts/releases/list/constants.js b/app/assets/javascripts/releases/constants.js
index defcd917465..defcd917465 100644
--- a/app/assets/javascripts/releases/list/constants.js
+++ b/app/assets/javascripts/releases/constants.js
diff --git a/app/assets/javascripts/releases/detail/index.js b/app/assets/javascripts/releases/detail/index.js
deleted file mode 100644
index 0dab90a1ede..00000000000
--- a/app/assets/javascripts/releases/detail/index.js
+++ /dev/null
@@ -1,19 +0,0 @@
-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();
- 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/list/index.js b/app/assets/javascripts/releases/list/index.js
deleted file mode 100644
index adbed3cb8e2..00000000000
--- a/app/assets/javascripts/releases/list/index.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import Vue from 'vue';
-import App from './components/app.vue';
-import createStore from './store';
-
-export default () => {
- const element = document.getElementById('js-releases-page');
-
- return new Vue({
- el: element,
- store: createStore(),
- components: {
- App,
- },
- render(createElement) {
- return createElement('app', {
- props: {
- projectId: element.dataset.projectId,
- documentationLink: element.dataset.documentationPath,
- illustrationPath: element.dataset.illustrationPath,
- },
- });
- },
- });
-};
diff --git a/app/assets/javascripts/releases/list/store/index.js b/app/assets/javascripts/releases/list/store/index.js
deleted file mode 100644
index 968b94f0e0d..00000000000
--- a/app/assets/javascripts/releases/list/store/index.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
-import state from './state';
-import * as actions from './actions';
-import mutations from './mutations';
-
-Vue.use(Vuex);
-
-export default () =>
- new Vuex.Store({
- actions,
- mutations,
- state: state(),
- });
diff --git a/app/assets/javascripts/releases/mount_edit.js b/app/assets/javascripts/releases/mount_edit.js
new file mode 100644
index 00000000000..2bc2728312a
--- /dev/null
+++ b/app/assets/javascripts/releases/mount_edit.js
@@ -0,0 +1,17 @@
+import Vue from 'vue';
+import ReleaseEditApp from './components/app_edit.vue';
+import createStore from './stores';
+import detailModule from './stores/modules/detail';
+
+export default () => {
+ const el = document.getElementById('js-edit-release-page');
+
+ const store = createStore({ detail: detailModule });
+ store.dispatch('detail/setInitialState', el.dataset);
+
+ return new Vue({
+ el,
+ store,
+ render: h => h(ReleaseEditApp),
+ });
+};
diff --git a/app/assets/javascripts/releases/mount_index.js b/app/assets/javascripts/releases/mount_index.js
new file mode 100644
index 00000000000..6fcb6d802e4
--- /dev/null
+++ b/app/assets/javascripts/releases/mount_index.js
@@ -0,0 +1,21 @@
+import Vue from 'vue';
+import ReleaseListApp from './components/app_index.vue';
+import createStore from './stores';
+import listModule from './stores/modules/list';
+
+export default () => {
+ const el = document.getElementById('js-releases-page');
+
+ return new Vue({
+ el,
+ store: createStore({ list: listModule }),
+ render: h =>
+ h(ReleaseListApp, {
+ props: {
+ projectId: el.dataset.projectId,
+ documentationLink: el.dataset.documentationPath,
+ illustrationPath: el.dataset.illustrationPath,
+ },
+ }),
+ });
+};
diff --git a/app/assets/javascripts/releases/stores/index.js b/app/assets/javascripts/releases/stores/index.js
new file mode 100644
index 00000000000..aa607906a0e
--- /dev/null
+++ b/app/assets/javascripts/releases/stores/index.js
@@ -0,0 +1,6 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+
+Vue.use(Vuex);
+
+export default modules => new Vuex.Store({ modules });
diff --git a/app/assets/javascripts/releases/detail/store/actions.js b/app/assets/javascripts/releases/stores/modules/detail/actions.js
index c9749582f5c..c9749582f5c 100644
--- a/app/assets/javascripts/releases/detail/store/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/actions.js
diff --git a/app/assets/javascripts/releases/stores/modules/detail/index.js b/app/assets/javascripts/releases/stores/modules/detail/index.js
new file mode 100644
index 00000000000..243c2389d8c
--- /dev/null
+++ b/app/assets/javascripts/releases/stores/modules/detail/index.js
@@ -0,0 +1,10 @@
+import * as actions from './actions';
+import mutations from './mutations';
+import state from './state';
+
+export default {
+ namespaced: true,
+ actions,
+ mutations,
+ state,
+};
diff --git a/app/assets/javascripts/releases/detail/store/mutation_types.js b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js
index 75e1d78a645..75e1d78a645 100644
--- a/app/assets/javascripts/releases/detail/store/mutation_types.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js
diff --git a/app/assets/javascripts/releases/detail/store/mutations.js b/app/assets/javascripts/releases/stores/modules/detail/mutations.js
index d739978d755..d739978d755 100644
--- a/app/assets/javascripts/releases/detail/store/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/mutations.js
diff --git a/app/assets/javascripts/releases/detail/store/state.js b/app/assets/javascripts/releases/stores/modules/detail/state.js
index 7e3d975f1ae..7e3d975f1ae 100644
--- a/app/assets/javascripts/releases/detail/store/state.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/state.js
diff --git a/app/assets/javascripts/releases/list/store/actions.js b/app/assets/javascripts/releases/stores/modules/list/actions.js
index b15fb69226f..b15fb69226f 100644
--- a/app/assets/javascripts/releases/list/store/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/list/actions.js
diff --git a/app/assets/javascripts/releases/stores/modules/list/index.js b/app/assets/javascripts/releases/stores/modules/list/index.js
new file mode 100644
index 00000000000..e4633b15a0c
--- /dev/null
+++ b/app/assets/javascripts/releases/stores/modules/list/index.js
@@ -0,0 +1,10 @@
+import state from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+export default {
+ namespaced: true,
+ actions,
+ mutations,
+ state,
+};
diff --git a/app/assets/javascripts/releases/list/store/mutation_types.js b/app/assets/javascripts/releases/stores/modules/list/mutation_types.js
index a74bf15c515..a74bf15c515 100644
--- a/app/assets/javascripts/releases/list/store/mutation_types.js
+++ b/app/assets/javascripts/releases/stores/modules/list/mutation_types.js
diff --git a/app/assets/javascripts/releases/list/store/mutations.js b/app/assets/javascripts/releases/stores/modules/list/mutations.js
index 99fc096264a..99fc096264a 100644
--- a/app/assets/javascripts/releases/list/store/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/list/mutations.js
diff --git a/app/assets/javascripts/releases/list/store/state.js b/app/assets/javascripts/releases/stores/modules/list/state.js
index c251f56c9c5..c251f56c9c5 100644
--- a/app/assets/javascripts/releases/list/store/state.js
+++ b/app/assets/javascripts/releases/stores/modules/list/state.js
diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
index 82601363aa4..88d174f96ed 100644
--- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
+++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
@@ -62,9 +62,21 @@ export default {
return (
report.existing_failures.length > 0 ||
report.new_failures.length > 0 ||
- report.resolved_failures.length > 0
+ report.resolved_failures.length > 0 ||
+ report.existing_errors.length > 0 ||
+ report.new_errors.length > 0 ||
+ report.resolved_errors.length > 0
);
},
+ unresolvedIssues(report) {
+ return report.existing_failures.concat(report.existing_errors);
+ },
+ newIssues(report) {
+ return report.new_failures.concat(report.new_errors);
+ },
+ resolvedIssues(report) {
+ return report.resolved_failures.concat(report.resolved_errors);
+ },
},
};
</script>
@@ -87,9 +99,9 @@ export default {
<issues-list
v-if="shouldRenderIssuesList(report)"
:key="`issues-list-${i}`"
- :unresolved-issues="report.existing_failures"
- :new-issues="report.new_failures"
- :resolved-issues="report.resolved_failures"
+ :unresolved-issues="unresolvedIssues(report)"
+ :new-issues="newIssues(report)"
+ :resolved-issues="resolvedIssues(report)"
:component="$options.componentNames.TestIssueBody"
class="report-block-group-list"
/>
diff --git a/app/assets/javascripts/reports/components/modal.vue b/app/assets/javascripts/reports/components/modal.vue
index 40ce200befb..78c355ecb76 100644
--- a/app/assets/javascripts/reports/components/modal.vue
+++ b/app/assets/javascripts/reports/components/modal.vue
@@ -46,8 +46,8 @@ export default {
</a>
</template>
- <template v-else-if="field.type === $options.fieldTypes.miliseconds">{{
- sprintf(__('%{value} ms'), { value: field.value })
+ <template v-else-if="field.type === $options.fieldTypes.seconds">{{
+ sprintf(__('%{value} s'), { value: field.value })
}}</template>
<template v-else-if="field.type === $options.fieldTypes.text">
diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js
index 66ac1af062b..1845b51e6b2 100644
--- a/app/assets/javascripts/reports/constants.js
+++ b/app/assets/javascripts/reports/constants.js
@@ -1,7 +1,7 @@
export const fieldTypes = {
codeBock: 'codeBlock',
link: 'link',
- miliseconds: 'miliseconds',
+ seconds: 'seconds',
text: 'text',
};
diff --git a/app/assets/javascripts/reports/store/mutations.js b/app/assets/javascripts/reports/store/mutations.js
index 2a37f5b74fa..68f6de3a7ee 100644
--- a/app/assets/javascripts/reports/store/mutations.js
+++ b/app/assets/javascripts/reports/store/mutations.js
@@ -16,6 +16,7 @@ export default {
state.summary.total = response.summary.total;
state.summary.resolved = response.summary.resolved;
state.summary.failed = response.summary.failed;
+ state.summary.errored = response.summary.errored;
state.status = response.status;
state.reports = response.suites;
@@ -29,6 +30,7 @@ export default {
total: 0,
resolved: 0,
failed: 0,
+ errored: 0,
};
state.status = null;
},
diff --git a/app/assets/javascripts/reports/store/state.js b/app/assets/javascripts/reports/store/state.js
index 25f9f70d095..4f9eb53e787 100644
--- a/app/assets/javascripts/reports/store/state.js
+++ b/app/assets/javascripts/reports/store/state.js
@@ -13,6 +13,7 @@ export default () => ({
total: 0,
resolved: 0,
failed: 0,
+ errored: 0,
},
/**
@@ -23,10 +24,14 @@ export default () => ({
* total: {Number},
* resolved: {Number},
* failed: {Number},
+ * errored: {Number},
* },
* new_failures: {Array.<Object>},
* resolved_failures: {Array.<Object>},
* existing_failures: {Array.<Object>},
+ * new_errors: {Array.<Object>},
+ * resolved_errors: {Array.<Object>},
+ * existing_errors: {Array.<Object>},
* }
*/
reports: [],
@@ -48,7 +53,7 @@ export default () => ({
execution_time: {
value: null,
text: s__('Reports|Execution time'),
- type: fieldTypes.miliseconds,
+ type: fieldTypes.seconds,
},
failure: {
value: null,
diff --git a/app/assets/javascripts/reports/store/utils.js b/app/assets/javascripts/reports/store/utils.js
index 7381f038eaf..ce3ffaae703 100644
--- a/app/assets/javascripts/reports/store/utils.js
+++ b/app/assets/javascripts/reports/store/utils.js
@@ -8,10 +8,11 @@ import {
} from '../constants';
const textBuilder = results => {
- const { failed, resolved, total } = results;
+ const { failed, errored, resolved, total } = results;
- const failedString = failed
- ? n__('%d failed/error test result', '%d failed/error test results', failed)
+ const failedOrErrored = (failed || 0) + (errored || 0);
+ const failedString = failedOrErrored
+ ? n__('%d failed/error test result', '%d failed/error test results', failedOrErrored)
: null;
const resolvedString = resolved
? n__('%d fixed test result', '%d fixed test results', resolved)
@@ -20,7 +21,7 @@ const textBuilder = results => {
let resultsString = s__('Reports|no changed test results');
- if (failed) {
+ if (failedOrErrored) {
if (resolved) {
resultsString = sprintf(s__('Reports|%{failedString} and %{resolvedString}'), {
failedString,
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index f6b9ea5d30d..751565ad542 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -34,7 +34,10 @@ export default {
projectPath: this.projectPath,
};
},
- update: data => data.project.userPermissions,
+ update: data => data.project?.userPermissions,
+ error(error) {
+ throw error;
+ },
},
},
mixins: [getRefMixin],
@@ -42,7 +45,7 @@ export default {
currentPath: {
type: String,
required: false,
- default: '/',
+ default: '',
},
canCollaborate: {
type: Boolean,
@@ -104,10 +107,16 @@ export default {
return acc.concat({
name,
path,
- to: `/tree/${this.ref}${path}`,
+ to: `/-/tree/${escape(this.ref)}${escape(path)}`,
});
},
- [{ name: this.projectShortPath, path: '/', to: `/tree/${this.ref}/` }],
+ [
+ {
+ name: this.projectShortPath,
+ path: '/',
+ to: `/-/tree/${escape(this.ref)}/`,
+ },
+ ],
);
},
canCreateMrFromFork() {
@@ -124,7 +133,7 @@ export default {
},
{
attrs: {
- href: `${this.newBlobPath}${this.currentPath}`,
+ href: `${this.newBlobPath}/${this.currentPath ? escape(this.currentPath) : ''}`,
class: 'qa-new-file-option',
},
text: __('New file'),
@@ -172,7 +181,7 @@ export default {
);
}
- if (this.userPermissions.pushCode) {
+ if (this.userPermissions?.pushCode) {
items.push(
{
type: ROW_TYPES.divider,
@@ -233,7 +242,7 @@ export default {
<template slot="button-content">
<span class="sr-only">{{ __('Add to tree') }}</span>
<icon name="plus" :size="16" class="float-left" />
- <icon name="arrow-down" :size="16" class="float-left" />
+ <icon name="chevron-down" :size="16" class="float-left" />
</template>
<template v-for="(item, i) in dropdownItems">
<component :is="getComponent(item.type)" :key="i" v-bind="item.attrs">
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index fe1724acf89..968bd9af84f 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -40,16 +40,19 @@ export default {
};
},
update: data => {
- const pipelines = data.project.repository.tree.lastCommit.pipelines.edges;
+ const pipelines = data.project?.repository?.tree?.lastCommit?.pipelines?.edges;
return {
- ...data.project.repository.tree.lastCommit,
- pipeline: pipelines.length && pipelines[0].node,
+ ...data.project?.repository?.tree?.lastCommit,
+ pipeline: pipelines?.length && pipelines[0].node,
};
},
context: {
isSingleRequest: true,
},
+ error(error) {
+ throw error;
+ },
},
},
props: {
@@ -62,7 +65,7 @@ export default {
data() {
return {
projectPath: '',
- commit: {},
+ commit: null,
showDescription: false,
};
},
@@ -79,6 +82,11 @@ export default {
return this.commit.sha.substr(0, 8);
},
},
+ watch: {
+ currentPath() {
+ this.commit = null;
+ },
+ },
methods: {
toggleShowDescription() {
this.showDescription = !this.showDescription;
@@ -91,7 +99,7 @@ export default {
<template>
<div class="info-well d-none d-sm-flex project-last-commit commit p-3">
<gl-loading-icon v-if="isLoading" size="md" color="dark" class="m-auto" />
- <template v-else>
+ <template v-else-if="commit">
<user-avatar-link
v-if="commit.author"
:link-href="commit.author.webUrl"
@@ -100,7 +108,12 @@ export default {
class="avatar-cell"
/>
<span v-else class="avatar-cell user-avatar-link">
- <img :src="$options.defaultAvatarUrl" width="40" height="40" class="avatar s40" />
+ <img
+ :src="commit.authorGravatar || $options.defaultAvatarUrl"
+ width="40"
+ height="40"
+ class="avatar s40"
+ />
</span>
<div class="commit-detail flex-list">
<div class="commit-content qa-commit-content">
@@ -138,9 +151,8 @@ export default {
v-if="commit.description"
:class="{ 'd-block': showDescription }"
class="commit-row-description append-bottom-8"
+ >{{ commit.description }}</pre
>
- {{ commit.description }}
- </pre>
</div>
<div class="commit-actions flex-row">
<div v-if="commit.signatureHtml" v-html="commit.signatureHtml"></div>
diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue
index 29a3340b83d..2ba170998e8 100644
--- a/app/assets/javascripts/repository/components/table/index.vue
+++ b/app/assets/javascripts/repository/components/table/index.vue
@@ -71,7 +71,12 @@ export default {
<template>
<div class="tree-content-holder">
<div class="table-holder bordered-box">
- <table :aria-label="tableCaption" class="table tree-table qa-file-tree" aria-live="polite">
+ <table
+ :aria-label="tableCaption"
+ class="table tree-table"
+ aria-live="polite"
+ data-qa-selector="file_tree_table"
+ >
<table-header v-once />
<tbody>
<parent-row
diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue
index 70a188f98cc..a5c6c9822fb 100644
--- a/app/assets/javascripts/repository/components/table/parent_row.vue
+++ b/app/assets/javascripts/repository/components/table/parent_row.vue
@@ -28,7 +28,7 @@ export default {
return splitArray.join('/');
},
parentRoute() {
- return { path: `/tree/${this.commitRef}/${this.parentPath}` };
+ return { path: `/-/tree/${escape(this.commitRef)}/${escape(this.parentPath)}` };
},
},
methods: {
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index a8e13241c37..c905c39bbba 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -1,4 +1,5 @@
<script>
+import { escapeRegExp } from 'lodash';
import { GlBadge, GlLink, GlSkeletonLoading, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui';
import { visitUrl } from '~/lib/utils/url_utility';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -90,7 +91,7 @@ export default {
},
computed: {
routerLinkTo() {
- return this.isFolder ? { path: `/tree/${this.ref}/${this.path}` } : null;
+ return this.isFolder ? { path: `/-/tree/${escape(this.ref)}/${escape(this.path)}` } : null;
},
iconName() {
return `fa-${getIconName(this.type, this.path)}`;
@@ -105,7 +106,7 @@ export default {
return this.isFolder ? 'router-link' : 'a';
},
fullPath() {
- return this.path.replace(new RegExp(`^${this.currentPath}/`), '');
+ return this.path.replace(new RegExp(`^${escapeRegExp(this.currentPath)}/`), '');
},
shortSha() {
return this.sha.slice(0, 8);
@@ -138,7 +139,13 @@ export default {
class="d-inline-block align-text-bottom fa-fw"
/>
<i v-else :aria-label="type" role="img" :class="iconName" class="fa fa-fw"></i>
- <component :is="linkComponent" :to="routerLinkTo" :href="url" class="str-truncated">
+ <component
+ :is="linkComponent"
+ :to="routerLinkTo"
+ :href="url"
+ class="str-truncated"
+ data-qa-selector="file_name_link"
+ >
{{ fullPath }}
</component>
<!-- eslint-disable-next-line @gitlab/vue-i18n/no-bare-strings -->
diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue
index 92e33b013c3..7b34e9ef60d 100644
--- a/app/assets/javascripts/repository/components/tree_content.vue
+++ b/app/assets/javascripts/repository/components/tree_content.vue
@@ -86,7 +86,8 @@ export default {
},
})
.then(({ data }) => {
- if (!data) return;
+ if (data.errors) throw data.errors;
+ if (!data?.project?.repository) return;
const pageInfo = this.hasNextPage(data.project.repository.tree);
@@ -99,12 +100,15 @@ export default {
{},
);
- if (pageInfo && pageInfo.hasNextPage) {
+ if (pageInfo?.hasNextPage) {
this.nextPageCursor = pageInfo.endCursor;
this.fetchFiles();
}
})
- .catch(() => createFlash(__('An error occurred while fetching folder content.')));
+ .catch(error => {
+ createFlash(__('An error occurred while fetching folder content.'));
+ throw error;
+ });
},
normalizeData(key, data) {
return this.entries[key].concat(data.map(({ node }) => node));
diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js
index 6936c08d852..265df20636b 100644
--- a/app/assets/javascripts/repository/graphql.js
+++ b/app/assets/javascripts/repository/graphql.js
@@ -48,7 +48,7 @@ const defaultClient = createDefaultClient(
case 'TreeEntry':
case 'Submodule':
case 'Blob':
- return `${obj.flatPath}-${obj.id}`;
+ return `${escape(obj.flatPath)}-${obj.id}`;
default:
// If the type doesn't match any of the above we fallback
// to using the default Apollo ID
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 2ef0c078f13..637060f6ed9 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -23,13 +23,13 @@ export default function setupVueRepositoryList() {
projectPath,
projectShortPath,
ref,
- vueFileListLfsBadge: gon?.features?.vueFileListLfsBadge,
+ vueFileListLfsBadge: gon.features?.vueFileListLfsBadge || false,
commits: [],
},
});
- router.afterEach(({ params: { pathMatch } }) => {
- setTitle(pathMatch, ref, fullName);
+ router.afterEach(({ params: { path } }) => {
+ setTitle(path, ref, fullName);
});
const breadcrumbEl = document.getElementById('js-repo-breadcrumb');
@@ -48,9 +48,9 @@ export default function setupVueRepositoryList() {
newDirPath,
} = breadcrumbEl.dataset;
- router.afterEach(({ params: { pathMatch = '/' } }) => {
- updateFormAction('.js-upload-blob-form', uploadPath, pathMatch);
- updateFormAction('.js-create-dir-form', newDirPath, pathMatch);
+ router.afterEach(({ params: { path = '/' } }) => {
+ updateFormAction('.js-upload-blob-form', uploadPath, path);
+ updateFormAction('.js-create-dir-form', newDirPath, path);
});
// eslint-disable-next-line no-new
@@ -61,7 +61,7 @@ export default function setupVueRepositoryList() {
render(h) {
return h(Breadcrumbs, {
props: {
- currentPath: this.$route.params.pathMatch,
+ currentPath: this.$route.params.path,
canCollaborate: parseBoolean(canCollaborate),
canEditTree: parseBoolean(canEditTree),
newBranchPath,
@@ -84,7 +84,7 @@ export default function setupVueRepositoryList() {
render(h) {
return h(LastCommit, {
props: {
- currentPath: this.$route.params.pathMatch,
+ currentPath: this.$route.params.path,
},
});
},
@@ -100,7 +100,7 @@ export default function setupVueRepositoryList() {
render(h) {
return h(TreeActionLink, {
props: {
- path: historyLink + (this.$route.params.pathMatch || '/'),
+ path: `${historyLink}/${this.$route.params.path ? escape(this.$route.params.path) : ''}`,
text: __('History'),
},
});
@@ -117,7 +117,7 @@ export default function setupVueRepositoryList() {
render(h) {
return h(TreeActionLink, {
props: {
- path: webIDEUrl(`/${projectPath}/edit/${ref}/-${this.$route.params.pathMatch || '/'}`),
+ path: webIDEUrl(`/${projectPath}/edit/${ref}/-/${this.$route.params.path || ''}`),
text: __('Web IDE'),
cssClass: 'qa-web-ide-button',
},
@@ -134,7 +134,7 @@ export default function setupVueRepositoryList() {
el: directoryDownloadLinks,
router,
render(h) {
- const currentPath = this.$route.params.pathMatch || '/';
+ const currentPath = this.$route.params.path || '/';
if (currentPath !== '/') {
return h(DirectoryDownloadLinks, {
diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js
index 6498725adb6..192e410b36f 100644
--- a/app/assets/javascripts/repository/log_tree.js
+++ b/app/assets/javascripts/repository/log_tree.js
@@ -27,7 +27,9 @@ export function fetchLogsTree(client, path, offset, resolver = null) {
fetchpromise = axios
.get(
- `${gon.relative_url_root}/${projectPath}/refs/${ref}/logs_tree/${path.replace(/^\//, '')}`,
+ `${gon.relative_url_root}/${projectPath}/-/refs/${escape(ref)}/logs_tree/${escape(
+ path.replace(/^\//, ''),
+ )}`,
{
params: { format: 'json', offset },
},
diff --git a/app/assets/javascripts/repository/mixins/preload.js b/app/assets/javascripts/repository/mixins/preload.js
index e68996245a8..cb6c2294679 100644
--- a/app/assets/javascripts/repository/mixins/preload.js
+++ b/app/assets/javascripts/repository/mixins/preload.js
@@ -13,10 +13,10 @@ export default {
return { projectPath: '', loadingPath: null };
},
beforeRouteUpdate(to, from, next) {
- this.preload(to.params.pathMatch, next);
+ this.preload(to.params.path, next);
},
methods: {
- preload(path, next) {
+ preload(path = '/', next) {
this.loadingPath = path.replace(/^\//, '');
return this.$apollo
diff --git a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
index c812614e94d..a22cadf0e8d 100644
--- a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
+++ b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
@@ -10,6 +10,7 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) {
webUrl
authoredDate
authorName
+ authorGravatar
author {
name
avatarUrl
diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js
index ebf0a7091ea..2386773699c 100644
--- a/app/assets/javascripts/repository/router.js
+++ b/app/assets/javascripts/repository/router.js
@@ -12,11 +12,11 @@ export default function createRouter(base, baseRef) {
base: joinPaths(gon.relative_url_root || '', base),
routes: [
{
- path: `/tree/${baseRef}(/.*)?`,
+ path: `(/-)?/tree/${escape(baseRef)}/:path*`,
name: 'treePath',
component: TreePage,
props: route => ({
- path: route.params.pathMatch && (route.params.pathMatch.replace(/^\//, '') || '/'),
+ path: route.params.path?.replace(/^\//, '') || '/',
}),
},
{
diff --git a/app/assets/javascripts/repository/utils/dom.js b/app/assets/javascripts/repository/utils/dom.js
index 81565a00d82..abf726194ac 100644
--- a/app/assets/javascripts/repository/utils/dom.js
+++ b/app/assets/javascripts/repository/utils/dom.js
@@ -1,3 +1,5 @@
+import { joinPaths } from '~/lib/utils/url_utility';
+
export const updateElementsVisibility = (selector, isVisible) => {
document.querySelectorAll(selector).forEach(elem => elem.classList.toggle('hidden', !isVisible));
};
@@ -6,6 +8,6 @@ export const updateFormAction = (selector, basePath, path) => {
const form = document.querySelector(selector);
if (form) {
- form.action = `${basePath}${path}`;
+ form.action = joinPaths(basePath, path);
}
};
diff --git a/app/assets/javascripts/repository/utils/title.js b/app/assets/javascripts/repository/utils/title.js
index ff16fbdd420..9c4b334a1ce 100644
--- a/app/assets/javascripts/repository/utils/title.js
+++ b/app/assets/javascripts/repository/utils/title.js
@@ -1,5 +1,5 @@
const DEFAULT_TITLE = '· GitLab';
-// eslint-disable-next-line import/prefer-default-export
+
export const setTitle = (pathMatch, ref, project) => {
if (!pathMatch) {
document.title = `${project} ${DEFAULT_TITLE}`;
@@ -12,3 +12,15 @@ export const setTitle = (pathMatch, ref, project) => {
/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project} ${DEFAULT_TITLE}`;
};
+
+export function updateRefPortionOfTitle(sha, doc = document) {
+ const { title = '' } = doc;
+ const titleParts = title.split(' · ');
+
+ if (titleParts.length > 1) {
+ titleParts[1] = sha;
+
+ /* eslint-disable-next-line no-param-reassign */
+ doc.title = titleParts.join(' · ');
+ }
+}
diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
index 2f364eae67f..6b19a72317c 100644
--- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
+++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
@@ -106,7 +106,7 @@ export default {
saveChangesSelfMonitorProject() {
if (this.projectCreated && !this.projectEnabled) {
this.showSelfMonitorModal();
- } else {
+ } else if (!this.projectCreated && !this.loading) {
this.createProject();
}
},
diff --git a/app/assets/javascripts/self_monitor/index.js b/app/assets/javascripts/self_monitor/index.js
index 42c94e11989..7db87b4c627 100644
--- a/app/assets/javascripts/self_monitor/index.js
+++ b/app/assets/javascripts/self_monitor/index.js
@@ -4,15 +4,12 @@ import SelfMonitorForm from './components/self_monitor_form.vue';
export default () => {
const el = document.querySelector('.js-self-monitoring-settings');
- let selfMonitorProjectCreated;
if (el) {
- selfMonitorProjectCreated = el.dataset.selfMonitoringProjectExists;
// eslint-disable-next-line no-new
new Vue({
el,
store: store({
- projectEnabled: selfMonitorProjectCreated,
...el.dataset,
}),
render(createElement) {
diff --git a/app/assets/javascripts/self_monitor/store/actions.js b/app/assets/javascripts/self_monitor/store/actions.js
index f8430a9b136..10deec6921c 100644
--- a/app/assets/javascripts/self_monitor/store/actions.js
+++ b/app/assets/javascripts/self_monitor/store/actions.js
@@ -52,7 +52,7 @@ export const requestCreateProjectStatus = ({ dispatch, state }, jobId) => {
});
};
-export const requestCreateProjectSuccess = ({ commit }, selfMonitorData) => {
+export const requestCreateProjectSuccess = ({ commit, dispatch }, selfMonitorData) => {
commit(types.SET_LOADING, false);
commit(types.SET_PROJECT_URL, selfMonitorData.project_full_path);
commit(types.SET_ALERT_CONTENT, {
@@ -62,6 +62,7 @@ export const requestCreateProjectSuccess = ({ commit }, selfMonitorData) => {
});
commit(types.SET_SHOW_ALERT, true);
commit(types.SET_PROJECT_CREATED, true);
+ dispatch('setSelfMonitor', true);
};
export const requestCreateProjectError = ({ commit }, error) => {
diff --git a/app/assets/javascripts/self_monitor/store/state.js b/app/assets/javascripts/self_monitor/store/state.js
index b8b4a4af614..a0ce88ff58c 100644
--- a/app/assets/javascripts/self_monitor/store/state.js
+++ b/app/assets/javascripts/self_monitor/store/state.js
@@ -1,8 +1,8 @@
import { parseBoolean } from '~/lib/utils/common_utils';
export default (initialState = {}) => ({
- projectEnabled: parseBoolean(initialState.projectEnabled) || false,
- projectCreated: parseBoolean(initialState.selfMonitorProjectCreated) || false,
+ projectEnabled: parseBoolean(initialState.selfMonitoringProjectExists) || false,
+ projectCreated: parseBoolean(initialState.selfMonitoringProjectExists) || false,
createProjectEndpoint: initialState.createSelfMonitoringProjectPath || '',
deleteProjectEndpoint: initialState.deleteSelfMonitoringProjectPath || '',
createProjectStatusEndpoint: initialState.statusCreateSelfMonitoringProjectPath || '',
diff --git a/app/assets/javascripts/serverless/components/environment_row.vue b/app/assets/javascripts/serverless/components/environment_row.vue
index 4d18c5c4bdd..089e0550583 100644
--- a/app/assets/javascripts/serverless/components/environment_row.vue
+++ b/app/assets/javascripts/serverless/components/environment_row.vue
@@ -47,7 +47,7 @@ export default {
<template>
<li :id="envId" :class="isOpenClass" class="group-row has-children">
<div
- class="group-row-contents d-flex justify-content-end align-items-center"
+ class="group-row-contents d-flex justify-content-end align-items-center py-2"
role="button"
@click.stop="toggleOpen"
>
diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue
index d542dad8119..2ac57ac5bcb 100644
--- a/app/assets/javascripts/serverless/components/function_details.vue
+++ b/app/assets/javascripts/serverless/components/function_details.vue
@@ -1,5 +1,5 @@
<script>
-import _ from 'underscore';
+import { isString } from 'lodash';
import { mapState, mapActions, mapGetters } from 'vuex';
import PodBox from './pod_box.vue';
import Url from './url.vue';
@@ -42,7 +42,7 @@ export default {
return this.func.name;
},
description() {
- return _.isString(this.func.description) ? this.func.description : '';
+ return isString(this.func.description) ? this.func.description : '';
},
funcUrl() {
return this.func.url;
diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue
index 4b3bb078eae..bbafdd7f8f1 100644
--- a/app/assets/javascripts/serverless/components/function_row.vue
+++ b/app/assets/javascripts/serverless/components/function_row.vue
@@ -1,5 +1,5 @@
<script>
-import _ from 'underscore';
+import { isString } from 'lodash';
import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
import Url from './url.vue';
import { visitUrl } from '~/lib/utils/url_utility';
@@ -20,7 +20,7 @@ export default {
return this.func.name;
},
description() {
- if (!_.isString(this.func.description)) {
+ if (!isString(this.func.description)) {
return '';
}
@@ -63,7 +63,7 @@ export default {
<template>
<li :id="name" class="group-row">
- <div class="group-row-contents" role="button" @click="openDetails">
+ <div class="group-row-contents py-2" role="button" @click="openDetails">
<p class="float-right text-right">
<span>{{ image }}</span
><br />
diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue
index cdbf57f3e55..e06149f2bcb 100644
--- a/app/assets/javascripts/serverless/components/functions.vue
+++ b/app/assets/javascripts/serverless/components/functions.vue
@@ -74,7 +74,7 @@ export default {
</script>
<template>
- <section id="serverless-functions">
+ <section id="serverless-functions" class="flex-grow">
<gl-loading-icon
v-if="checkingInstalled"
:size="2"
diff --git a/app/assets/javascripts/serverless/components/url.vue b/app/assets/javascripts/serverless/components/url.vue
index 5e30c8d614e..d6de5e56a5c 100644
--- a/app/assets/javascripts/serverless/components/url.vue
+++ b/app/assets/javascripts/serverless/components/url.vue
@@ -1,12 +1,8 @@
<script>
-import { GlButton } from '@gitlab/ui';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
-import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
- Icon,
- GlButton,
ClipboardButton,
},
props: {
@@ -26,13 +22,5 @@ export default {
:title="s__('ServerlessURL|Copy URL')"
class="input-group-text js-clipboard-btn"
/>
- <gl-button
- :href="uri"
- target="_blank"
- rel="noopener noreferrer nofollow"
- class="input-group-text btn btn-default"
- >
- <icon name="external-link" />
- </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index de4a7f89449..2d505c4c96b 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -9,7 +9,7 @@ import initImageDiffHelper from './image_diff/helpers/init_image_diff';
import syntaxHighlight from './syntax_highlight';
const WRAPPER = '<div class="diff-content"></div>';
-const LOADING_HTML = '<i class="fa fa-spinner fa-spin"></i>';
+const LOADING_HTML = '<span class="spinner"></span>';
const ERROR_HTML =
'<div class="nothing-here-block"><i class="fa fa-warning"></i> Could not load diff</div>';
const COLLAPSED_HTML =
diff --git a/app/assets/javascripts/snippet/collapsible_input.js b/app/assets/javascripts/snippet/collapsible_input.js
new file mode 100644
index 00000000000..e7225162f86
--- /dev/null
+++ b/app/assets/javascripts/snippet/collapsible_input.js
@@ -0,0 +1,45 @@
+const hide = el => el.classList.add('d-none');
+const show = el => el.classList.remove('d-none');
+
+const setupCollapsibleInput = el => {
+ const collapsedEl = el.querySelector('.js-collapsed');
+ const expandedEl = el.querySelector('.js-expanded');
+ const collapsedInputEl = collapsedEl.querySelector('textarea,input,select');
+ const expandedInputEl = expandedEl.querySelector('textarea,input,select');
+ const formEl = el.closest('form');
+
+ const collapse = () => {
+ hide(expandedEl);
+ show(collapsedEl);
+ };
+
+ const expand = () => {
+ hide(collapsedEl);
+ show(expandedEl);
+ };
+
+ // NOTE:
+ // We add focus listener to all form inputs so that we can collapse
+ // when something is focused that's not the expanded input.
+ formEl.addEventListener('focusin', e => {
+ if (e.target === collapsedInputEl) {
+ expand();
+ expandedInputEl.focus();
+ } else if (!el.contains(e.target) && !expandedInputEl.value) {
+ collapse();
+ }
+ });
+};
+
+/**
+ * Usage in HAML
+ *
+ * .js-collapsible-input
+ * .js-collapsed{ class: ('d-none' if is_expanded) }
+ * = input
+ * .js-expanded{ class: ('d-none' if !is_expanded) }
+ * = big_input
+ */
+export default () => {
+ Array.from(document.querySelectorAll('.js-collapsible-input')).forEach(setupCollapsibleInput);
+};
diff --git a/app/assets/javascripts/snippet/snippet_bundle.js b/app/assets/javascripts/snippet/snippet_bundle.js
index dcee17453b8..652531a1289 100644
--- a/app/assets/javascripts/snippet/snippet_bundle.js
+++ b/app/assets/javascripts/snippet/snippet_bundle.js
@@ -1,6 +1,7 @@
/* global ace */
import $ from 'jquery';
+import setupCollapsibleInputs from './collapsible_input';
export default () => {
const editor = ace.edit('editor');
@@ -8,4 +9,6 @@ export default () => {
$('.snippet-form-holder form').on('submit', () => {
$('.snippet-file-content').val(editor.getValue());
});
+
+ setupCollapsibleInputs();
};
diff --git a/app/assets/javascripts/snippets/components/app.vue b/app/assets/javascripts/snippets/components/app.vue
index 7a2145a800c..e98f56d87f5 100644
--- a/app/assets/javascripts/snippets/components/app.vue
+++ b/app/assets/javascripts/snippets/components/app.vue
@@ -2,6 +2,7 @@
import GetSnippetQuery from '../queries/snippet.query.graphql';
import SnippetHeader from './snippet_header.vue';
import SnippetTitle from './snippet_title.vue';
+import SnippetBlob from './snippet_blob_view.vue';
import { GlLoadingIcon } from '@gitlab/ui';
export default {
@@ -9,6 +10,7 @@ export default {
SnippetHeader,
SnippetTitle,
GlLoadingIcon,
+ SnippetBlob,
},
apollo: {
snippet: {
@@ -50,6 +52,7 @@ export default {
<template v-else>
<snippet-header :snippet="snippet" />
<snippet-title :snippet="snippet" />
+ <snippet-blob :snippet="snippet" />
</template>
</div>
</template>
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue
new file mode 100644
index 00000000000..4703a940e08
--- /dev/null
+++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue
@@ -0,0 +1,97 @@
+<script>
+import BlobEmbeddable from '~/blob/components/blob_embeddable.vue';
+import { SNIPPET_VISIBILITY_PUBLIC } from '../constants';
+import BlobHeader from '~/blob/components/blob_header.vue';
+import BlobContent from '~/blob/components/blob_content.vue';
+import { GlLoadingIcon } from '@gitlab/ui';
+
+import GetSnippetBlobQuery from '../queries/snippet.blob.query.graphql';
+import GetBlobContent from '../queries/snippet.blob.content.query.graphql';
+
+import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constants';
+
+export default {
+ components: {
+ BlobEmbeddable,
+ BlobHeader,
+ BlobContent,
+ GlLoadingIcon,
+ },
+ apollo: {
+ blob: {
+ query: GetSnippetBlobQuery,
+ variables() {
+ return {
+ ids: this.snippet.id,
+ };
+ },
+ update: data => data.snippets.edges[0].node.blob,
+ result(res) {
+ const viewer = res.data.snippets.edges[0].node.blob.richViewer
+ ? RICH_BLOB_VIEWER
+ : SIMPLE_BLOB_VIEWER;
+ this.switchViewer(viewer, true);
+ },
+ },
+ blobContent: {
+ query: GetBlobContent,
+ variables() {
+ return {
+ ids: this.snippet.id,
+ rich: this.activeViewerType === RICH_BLOB_VIEWER,
+ };
+ },
+ update: data =>
+ data.snippets.edges[0].node.blob.richData || data.snippets.edges[0].node.blob.plainData,
+ },
+ },
+ props: {
+ snippet: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ blob: {},
+ blobContent: '',
+ activeViewerType: window.location.hash ? SIMPLE_BLOB_VIEWER : '',
+ };
+ },
+ computed: {
+ embeddable() {
+ return this.snippet.visibilityLevel === SNIPPET_VISIBILITY_PUBLIC;
+ },
+ isBlobLoading() {
+ return this.$apollo.queries.blob.loading;
+ },
+ isContentLoading() {
+ return this.$apollo.queries.blobContent.loading;
+ },
+ viewer() {
+ const { richViewer, simpleViewer } = this.blob;
+ return this.activeViewerType === RICH_BLOB_VIEWER ? richViewer : simpleViewer;
+ },
+ },
+ methods: {
+ switchViewer(newViewer, respectHash = false) {
+ this.activeViewerType = respectHash && window.location.hash ? SIMPLE_BLOB_VIEWER : newViewer;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <blob-embeddable v-if="embeddable" class="mb-3" :url="snippet.webUrl" />
+ <gl-loading-icon
+ v-if="isBlobLoading"
+ :label="__('Loading blob')"
+ size="lg"
+ class="prepend-top-20 append-bottom-20"
+ />
+ <article v-else class="file-holder snippet-file-content">
+ <blob-header :blob="blob" :active-viewer-type="viewer.type" @viewer-changed="switchViewer" />
+ <blob-content :loading="isContentLoading" :content="blobContent" :active-viewer="viewer" />
+ </article>
+ </div>
+</template>
diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue
index e8f1bfeaf43..36ba6eeecbd 100644
--- a/app/assets/javascripts/snippets/components/snippet_header.vue
+++ b/app/assets/javascripts/snippets/components/snippet_header.vue
@@ -165,7 +165,7 @@ export default {
<gl-icon :name="visibilityLevelIcon" :size="14" />
</div>
<div class="creator">
- <gl-sprintf message="Authored %{timeago} by %{author}">
+ <gl-sprintf :message="__('Authored %{timeago} by %{author}')">
<template #timeago>
<time-ago-tooltip
:time="snippet.createdAt"
@@ -218,7 +218,7 @@ export default {
errorMessage
}}</gl-alert>
- <gl-sprintf message="Are you sure you want to delete %{name}?">
+ <gl-sprintf :message="__('Are you sure you want to delete %{name}?')">
<template #name
><strong>{{ snippet.title }}</strong></template
>
diff --git a/app/assets/javascripts/snippets/components/snippet_title.vue b/app/assets/javascripts/snippets/components/snippet_title.vue
index fc8a9b4a390..6646e70f5db 100644
--- a/app/assets/javascripts/snippets/components/snippet_title.vue
+++ b/app/assets/javascripts/snippets/components/snippet_title.vue
@@ -25,7 +25,7 @@ export default {
</div>
<small v-if="snippet.updatedAt !== snippet.createdAt" class="edited-text">
- <gl-sprintf message="Edited %{timeago}">
+ <gl-sprintf :message="__('Edited %{timeago}')">
<template #timeago>
<time-ago-tooltip :time="snippet.updatedAt" tooltip-placement="bottom" />
</template>
diff --git a/app/assets/javascripts/snippets/constants.js b/app/assets/javascripts/snippets/constants.js
new file mode 100644
index 00000000000..87e3fe360a3
--- /dev/null
+++ b/app/assets/javascripts/snippets/constants.js
@@ -0,0 +1,3 @@
+export const SNIPPET_VISIBILITY_PRIVATE = 'private';
+export const SNIPPET_VISIBILITY_INTERNAL = 'internal';
+export const SNIPPET_VISIBILITY_PUBLIC = 'public';
diff --git a/app/assets/javascripts/snippets/fragments/author.fragment.graphql b/app/assets/javascripts/snippets/fragments/author.fragment.graphql
deleted file mode 100644
index 2684bd0fa37..00000000000
--- a/app/assets/javascripts/snippets/fragments/author.fragment.graphql
+++ /dev/null
@@ -1,8 +0,0 @@
-fragment Author on Snippet {
- author {
- name,
- avatarUrl,
- username,
- webUrl
- }
-} \ No newline at end of file
diff --git a/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql b/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql
new file mode 100644
index 00000000000..889a88dd93c
--- /dev/null
+++ b/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql
@@ -0,0 +1,13 @@
+query SnippetBlobContent($ids: [ID!], $rich: Boolean!) {
+ snippets(ids: $ids) {
+ edges {
+ node {
+ id
+ blob {
+ richData @include(if: $rich)
+ plainData @skip(if: $rich)
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/snippets/queries/snippet.blob.query.graphql b/app/assets/javascripts/snippets/queries/snippet.blob.query.graphql
new file mode 100644
index 00000000000..785c88c185a
--- /dev/null
+++ b/app/assets/javascripts/snippets/queries/snippet.blob.query.graphql
@@ -0,0 +1,24 @@
+#import '~/graphql_shared/fragments/blobviewer.fragment.graphql'
+
+query SnippetBlobFull($ids: [ID!]) {
+ snippets(ids: $ids) {
+ edges {
+ node {
+ id
+ blob {
+ binary
+ name
+ path
+ rawPath
+ size
+ simpleViewer {
+ ...BlobViewer
+ }
+ richViewer {
+ ...BlobViewer
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/snippets/queries/snippet.query.graphql b/app/assets/javascripts/snippets/queries/snippet.query.graphql
index 1cb2c86c4d8..c58a5168ba3 100644
--- a/app/assets/javascripts/snippets/queries/snippet.query.graphql
+++ b/app/assets/javascripts/snippets/queries/snippet.query.graphql
@@ -1,6 +1,6 @@
#import '../fragments/snippetBase.fragment.graphql'
#import '../fragments/project.fragment.graphql'
-#import '../fragments/author.fragment.graphql'
+#import "~/graphql_shared/fragments/author.fragment.graphql"
query GetSnippetQuery($ids: [ID!]) {
snippets(ids: $ids) {
@@ -8,7 +8,9 @@ query GetSnippetQuery($ids: [ID!]) {
node {
...SnippetBase
...Project
- ...Author
+ author {
+ ...Author
+ }
}
}
}
diff --git a/app/assets/javascripts/test_utils/index.js b/app/assets/javascripts/test_utils/index.js
index 1a1f3e8d0a8..2727485fb95 100644
--- a/app/assets/javascripts/test_utils/index.js
+++ b/app/assets/javascripts/test_utils/index.js
@@ -1,5 +1,3 @@
-import 'core-js/es/map';
-import 'core-js/es/set';
import { Sortable } from 'sortablejs';
import simulateDrag from './simulate_drag';
import simulateInput from './simulate_input';
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index 157d89a3a40..5cc22f62262 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -3,108 +3,102 @@ import Vue from 'vue';
import UsersCache from './lib/utils/users_cache';
import UserPopover from './vue_shared/components/user_popover/user_popover.vue';
-let renderedPopover;
-let renderFn;
-
-const handleUserPopoverMouseOut = event => {
- const { target } = event;
- target.removeEventListener('mouseleave', handleUserPopoverMouseOut);
-
- if (renderFn) {
- clearTimeout(renderFn);
- }
- if (renderedPopover) {
- renderedPopover.$destroy();
- renderedPopover = null;
- }
- target.removeAttribute('aria-describedby');
+const removeTitle = el => {
+ // Removing titles so its not showing tooltips also
+
+ el.dataset.originalTitle = '';
+ el.setAttribute('title', '');
+};
+
+const getPreloadedUserInfo = dataset => {
+ const userId = dataset.user || dataset.userId;
+ const { username, name, avatarUrl } = dataset;
+
+ return {
+ userId,
+ username,
+ name,
+ avatarUrl,
+ };
};
/**
* Adds a UserPopover component to the body, hands over as much data as the target element has in data attributes.
* loads based on data-user-id more data about a user from the API and sets it on the popover
*/
-const handleUserPopoverMouseOver = event => {
- const { target } = event;
- // Add listener to actually remove it again
- target.addEventListener('mouseleave', handleUserPopoverMouseOut);
-
- renderFn = setTimeout(() => {
- // Helps us to use current markdown setup without maybe breaking or duplicating for now
- if (target.dataset.user) {
- target.dataset.userId = target.dataset.user;
- // Removing titles so its not showing tooltips also
- target.dataset.originalTitle = '';
- target.setAttribute('title', '');
- }
-
- const { userId, username, name, avatarUrl } = target.dataset;
- const user = {
- userId,
- username,
- name,
- avatarUrl,
- location: null,
- bio: null,
- organization: null,
- status: null,
- loaded: false,
- };
- if (userId || username) {
- const UserPopoverComponent = Vue.extend(UserPopover);
- renderedPopover = new UserPopoverComponent({
+const populateUserInfo = user => {
+ const { userId } = user;
+
+ return Promise.all([UsersCache.retrieveById(userId), UsersCache.retrieveStatusById(userId)]).then(
+ ([userData, status]) => {
+ if (userData) {
+ Object.assign(user, {
+ avatarUrl: userData.avatar_url,
+ username: userData.username,
+ name: userData.name,
+ location: userData.location,
+ bio: userData.bio,
+ organization: userData.organization,
+ loaded: true,
+ });
+ }
+
+ if (status) {
+ Object.assign(user, {
+ status,
+ });
+ }
+
+ return user;
+ },
+ );
+};
+
+const initializedPopovers = new Map();
+
+export default (elements = document.querySelectorAll('.js-user-link')) => {
+ const userLinks = Array.from(elements);
+ const UserPopoverComponent = Vue.extend(UserPopover);
+
+ return userLinks
+ .filter(({ dataset }) => dataset.user || dataset.userId)
+ .map(el => {
+ if (initializedPopovers.has(el)) {
+ return initializedPopovers.get(el);
+ }
+
+ const user = {
+ location: null,
+ bio: null,
+ organization: null,
+ status: null,
+ loaded: false,
+ };
+ const renderedPopover = new UserPopoverComponent({
propsData: {
- target,
+ target: el,
user,
},
});
+ initializedPopovers.set(el, renderedPopover);
+
renderedPopover.$mount();
- UsersCache.retrieveById(userId)
- .then(userData => {
- if (!userData) {
- return undefined;
- }
-
- Object.assign(user, {
- avatarUrl: userData.avatar_url,
- username: userData.username,
- name: userData.name,
- location: userData.location,
- bio: userData.bio,
- organization: userData.organization,
- status: userData.status,
- loaded: true,
- });
-
- if (userData.status) {
- return Promise.resolve();
- }
-
- return UsersCache.retrieveStatusById(userId);
- })
- .then(status => {
- if (!status) {
- return;
- }
-
- Object.assign(user, {
- status,
- });
- })
- .catch(() => {
- renderedPopover.$destroy();
- renderedPopover = null;
- });
- }
- }, 200); // 200ms delay so not every mouseover triggers Popover + API Call
-};
+ el.addEventListener('mouseenter', ({ target }) => {
+ removeTitle(target);
+ const preloadedUserInfo = getPreloadedUserInfo(target.dataset);
+
+ Object.assign(user, preloadedUserInfo);
-export default elements => {
- const userLinks = elements || [...document.querySelectorAll('.js-user-link')];
+ if (preloadedUserInfo.userId) {
+ populateUserInfo(user);
+ }
+ });
+ el.addEventListener('mouseleave', ({ target }) => {
+ target.removeAttribute('aria-describedby');
+ });
- userLinks.forEach(el => {
- el.addEventListener('mouseenter', handleUserPopoverMouseOver);
- });
+ return renderedPopover;
+ });
};
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index 6d7d863f273..6821df57b5a 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, prefer-rest-params, consistent-return, no-shadow, no-else-return, no-self-compare, no-unused-expressions, yoda, prefer-spread, camelcase, no-param-reassign */
+/* eslint-disable func-names, prefer-rest-params, consistent-return, no-shadow, no-else-return, no-self-compare, no-unused-expressions, yoda, prefer-spread, babel/camelcase, no-param-reassign */
/* global Issuable */
/* global emitSidebarEvent */
diff --git a/app/assets/javascripts/vue_alerts.js b/app/assets/javascripts/vue_alerts.js
new file mode 100644
index 00000000000..6550eb31491
--- /dev/null
+++ b/app/assets/javascripts/vue_alerts.js
@@ -0,0 +1,22 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import DismissibleAlert from '~/vue_shared/components/dismissible_alert.vue';
+
+const mountVueAlert = el => {
+ const props = {
+ html: el.innerHTML,
+ };
+ const attrs = {
+ ...el.dataset,
+ dismissible: parseBoolean(el.dataset.dismissible),
+ };
+
+ return new Vue({
+ el,
+ render(h) {
+ return h(DismissibleAlert, { props, attrs });
+ },
+ });
+};
+
+export default () => [...document.querySelectorAll('.js-vue-alert')].map(mountVueAlert);
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
index db4a4ece002..33db9b87b17 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
@@ -32,12 +32,12 @@ export default {
},
},
deployedTextMap: {
- [MANUAL_DEPLOY]: __('Can deploy manually to'),
+ [MANUAL_DEPLOY]: __('Can be manually deployed to'),
[WILL_DEPLOY]: __('Will deploy to'),
[RUNNING]: __('Deploying to'),
[SUCCESS]: __('Deployed to'),
[FAILED]: __('Failed to deploy to'),
- [CANCELED]: __('Canceled deploy to'),
+ [CANCELED]: __('Canceled deployment to'),
},
computed: {
deployTimeago() {
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 2aaba6e1c8a..7c71463c949 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
@@ -77,7 +77,7 @@ export default {
};
</script>
<template>
- <div class="mr-source-target append-bottom-default">
+ <div class="d-flex mr-source-target append-bottom-default">
<mr-widget-icon name="git-merge" />
<div class="git-merge-container d-flex">
<div class="normal">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue
new file mode 100644
index 00000000000..f08bfb3a90f
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue
@@ -0,0 +1,45 @@
+<script>
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import MrWidgetIcon from './mr_widget_icon.vue';
+
+export default {
+ name: 'MRWidgetSuggestPipeline',
+ iconName: 'status_notfound',
+ components: {
+ GlLink,
+ GlSprintf,
+ MrWidgetIcon,
+ },
+ props: {
+ pipelinePath: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="d-flex mr-pipeline-suggest append-bottom-default">
+ <mr-widget-icon :name="$options.iconName" />
+ <gl-sprintf
+ class="js-no-pipeline-message"
+ :message="
+ s__(`mrWidget|%{prefixToLinkStart}No pipeline%{prefixToLinkEnd}
+ %{addPipelineLinkStart}Add the .gitlab-ci.yml file%{addPipelineLinkEnd}
+ to create one.`)
+ "
+ >
+ <template #prefixToLink="{content}">
+ <strong>
+ {{ content }}
+ </strong>
+ </template>
+ <template #addPipelineLink="{content}">
+ <gl-link :href="pipelinePath" class="ml-2">
+ {{ content }}
+ </gl-link>
+ &nbsp;
+ </template>
+ </gl-sprintf>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
index cf26003d038..a5e3115397a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
@@ -12,7 +12,7 @@ export default {
<div class="mr-widget-body media">
<status-icon :show-disabled-button="true" status="loading" />
<div class="media-body space-children">
- <span class="bold"> {{ s__('mrWidget|Checking ability to merge automatically') }} </span>
+ <span class="bold"> {{ s__('mrWidget|Checking ability to merge automatically…') }} </span>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
index 75d1e5865b0..9df0c045fe4 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
@@ -61,7 +61,7 @@ export default {
eventHub.$emit('EnablePolling');
},
updateTimer() {
- this.timer = this.timer - 1;
+ this.timer -= 1;
if (this.timer === 0) {
this.refresh();
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index d230ac566de..66167a0d748 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -146,9 +146,15 @@ export default {
auto_merge_strategy: useAutoMerge ? this.mr.preferredAutoMergeStrategy : undefined,
should_remove_source_branch: this.removeSourceBranch === true,
squash: this.squashBeforeMerge,
- squash_commit_message: this.squashCommitMessage,
};
+ // If users can't alter the squash message (e.g. for 1-commit merge requests),
+ // we shouldn't send the commit message because that would make the backend
+ // do unnecessary work.
+ if (this.shouldShowSquashBeforeMerge) {
+ options.squash_commit_message = this.squashCommitMessage;
+ }
+
this.isMakingRequest = true;
this.service
.merge(options)
diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js
index 0cedbdbdfef..7a9ef7e496e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/index.js
@@ -5,6 +5,8 @@ import Translate from '../vue_shared/translate';
Vue.use(Translate);
export default () => {
+ if (gl.mrWidget) return;
+
gl.mrWidgetData.gitlabLogo = gon.gitlab_logo;
const vm = new Vue(MrWidgetOptions);
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index 38a7c262b3e..27f13ace779 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -9,6 +9,7 @@ import SmartInterval from '~/smart_interval';
import createFlash from '../flash';
import Loading from './components/loading.vue';
import WidgetHeader from './components/mr_widget_header.vue';
+import WidgetSuggestPipeline from './components/mr_widget_suggest_pipeline.vue';
import WidgetMergeHelp from './components/mr_widget_merge_help.vue';
import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue';
import Deployment from './components/deployment/deployment.vue';
@@ -46,6 +47,7 @@ export default {
components: {
Loading,
'mr-widget-header': WidgetHeader,
+ 'mr-widget-suggest-pipeline': WidgetSuggestPipeline,
'mr-widget-merge-help': WidgetMergeHelp,
MrWidgetPipelineContainer,
Deployment,
@@ -99,6 +101,9 @@ export default {
shouldRenderPipelines() {
return this.mr.hasCI;
},
+ shouldSuggestPipelines() {
+ return gon.features?.suggestPipeline && !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath;
+ },
shouldRenderRelatedLinks() {
return Boolean(this.mr.relatedLinks) && !this.mr.isNothingToMergeState;
},
@@ -121,8 +126,14 @@ export default {
);
},
mergeError() {
+ let { mergeError } = this.mr;
+
+ if (mergeError && mergeError.slice(-1) === '.') {
+ mergeError = mergeError.slice(0, -1);
+ }
+
return sprintf(s__('mrWidget|Merge failed: %{mergeError}. Please try again.'), {
- mergeError: this.mr.mergeError,
+ mergeError,
});
},
},
@@ -135,15 +146,11 @@ export default {
},
},
mounted() {
- if (gon && gon.features && gon.features.asyncMrWidget) {
- MRWidgetService.fetchInitialData()
- .then(({ data }) => this.initWidget(data))
- .catch(() =>
- createFlash(__('Unable to load the merge request widget. Try reloading the page.')),
- );
- } else {
- this.initWidget();
- }
+ MRWidgetService.fetchInitialData()
+ .then(({ data }) => this.initWidget(data))
+ .catch(() =>
+ createFlash(__('Unable to load the merge request widget. Try reloading the page.')),
+ );
},
beforeDestroy() {
eventHub.$off('mr.discussion.updated', this.checkStatus);
@@ -351,6 +358,11 @@ export default {
<template>
<div v-if="mr" class="mr-state-widget prepend-top-default">
<mr-widget-header :mr="mr" />
+ <mr-widget-suggest-pipeline
+ v-if="shouldSuggestPipelines"
+ class="mr-widget-workflow"
+ :pipeline-path="mr.mergeRequestAddCiConfigPath"
+ />
<mr-widget-pipeline-container
v-if="shouldRenderPipelines"
class="mr-widget-workflow"
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
index 3ab229567f6..a298331c1fc 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
@@ -7,7 +7,7 @@ export default function deviseState(data) {
return stateKey.missingBranch;
} else if (!data.commits_count) {
return stateKey.nothingToMerge;
- } else if (this.mergeStatus === 'unchecked') {
+ } else if (this.mergeStatus === 'unchecked' || this.mergeStatus === 'checking') {
return stateKey.checking;
} else if (data.has_conflicts) {
return stateKey.conflicts;
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 c7949fa264e..73a0b3cb673 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
@@ -175,6 +175,8 @@ export default class MergeRequestStore {
this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path;
this.eligibleApproversDocsPath = data.eligible_approvers_docs_path;
this.mergeImmediatelyDocsPath = data.merge_immediately_docs_path;
+ this.mergeRequestAddCiConfigPath = data.merge_request_add_ci_config_path;
+ this.humanAccess = data.human_access;
}
get isNothingToMergeState() {
diff --git a/app/assets/javascripts/vue_shared/components/bar_chart.vue b/app/assets/javascripts/vue_shared/components/bar_chart.vue
deleted file mode 100644
index 25d7bfe515c..00000000000
--- a/app/assets/javascripts/vue_shared/components/bar_chart.vue
+++ /dev/null
@@ -1,351 +0,0 @@
-<script>
-import * as d3 from 'd3';
-import tooltip from '../directives/tooltip';
-import Icon from './icon.vue';
-import SvgGradient from './svg_gradient.vue';
-import {
- GRADIENT_COLORS,
- GRADIENT_OPACITY,
- INVERSE_GRADIENT_COLORS,
- INVERSE_GRADIENT_OPACITY,
-} from './bar_chart_constants';
-
-/**
- * Renders a bar chart that can be dragged(scrolled) when the number
- * of elements to renders surpasses that of the available viewport space
- * while keeping even padding and a width of 24px (customizable)
- *
- * It can render data with the following format:
- * graphData: [{
- * name: 'element' // x domain data
- * value: 1 // y domain data
- * }]
- *
- * Used in:
- * - Contribution analytics - all of the rows describing pushes, merge requests and issues
- */
-
-export default {
- directives: {
- tooltip,
- },
- components: {
- Icon,
- SvgGradient,
- },
- props: {
- graphData: {
- type: Array,
- required: true,
- },
- barWidth: {
- type: Number,
- required: false,
- default: 24,
- },
- yAxisLabel: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- minX: -40,
- minY: 0,
- vbWidth: 0,
- vbHeight: 0,
- vpWidth: 0,
- vpHeight: 200,
- preserveAspectRatioType: 'xMidYMin meet',
- containerMargin: {
- leftRight: 30,
- },
- viewBoxMargin: {
- topBottom: 100,
- },
- panX: 0,
- xScale: {},
- yScale: {},
- zoom: {},
- bars: {},
- xGraphRange: 0,
- isLoading: true,
- paddingThreshold: 50,
- showScrollIndicator: false,
- showLeftScrollIndicator: false,
- isGrabbed: false,
- isPanAvailable: false,
- gradientColors: GRADIENT_COLORS,
- gradientOpacity: GRADIENT_OPACITY,
- inverseGradientColors: INVERSE_GRADIENT_COLORS,
- inverseGradientOpacity: INVERSE_GRADIENT_OPACITY,
- maxTextWidth: 72,
- rectYAxisLabelDims: {},
- xAxisTextElements: {},
- yAxisRectTransformPadding: 20,
- yAxisTextTransformPadding: 10,
- yAxisTextRotation: 90,
- };
- },
- computed: {
- svgViewBox() {
- return `${this.minX} ${this.minY} ${this.vbWidth} ${this.vbHeight}`;
- },
- xAxisLocation() {
- return `translate(${this.panX}, ${this.vbHeight})`;
- },
- barTranslationTransform() {
- return `translate(${this.panX}, 0)`;
- },
- scrollIndicatorTransform() {
- return `translate(${this.vbWidth - 80}, 0)`;
- },
- activateGrabCursor() {
- return {
- 'svg-graph-container-with-grab': this.isPanAvailable,
- 'svg-graph-container-grabbed': this.isPanAvailable && this.isGrabbed,
- };
- },
- yAxisLabelRectTransform() {
- const rectWidth =
- this.rectYAxisLabelDims.height != null ? this.rectYAxisLabelDims.height / 2 : 0;
- const yCoord = this.vbHeight / 2 - rectWidth;
-
- return `translate(${this.minX - this.yAxisRectTransformPadding}, ${yCoord})`;
- },
- yAxisLabelTextTransform() {
- const rectWidth =
- this.rectYAxisLabelDims.height != null ? this.rectYAxisLabelDims.height / 2 : 0;
- const yCoord = this.vbHeight / 2 + rectWidth - 5;
-
- return `translate(${this.minX + this.yAxisTextTransformPadding}, ${yCoord}) rotate(-${
- this.yAxisTextRotation
- })`;
- },
- },
- mounted() {
- if (!this.allValuesEmpty) {
- this.draw();
- }
- },
- methods: {
- draw() {
- // update viewport
- this.vpWidth = this.$refs.svgContainer.clientWidth - this.containerMargin.leftRight;
- // update viewbox
- this.vbWidth = this.vpWidth;
- this.vbHeight = this.vpHeight - this.viewBoxMargin.topBottom;
- let padding = 0;
- if (this.graphData.length * this.barWidth > this.vbWidth) {
- this.xGraphRange = this.graphData.length * this.barWidth;
- padding = this.calculatePadding(this.barWidth);
- this.showScrollIndicator = true;
- this.isPanAvailable = true;
- } else {
- this.xGraphRange = this.vbWidth - Math.abs(this.minX);
- }
-
- this.xScale = d3
- .scaleBand()
- .range([0, this.xGraphRange])
- .round(true)
- .paddingInner(padding);
- this.yScale = d3.scaleLinear().rangeRound([this.vbHeight, 0]);
-
- this.xScale.domain(this.graphData.map(d => d.name));
- this.yScale.domain([0, d3.max(this.graphData.map(d => d.value))]);
-
- // Zoom/Panning Function
- this.zoom = d3
- .zoom()
- .translateExtent([[0, 0], [this.xGraphRange, this.vbHeight]])
- .on('zoom', this.panGraph)
- .on('end', this.removeGrabStyling);
-
- const xAxis = d3.axisBottom().scale(this.xScale);
-
- const yAxis = d3
- .axisLeft()
- .scale(this.yScale)
- .ticks(4);
-
- const renderedXAxis = d3
- .select(this.$refs.baseSvg)
- .select('.x-axis')
- .call(xAxis);
-
- this.xAxisTextElements = this.$refs.xAxis.querySelectorAll('text');
-
- renderedXAxis.select('.domain').remove();
-
- renderedXAxis
- .selectAll('text')
- .style('text-anchor', 'end')
- .attr('dx', '-.3em')
- .attr('dy', '-.95em')
- .attr('class', 'tick-text')
- .attr('transform', 'rotate(-90)');
-
- renderedXAxis.selectAll('line').remove();
-
- const { maxTextWidth } = this;
- renderedXAxis.selectAll('text').each(function formatText() {
- const axisText = d3.select(this);
- let textLength = axisText.node().getComputedTextLength();
- let textContent = axisText.text();
- while (textLength > maxTextWidth && textContent.length > 0) {
- textContent = textContent.slice(0, -1);
- axisText.text(`${textContent}...`);
- textLength = axisText.node().getComputedTextLength();
- }
- });
-
- const width = this.vbWidth;
-
- const renderedYAxis = d3
- .select(this.$refs.baseSvg)
- .select('.y-axis')
- .call(yAxis);
-
- renderedYAxis.selectAll('.tick').each(function createTickLines(d, i) {
- if (i > 0) {
- d3.select(this)
- .select('line')
- .attr('x2', width)
- .attr('class', 'axis-tick');
- }
- });
-
- // Add the panning capabilities
- if (this.isPanAvailable) {
- d3.select(this.$refs.baseSvg)
- .call(this.zoom)
- .on('wheel.zoom', null); // This disables the pan of the graph with the scroll of the mouse wheel
- }
-
- this.isLoading = false;
- // Update the yAxisLabel coordinates
- const labelDims = this.$refs.yAxisLabel.getBBox();
- this.rectYAxisLabelDims = {
- height: labelDims.width + 10,
- };
- },
- panGraph() {
- const allowedRightScroll = this.xGraphRange - this.vbWidth - this.paddingThreshold;
- const graphMaxPan = Math.abs(d3.event.transform.x) < allowedRightScroll;
- this.isGrabbed = true;
- this.panX = d3.event.transform.x;
-
- if (d3.event.transform.x === 0) {
- this.showLeftScrollIndicator = false;
- } else {
- this.showLeftScrollIndicator = true;
- this.showScrollIndicator = true;
- }
-
- if (!graphMaxPan) {
- this.panX = -1 * (this.xGraphRange - this.vbWidth + this.paddingThreshold);
- this.showScrollIndicator = false;
- }
- },
- setTooltipTitle(data) {
- return data !== null ? `${data.name}: ${data.value}` : '';
- },
- calculatePadding(desiredBarWidth) {
- const widthWithMargin = this.vbWidth - Math.abs(this.minX);
- const dividend = widthWithMargin - this.graphData.length * desiredBarWidth;
- const divisor = widthWithMargin - desiredBarWidth;
-
- return dividend / divisor;
- },
- removeGrabStyling() {
- this.isGrabbed = false;
- },
- barHoveredIn(index) {
- this.xAxisTextElements[index].classList.add('x-axis-text');
- },
- barHoveredOut(index) {
- this.xAxisTextElements[index].classList.remove('x-axis-text');
- },
- },
-};
-</script>
-<template>
- <div ref="svgContainer" :class="activateGrabCursor" class="svg-graph-container">
- <svg
- ref="baseSvg"
- class="svg-graph overflow-visible pt-5"
- :width="vpWidth"
- :height="vpHeight"
- :viewBox="svgViewBox"
- :preserveAspectRatio="preserveAspectRatioType"
- >
- <g ref="xAxis" :transform="xAxisLocation" class="x-axis" />
- <g v-if="!isLoading">
- <template v-for="(data, index) in graphData">
- <rect
- :key="index"
- v-tooltip
- :width="xScale.bandwidth()"
- :x="xScale(data.name)"
- :y="yScale(data.value)"
- :height="vbHeight - yScale(data.value)"
- :transform="barTranslationTransform"
- :title="setTooltipTitle(data)"
- class="bar-rect"
- data-placement="top"
- @mouseover="barHoveredIn(index)"
- @mouseout="barHoveredOut(index)"
- />
- </template>
- </g>
- <rect :height="vbHeight + 100" transform="translate(-100, -5)" width="100" fill="#fff" />
- <g class="y-axis-label">
- <line :x1="0" :x2="0" :y1="0" :y2="vbHeight" transform="translate(-35, 0)" stroke="black" />
- <!-- Get text length and change the height of this rect accordingly -->
- <rect
- :height="rectYAxisLabelDims.height"
- :transform="yAxisLabelRectTransform"
- :width="30"
- fill="#fff"
- />
- <text ref="yAxisLabel" :transform="yAxisLabelTextTransform">{{ yAxisLabel }}</text>
- </g>
- <g class="y-axis" />
- <g v-if="showScrollIndicator">
- <rect
- :height="vbHeight + 100"
- :transform="`translate(${vpWidth - 60}, -5)`"
- width="40"
- fill="#fff"
- />
- <icon
- :x="vpWidth - 50"
- :y="vbHeight / 2"
- :width="14"
- :height="14"
- name="chevron-right"
- class="animate-flicker"
- />
- </g>
- <!-- The line that shows up when the data elements surpass the available width -->
- <g v-if="showScrollIndicator" :transform="scrollIndicatorTransform">
- <rect :height="vbHeight" x="0" y="0" width="20" fill="url(#shadow-gradient)" />
- </g>
- <!-- Left scroll indicator -->
- <g v-if="showLeftScrollIndicator" transform="translate(0, 0)">
- <rect :height="vbHeight" x="0" y="0" width="20" fill="url(#left-shadow-gradient)" />
- </g>
- <svg-gradient
- :colors="gradientColors"
- :opacity="gradientOpacity"
- identifier-name="shadow-gradient"
- />
- <svg-gradient
- :colors="inverseGradientColors"
- :opacity="inverseGradientOpacity"
- identifier-name="left-shadow-gradient"
- />
- </svg>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/bar_chart_constants.js b/app/assets/javascripts/vue_shared/components/bar_chart_constants.js
deleted file mode 100644
index 6957b112da6..00000000000
--- a/app/assets/javascripts/vue_shared/components/bar_chart_constants.js
+++ /dev/null
@@ -1,4 +0,0 @@
-export const GRADIENT_COLORS = ['#000', '#a7a7a7'];
-export const GRADIENT_OPACITY = ['0', '0.4'];
-export const INVERSE_GRADIENT_COLORS = ['#a7a7a7', '#000'];
-export const INVERSE_GRADIENT_OPACITY = ['0.4', '0'];
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js b/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js
new file mode 100644
index 00000000000..d4c1808eec2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js
@@ -0,0 +1,3 @@
+export const HIGHLIGHT_CLASS_NAME = 'hll';
+
+export default {};
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/index.js b/app/assets/javascripts/vue_shared/components/blob_viewers/index.js
new file mode 100644
index 00000000000..72fba9392f9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/index.js
@@ -0,0 +1,4 @@
+import RichViewer from './rich_viewer.vue';
+import SimpleViewer from './simple_viewer.vue';
+
+export { RichViewer, SimpleViewer };
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
new file mode 100644
index 00000000000..582213ee8d3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
@@ -0,0 +1,8 @@
+export default {
+ props: {
+ content: {
+ type: String,
+ required: true,
+ },
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
new file mode 100644
index 00000000000..b3a1df8f303
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
@@ -0,0 +1,10 @@
+<script>
+import ViewerMixin from './mixins';
+
+export default {
+ mixins: [ViewerMixin],
+};
+</script>
+<template>
+ <div v-html="content"></div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
new file mode 100644
index 00000000000..e64c7132117
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
@@ -0,0 +1,68 @@
+<script>
+import ViewerMixin from './mixins';
+import { GlIcon } from '@gitlab/ui';
+import { HIGHLIGHT_CLASS_NAME } from './constants';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ mixins: [ViewerMixin],
+ data() {
+ return {
+ highlightedLine: null,
+ };
+ },
+ computed: {
+ lineNumbers() {
+ return this.content.split('\n').length;
+ },
+ },
+ mounted() {
+ const { hash } = window.location;
+ if (hash) this.scrollToLine(hash, true);
+ },
+ methods: {
+ scrollToLine(hash, scroll = false) {
+ const lineToHighlight = hash && this.$el.querySelector(hash);
+ const currentlyHighlighted = this.highlightedLine;
+ if (lineToHighlight) {
+ if (currentlyHighlighted) {
+ currentlyHighlighted.classList.remove(HIGHLIGHT_CLASS_NAME);
+ }
+
+ lineToHighlight.classList.add(HIGHLIGHT_CLASS_NAME);
+ this.highlightedLine = lineToHighlight;
+ if (scroll) {
+ lineToHighlight.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ }
+ }
+ },
+ },
+ userColorScheme: window.gon.user_color_scheme,
+};
+</script>
+<template>
+ <div
+ class="file-content code js-syntax-highlight qa-file-content"
+ :class="$options.userColorScheme"
+ >
+ <div class="line-numbers">
+ <a
+ v-for="line in lineNumbers"
+ :id="`L${line}`"
+ :key="line"
+ class="diff-line-num js-line-number"
+ :href="`#LC${line}`"
+ :data-line-number="line"
+ @click="scrollToLine(`#LC${line}`)"
+ >
+ <gl-icon :size="12" name="link" />
+ {{ line }}
+ </a>
+ </div>
+ <div class="blob-content">
+ <pre class="code highlight"><code id="blob-code-content" v-html="content"></code></pre>
+ </div>
+ </div>
+</template>
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 75c3c544c77..9ec99ac93d7 100644
--- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
@@ -41,7 +41,7 @@ export default {
changedIcon() {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
- const suffix = !this.file.changed && this.file.staged && this.showStagedIcon ? '-solid' : '';
+ const suffix = this.file.staged && this.showStagedIcon ? '-solid' : '';
return `${getCommitIconMap(this.file).icon}${suffix}`;
},
@@ -49,25 +49,19 @@ export default {
return `${this.changedIcon} float-left d-block`;
},
tooltipTitle() {
- if (!this.showTooltip) return undefined;
+ if (!this.showTooltip || !this.file.changed) return undefined;
const type = this.file.tempFile ? 'addition' : 'modification';
- if (this.file.changed && !this.file.staged) {
- return sprintf(__('Unstaged %{type}'), {
- type,
- });
- } else if (!this.file.changed && this.file.staged) {
+ if (this.file.staged) {
return sprintf(__('Staged %{type}'), {
type,
});
- } else if (this.file.changed && this.file.staged) {
- return sprintf(__('Unstaged and staged %{type}'), {
- type,
- });
}
- return undefined;
+ return sprintf(__('Unstaged %{type}'), {
+ type,
+ });
},
showIcon() {
return (
diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
index 9f498037185..3ff1d9cf48a 100644
--- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue
+++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
@@ -12,8 +12,7 @@
* css-class="btn-transparent"
* />
*/
-import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import Icon from '../components/icon.vue';
+import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
export default {
name: 'ClipboardButton',
@@ -22,7 +21,7 @@ export default {
},
components: {
GlButton,
- Icon,
+ GlIcon,
},
props: {
text: {
@@ -72,6 +71,6 @@ export default {
:title="title"
:data-clipboard-text="clipboardText"
>
- <icon name="duplicate" />
+ <gl-icon name="copy-to-clipboard" />
</gl-button>
</template>
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 fe1a2a092ad..e80cb06edfb 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
@@ -13,6 +13,11 @@ export default {
type: String,
required: true,
},
+ filePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
fileSize: {
type: Number,
required: false,
@@ -24,7 +29,8 @@ export default {
return numberToHumanSize(this.fileSize);
},
fileName() {
- return this.path.split('/').pop();
+ // path could be a base64 uri too, so check if filePath was passed additionally
+ return (this.filePath || this.path).split('/').pop();
},
},
};
@@ -39,7 +45,13 @@ export default {
({{ fileSizeReadable }})
</template>
</p>
- <gl-link :href="path" class="btn btn-default" rel="nofollow" download target="_blank">
+ <gl-link
+ :href="path"
+ class="btn btn-default"
+ rel="nofollow"
+ :download="fileName"
+ target="_blank"
+ >
<icon :size="16" name="download" class="float-left append-right-8" />
{{ __('Download') }}
</gl-link>
diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
new file mode 100644
index 00000000000..9ac687f5e2c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
@@ -0,0 +1,218 @@
+<script>
+import { GlButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+
+import { convertToFixedRange, isEqualTimeRanges, findTimeRange } from '~/lib/utils/datetime_range';
+
+import Icon from '~/vue_shared/components/icon.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import DateTimePickerInput from './date_time_picker_input.vue';
+import {
+ defaultTimeRanges,
+ defaultTimeRange,
+ isValidDate,
+ stringToISODate,
+ ISODateToString,
+ truncateZerosInDateTime,
+ isDateTimePickerInputValid,
+} from './date_time_picker_lib';
+
+const events = {
+ input: 'input',
+ invalid: 'invalid',
+};
+
+export default {
+ components: {
+ Icon,
+ TooltipOnTruncate,
+ DateTimePickerInput,
+ GlFormGroup,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ },
+ props: {
+ value: {
+ type: Object,
+ required: false,
+ default: () => defaultTimeRange,
+ },
+ options: {
+ type: Array,
+ required: false,
+ default: () => defaultTimeRanges,
+ },
+ },
+ data() {
+ return {
+ timeRange: this.value,
+ startDate: '',
+ endDate: '',
+ };
+ },
+ computed: {
+ startInputValid() {
+ return isValidDate(this.startDate);
+ },
+ endInputValid() {
+ return isValidDate(this.endDate);
+ },
+ isValid() {
+ return this.startInputValid && this.endInputValid;
+ },
+
+ startInput: {
+ get() {
+ return this.startInputValid ? this.formatDate(this.startDate) : this.startDate;
+ },
+ set(val) {
+ // Attempt to set a formatted date if possible
+ this.startDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
+ this.timeRange = null;
+ },
+ },
+ endInput: {
+ get() {
+ return this.endInputValid ? this.formatDate(this.endDate) : this.endDate;
+ },
+ set(val) {
+ // Attempt to set a formatted date if possible
+ this.endDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
+ this.timeRange = null;
+ },
+ },
+
+ timeWindowText() {
+ try {
+ const timeRange = findTimeRange(this.value, this.options);
+ if (timeRange) {
+ return timeRange.label;
+ }
+
+ const { start, end } = convertToFixedRange(this.value);
+ if (isValidDate(start) && isValidDate(end)) {
+ return sprintf(__('%{start} to %{end}'), {
+ start: this.formatDate(start),
+ end: this.formatDate(end),
+ });
+ }
+ } catch {
+ return __('Invalid date range');
+ }
+ return '';
+ },
+ },
+ watch: {
+ value(newValue) {
+ const { start, end } = convertToFixedRange(newValue);
+ this.timeRange = this.value;
+ this.startDate = start;
+ this.endDate = end;
+ },
+ },
+ mounted() {
+ try {
+ const { start, end } = convertToFixedRange(this.timeRange);
+ this.startDate = start;
+ this.endDate = end;
+ } catch {
+ // when dates cannot be parsed, emit error.
+ this.$emit(events.invalid);
+ }
+
+ // Validate on mounted, and trigger an update if needed
+ if (!this.isValid) {
+ this.$emit(events.invalid);
+ }
+ },
+ methods: {
+ formatDate(date) {
+ return truncateZerosInDateTime(ISODateToString(date));
+ },
+ closeDropdown() {
+ this.$refs.dropdown.hide();
+ },
+ isOptionActive(option) {
+ return isEqualTimeRanges(option, this.timeRange);
+ },
+ setQuickRange(option) {
+ this.timeRange = option;
+ this.$emit(events.input, this.timeRange);
+ },
+ setFixedRange() {
+ this.timeRange = convertToFixedRange({
+ start: this.startDate,
+ end: this.endDate,
+ });
+ this.$emit(events.input, this.timeRange);
+ },
+ },
+};
+</script>
+<template>
+ <tooltip-on-truncate
+ :title="timeWindowText"
+ :truncate-target="elem => elem.querySelector('.date-time-picker-toggle')"
+ placement="top"
+ class="d-inline-block"
+ >
+ <gl-dropdown
+ :text="timeWindowText"
+ v-bind="$attrs"
+ class="date-time-picker w-100"
+ menu-class="date-time-picker-menu"
+ toggle-class="date-time-picker-toggle text-truncate"
+ >
+ <div class="d-flex justify-content-between gl-p-2">
+ <gl-form-group
+ :label="__('Custom range')"
+ label-for="custom-from-time"
+ label-class="gl-pb-1"
+ class="custom-time-range-form-group col-md-7 gl-pl-1 gl-pr-0 m-0"
+ >
+ <div class="gl-pt-2">
+ <date-time-picker-input
+ id="custom-time-from"
+ v-model="startInput"
+ :label="__('From')"
+ :state="startInputValid"
+ />
+ <date-time-picker-input
+ id="custom-time-to"
+ v-model="endInput"
+ :label="__('To')"
+ :state="endInputValid"
+ />
+ </div>
+ <gl-form-group>
+ <gl-button @click="closeDropdown">{{ __('Cancel') }}</gl-button>
+ <gl-button variant="success" :disabled="!isValid" @click="setFixedRange()">
+ {{ __('Apply') }}
+ </gl-button>
+ </gl-form-group>
+ </gl-form-group>
+ <gl-form-group label-for="group-id-dropdown" class="col-md-5 gl-pl-1 gl-pr-1 m-0">
+ <template #label>
+ <span class="gl-pl-5">{{ __('Quick range') }}</span>
+ </template>
+
+ <gl-dropdown-item
+ v-for="(option, index) in options"
+ :key="index"
+ :active="isOptionActive(option)"
+ active-class="active"
+ @click="setQuickRange(option)"
+ >
+ <icon
+ name="mobile-issue-close"
+ class="align-bottom"
+ :class="{ invisible: !isOptionActive(option) }"
+ />
+ {{ option.label }}
+ </gl-dropdown-item>
+ </gl-form-group>
+ </div>
+ </gl-dropdown>
+ </tooltip-on-truncate>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue
index c3beae18726..f19f8bd46b3 100644
--- a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue
+++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue
@@ -1,14 +1,14 @@
<script>
-import _ from 'underscore';
+import { uniqueId } from 'lodash';
import { GlFormGroup, GlFormInput } from '@gitlab/ui';
-import { s__, sprintf } from '~/locale';
-import { dateFormats } from '~/monitoring/constants';
+import { __, sprintf } from '~/locale';
+import { dateFormats } from './date_time_picker_lib';
const inputGroupText = {
- invalidFeedback: sprintf(s__('Format: %{dateFormat}'), {
- dateFormat: dateFormats.dateTimePicker.format,
+ invalidFeedback: sprintf(__('Format: %{dateFormat}'), {
+ dateFormat: dateFormats.stringDate,
}),
- placeholder: dateFormats.dateTimePicker.format,
+ placeholder: dateFormats.stringDate,
};
export default {
@@ -35,7 +35,7 @@ export default {
id: {
type: String,
required: false,
- default: () => _.uniqueId('dateTimePicker_'),
+ default: () => uniqueId('dateTimePicker_'),
},
},
data() {
diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js
new file mode 100644
index 00000000000..673d981cf07
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js
@@ -0,0 +1,84 @@
+import dateformat from 'dateformat';
+import { __ } from '~/locale';
+
+/**
+ * Valid strings for this regex are
+ * 2019-10-01 and 2019-10-01 01:02:03
+ */
+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]))?$/;
+
+/**
+ * Default time ranges for the date picker.
+ * @see app/assets/javascripts/lib/utils/datetime_range.js
+ */
+export const defaultTimeRanges = [
+ {
+ duration: { seconds: 60 * 30 },
+ label: __('30 minutes'),
+ },
+ {
+ duration: { seconds: 60 * 60 * 3 },
+ label: __('3 hours'),
+ },
+ {
+ duration: { seconds: 60 * 60 * 8 },
+ label: __('8 hours'),
+ default: true,
+ },
+ {
+ duration: { seconds: 60 * 60 * 24 * 1 },
+ label: __('1 day'),
+ },
+];
+
+export const defaultTimeRange = defaultTimeRanges.find(tr => tr.default);
+
+export const dateFormats = {
+ ISODate: "yyyy-mm-dd'T'HH:MM:ss'Z'",
+ stringDate: 'yyyy-mm-dd HH:MM:ss',
+};
+
+/**
+ * The URL params start and end need to be validated
+ * before passing them down to other components.
+ *
+ * @param {string} dateString
+ * @returns true if the string is a valid date, false otherwise
+ */
+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 (e) {
+ 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.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.stringDate);
+
+export const truncateZerosInDateTime = datetime => datetime.replace(' 00:00:00', '');
+
+export const isDateTimePickerInputValid = val => dateTimePickerRegex.test(val);
+
+export default {};
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue
index b874bedab36..bf3c3666300 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue
@@ -79,10 +79,10 @@ export default {
return this.projectPath.indexOf('/') === 0 ? '' : `${gon.relative_url_root}/`;
},
fullOldPath() {
- return `${this.basePath}${this.projectPath}/raw/${this.oldSha}/${this.oldPath}`;
+ return `${this.basePath}${this.projectPath}/-/raw/${this.oldSha}/${this.oldPath}`;
},
fullNewPath() {
- return `${this.basePath}${this.projectPath}/raw/${this.newSha}/${this.newPath}`;
+ return `${this.basePath}${this.projectPath}/-/raw/${this.newSha}/${this.newPath}`;
},
},
};
diff --git a/app/assets/javascripts/vue_shared/components/dismissible_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue
new file mode 100644
index 00000000000..986fa14349e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue
@@ -0,0 +1,32 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlAlert,
+ },
+ props: {
+ html: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ isDismissed: false,
+ };
+ },
+ methods: {
+ dismiss() {
+ this.isDismissed = true;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-alert v-if="!isDismissed" v-bind="$attrs" @dismiss="dismiss" v-on="$listeners">
+ <div v-html="html"></div>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue b/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue
index c35fee84771..9aca210c1fb 100644
--- a/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue
@@ -69,7 +69,7 @@ export default {
data-display="static"
data-toggle="dropdown"
>
- <icon name="arrow-down" :aria-label="__('toggle dropdown')" />
+ <icon name="chevron-down" :aria-label="__('toggle dropdown')" />
</button>
<ul :class="dropdownClass" class="dropdown-menu dropdown-open-top">
<template v-for="(action, index) in actions">
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index 611001df32f..578fcc819b0 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -1,16 +1,12 @@
<script>
-import Icon from '~/vue_shared/components/icon.vue';
import FileHeader from '~/vue_shared/components/file_row_header.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
-import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue';
export default {
name: 'FileRow',
components: {
FileHeader,
FileIcon,
- Icon,
- ChangedFileIcon,
},
props: {
file: {
@@ -21,26 +17,6 @@ export default {
type: Number,
required: true,
},
- extraComponent: {
- type: Object,
- required: false,
- default: null,
- },
- hideExtraOnTree: {
- type: Boolean,
- required: false,
- default: false,
- },
- showChangedIcon: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- dropdownOpen: false,
- };
},
computed: {
isTree() {
@@ -62,9 +38,6 @@ export default {
'is-open': this.file.opened,
};
},
- childFilesLevel() {
- return this.file.isHeader ? 0 : this.level + 1;
- },
},
watch: {
'file.active': function fileActiveWatch(active) {
@@ -123,61 +96,36 @@ export default {
return this.$router.currentRoute.path === `/project${this.file.url}`;
},
- toggleDropdown(val) {
- this.dropdownOpen = val;
- },
},
};
</script>
<template>
- <div>
- <file-header v-if="file.isHeader" :path="file.path" />
- <div
- v-else
- :class="fileClass"
- :title="file.name"
- class="file-row"
- role="button"
- @click="clickFile"
- @mouseleave="toggleDropdown(false)"
- >
- <div class="file-row-name-container">
- <span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated">
- <file-icon
- v-if="!showChangedIcon || file.type === 'tree'"
- class="file-row-icon"
- :file-name="file.name"
- :loading="file.loading"
- :folder="isTree"
- :opened="file.opened"
- :size="16"
- />
- <changed-file-icon v-else :file="file" :size="16" class="append-right-5" />
- {{ file.name }}
- </span>
- <component
- :is="extraComponent"
- v-if="extraComponent && !(hideExtraOnTree && file.type === 'tree')"
- :file="file"
- :dropdown-open="dropdownOpen"
- @toggle="toggleDropdown($event)"
+ <file-header v-if="file.isHeader" :path="file.path" />
+ <div
+ v-else
+ :class="fileClass"
+ :title="file.name"
+ class="file-row"
+ role="button"
+ @click="clickFile"
+ @mouseleave="$emit('mouseleave', $event)"
+ >
+ <div class="file-row-name-container">
+ <span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated">
+ <file-icon
+ class="file-row-icon"
+ :class="{ 'text-secondary': file.type === 'tree' }"
+ :file-name="file.name"
+ :loading="file.loading"
+ :folder="isTree"
+ :opened="file.opened"
+ :size="16"
/>
- </div>
+ {{ file.name }}
+ </span>
+ <slot></slot>
</div>
- <template v-if="file.opened || file.isHeader">
- <file-row
- v-for="childFile in file.tree"
- :key="childFile.key"
- :file="childFile"
- :level="childFilesLevel"
- :hide-extra-on-tree="hideExtraOnTree"
- :extra-component="extraComponent"
- :show-changed-icon="showChangedIcon"
- @toggleTreeOpen="toggleTreeOpen"
- @clickFile="clickedFile"
- />
- </template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_tree.vue b/app/assets/javascripts/vue_shared/components/file_tree.vue
new file mode 100644
index 00000000000..e7817b8f910
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/file_tree.vue
@@ -0,0 +1,47 @@
+<script>
+export default {
+ name: 'FileTree',
+ props: {
+ fileRowComponent: {
+ type: Object,
+ required: true,
+ },
+ level: {
+ type: Number,
+ required: true,
+ },
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ childFilesLevel() {
+ return this.file.isHeader ? 0 : this.level + 1;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <component
+ :is="fileRowComponent"
+ :level="level"
+ :file="file"
+ v-bind="$attrs"
+ v-on="$listeners"
+ />
+ <template v-if="file.opened || file.isHeader">
+ <file-tree
+ v-for="childFile in file.tree"
+ :key="childFile.key"
+ :file-row-component="fileRowComponent"
+ :level="childFilesLevel"
+ :file="childFile"
+ v-bind="$attrs"
+ v-on="$listeners"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index dba4a9231a1..876eb7b899c 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -4,7 +4,6 @@ import { __, sprintf } from '~/locale';
import CiIconBadge from './ci_badge_link.vue';
import TimeagoTooltip from './time_ago_tooltip.vue';
import UserAvatarImage from './user_avatar/user_avatar_image.vue';
-import LoadingButton from '~/vue_shared/components/loading_button.vue';
/**
* Renders header component for job and pipeline page based on UI mockups
@@ -20,7 +19,6 @@ export default {
UserAvatarImage,
GlLink,
GlButton,
- LoadingButton,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -47,11 +45,6 @@ export default {
required: false,
default: () => ({}),
},
- actions: {
- type: Array,
- required: false,
- default: () => [],
- },
hasSidebarButton: {
type: Boolean,
required: false,
@@ -71,9 +64,6 @@ export default {
},
methods: {
- onClickAction(action) {
- this.$emit('actionClicked', action);
- },
onClickSidebarButton() {
this.$emit('clickedSidebarButton');
},
@@ -115,18 +105,8 @@ export default {
</template>
</section>
- <section v-if="actions.length" class="header-action-buttons">
- <template v-for="(action, i) in actions">
- <loading-button
- :key="i"
- :loading="action.isLoading"
- :disabled="action.isLoading"
- :class="action.cssClass"
- container-class="d-inline"
- :label="action.label"
- @click="onClickAction(action)"
- />
- </template>
+ <section v-if="$slots.default" class="header-action-buttons">
+ <slot></slot>
</section>
<gl-button
v-if="hasSidebarButton"
diff --git a/app/assets/javascripts/vue_shared/components/identicon.vue b/app/assets/javascripts/vue_shared/components/identicon.vue
index d42f0d8192c..9dd61c8eada 100644
--- a/app/assets/javascripts/vue_shared/components/identicon.vue
+++ b/app/assets/javascripts/vue_shared/components/identicon.vue
@@ -29,7 +29,7 @@ export default {
</script>
<template>
- <div :class="[sizeClass, identiconBackgroundClass]" class="avatar identicon">
+ <div ref="identicon" :class="[sizeClass, identiconBackgroundClass]" class="avatar identicon">
{{ identiconTitle }}
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue
index 47f0851f650..b5d3f3685bc 100644
--- a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue
@@ -65,14 +65,14 @@ export default {
<div class="issuable-note-warning">
<icon v-if="!isLockedAndConfidential" :name="warningIcon" :size="16" class="icon inline" />
- <span v-if="isLockedAndConfidential">
+ <span v-if="isLockedAndConfidential" ref="lockedAndConfidential">
<span v-html="confidentialAndLockedDiscussionText"></span>
{{
__("People without permission will never get a notification and won't be able to comment.")
}}
</span>
- <span v-else-if="isConfidential">
+ <span v-else-if="isConfidential" ref="confidential">
{{ __('This is a confidential issue.') }}
{{ __('People without permission will never get a notification.') }}
<gl-link :href="confidentialIssueDocsPath" target="_blank">
@@ -80,7 +80,7 @@ export default {
</gl-link>
</span>
- <span v-else-if="isLocked">
+ <span v-else-if="isLocked" ref="locked">
{{ __('This issue is locked.') }}
{{ __('Only project members can comment.') }}
<gl-link :href="lockedIssueDocsPath" target="_blank">
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 4f5f3ee5cf9..e30876813c2 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -79,6 +79,12 @@ export default {
required: false,
default: false,
},
+ // This prop is used as a fallback in case if textarea.elm is undefined
+ textareaValue: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -183,7 +189,7 @@ export default {
Can't use `$refs` as the component is technically in the parent component
so we access the VNode & then get the element
*/
- const text = this.$slots.textarea[0].elm.value;
+ const text = this.$slots.textarea[0]?.elm?.value || this.textareaValue;
if (text) {
this.markdownPreviewLoading = true;
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index fee5d6d5e3a..36cbb230d30 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -128,8 +128,8 @@ export default {
@click="handleSuggestDismissed"
/>
<gl-popover
- v-if="showSuggestPopover"
- :target="() => $refs.suggestButton"
+ v-if="showSuggestPopover && $refs.suggestButton"
+ :target="$refs.suggestButton"
:css-classes="['diff-suggest-popover']"
placement="bottom"
:show="showSuggestPopover"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue
index 97d93eaaf3f..112bd03b49b 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue
@@ -8,6 +8,9 @@ export default {
},
},
computed: {
+ displayAsCell() {
+ return !(this.line.rich_text || this.line.text);
+ },
lineType() {
return this.line.type;
},
@@ -23,11 +26,9 @@ export default {
<td class="diff-line-num new_line border-top-0 border-bottom-0" :class="lineType">
{{ line.new_line }}
</td>
- <td class="line_content" :class="lineType">
+ <td class="line_content" :class="[{ 'd-table-cell': displayAsCell }, lineType]">
<span v-if="line.rich_text" v-html="line.rich_text"></span>
<span v-else-if="line.text">{{ line.text }}</span>
- <!-- TODO: replace this hack with zero-width whitespace when we have rich_text from BE -->
- <span v-else>&#8203;</span>
</td>
</tr>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
index cdcfff42981..271a375ade2 100644
--- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
+++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
@@ -121,7 +121,7 @@ export default {
:title="title"
>
<slot>
- <icon name="duplicate" />
+ <icon name="copy-to-clipboard" />
</slot>
</gl-button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
index af02b8969ee..69afd711797 100644
--- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
@@ -47,7 +47,7 @@ export default {
:img-size="40"
/>
</div>
- <div :class="{ discussion: !note.individual_note }" class="timeline-content">
+ <div ref="note" :class="{ discussion: !note.individual_note }" class="timeline-content">
<div class="note-header">
<div class="note-header-info">
<a :href="getUserData.path">
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index 15ca64ba297..0c4d75fb0ad 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -17,11 +17,12 @@
* />
*/
import $ from 'jquery';
-import { mapGetters, mapActions } from 'vuex';
-import { GlSkeletonLoading } from '@gitlab/ui';
+import { mapGetters, mapActions, mapState } from 'vuex';
+import { GlButton, GlSkeletonLoading, GlTooltipDirective } from '@gitlab/ui';
import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history';
import noteHeader from '~/notes/components/note_header.vue';
import Icon from '~/vue_shared/components/icon.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import TimelineEntryItem from './timeline_entry_item.vue';
import { spriteIcon } from '../../../lib/utils/common_utils';
import initMRPopovers from '~/mr_popover/';
@@ -34,9 +35,13 @@ export default {
Icon,
noteHeader,
TimelineEntryItem,
+ GlButton,
GlSkeletonLoading,
},
- mixins: [descriptionVersionHistoryMixin],
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [descriptionVersionHistoryMixin, glFeatureFlagsMixin()],
props: {
note: {
type: Object,
@@ -50,6 +55,7 @@ export default {
},
computed: {
...mapGetters(['targetNoteHash']),
+ ...mapState(['descriptionVersion', 'isLoadingDescriptionVersion']),
noteAnchorId() {
return `note_${this.note.id}`;
},
@@ -80,7 +86,7 @@ export default {
initMRPopovers(this.$el.querySelectorAll('.gfm-merge_request'));
},
methods: {
- ...mapActions(['fetchDescriptionVersion']),
+ ...mapActions(['fetchDescriptionVersion', 'softDeleteDescriptionVersion']),
},
};
</script>
@@ -122,6 +128,16 @@ export default {
<gl-skeleton-loading />
</pre>
<pre v-else class="wrapper mt-2" v-html="descriptionVersion"></pre>
+ <gl-button
+ v-if="canDeleteDescriptionVersion"
+ ref="deleteDescriptionVersionButton"
+ v-gl-tooltip
+ :title="__('Remove description history')"
+ class="btn-transparent delete-description-history"
+ @click="deleteDescriptionVersion"
+ >
+ <icon name="remove" />
+ </gl-button>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/pagination/graphql_pagination.vue b/app/assets/javascripts/vue_shared/components/pagination/graphql_pagination.vue
deleted file mode 100644
index 53e473432db..00000000000
--- a/app/assets/javascripts/vue_shared/components/pagination/graphql_pagination.vue
+++ /dev/null
@@ -1,47 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import { PREV, NEXT } from '~/vue_shared/components/pagination/constants';
-
-/**
- * Pagination Component for graphql API
- */
-export default {
- name: 'GraphqlPaginationComponent',
- components: {
- GlButton,
- },
- labels: {
- prev: PREV,
- next: NEXT,
- },
- props: {
- hasNextPage: {
- required: true,
- type: Boolean,
- },
- hasPreviousPage: {
- required: true,
- type: Boolean,
- },
- },
-};
-</script>
-<template>
- <div class="justify-content-center d-flex prepend-top-default">
- <div class="btn-group">
- <gl-button
- class="js-prev-btn page-link"
- :disabled="!hasPreviousPage"
- @click="$emit('previousClicked')"
- >{{ $options.labels.prev }}</gl-button
- >
-
- <gl-button
- class="js-next-btn page-link"
- :disabled="!hasNextPage"
- @click="$emit('nextClicked')"
- >{{ $options.labels.next }}</gl-button
- >
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue
index 69eb791d195..4ea3d162da2 100644
--- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue
+++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue
@@ -1,5 +1,5 @@
<script>
-import _ from 'underscore';
+import { isFunction } from 'lodash';
import tooltip from '../directives/tooltip';
export default {
@@ -28,16 +28,18 @@ export default {
showTooltip: false,
};
},
+ watch: {
+ title() {
+ // Wait on $nextTick in case of slot width changes
+ this.$nextTick(this.updateTooltip);
+ },
+ },
mounted() {
- const target = this.selectTarget();
-
- if (target && target.scrollWidth > target.offsetWidth) {
- this.showTooltip = true;
- }
+ this.updateTooltip();
},
methods: {
selectTarget() {
- if (_.isFunction(this.truncateTarget)) {
+ if (isFunction(this.truncateTarget)) {
return this.truncateTarget(this.$el);
} else if (this.truncateTarget === 'child') {
return this.$el.childNodes[0];
@@ -45,6 +47,10 @@ export default {
return this.$el;
},
+ updateTooltip() {
+ const target = this.selectTarget();
+ this.showTooltip = Boolean(target && target.scrollWidth > target.offsetWidth);
+ },
},
};
</script>
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 37e3643bf6c..ca25d9ee738 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
@@ -56,19 +56,16 @@ export default {
</script>
<template>
- <gl-popover :target="target" boundary="viewport" placement="top" offset="0, 1" show>
+ <!-- 200ms delay so not every mouseover triggers Popover -->
+ <gl-popover :target="target" :delay="200" boundary="viewport" triggers="hover" placement="top">
<div class="user-popover d-flex">
<div class="p-1 flex-shrink-1">
<user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="mr-2" />
</div>
<div class="p-1 w-100">
<h5 class="m-0">
- {{ user.name }}
- <gl-skeleton-loading
- v-if="nameIsLoading"
- :lines="1"
- class="animation-container-small mb-1"
- />
+ <span v-if="user.name">{{ user.name }}</span>
+ <gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" />
</h5>
<div class="text-secondary mb-2">
<span v-if="user.username">@{{ user.username }}</span>
diff --git a/app/assets/javascripts/webpack.js b/app/assets/javascripts/webpack.js
index ced847294ae..4f558843357 100644
--- a/app/assets/javascripts/webpack.js
+++ b/app/assets/javascripts/webpack.js
@@ -5,5 +5,5 @@
*/
if (gon && gon.webpack_public_path) {
- __webpack_public_path__ = gon.webpack_public_path; // eslint-disable-line camelcase
+ __webpack_public_path__ = gon.webpack_public_path; // eslint-disable-line babel/camelcase
}
diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js
index 044d703630e..ab0b0b02aa8 100644
--- a/app/assets/javascripts/zen_mode.js
+++ b/app/assets/javascripts/zen_mode.js
@@ -1,4 +1,4 @@
-/* eslint-disable consistent-return, camelcase, class-methods-use-this */
+/* eslint-disable consistent-return, class-methods-use-this */
// Zen Mode (full screen) textarea
//
@@ -91,8 +91,8 @@ export default class ZenMode {
}
}
- scrollTo(zen_area) {
- return $.scrollTo(zen_area, 0, {
+ scrollTo(zenArea) {
+ return $.scrollTo(zenArea, 0, {
offset: -150,
});
}
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index e98030f1511..657e52674db 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -11,7 +11,7 @@
// like a table or typography then make changes in the framework/ directory.
// If you need to add unique style that should affect only one page - use pages/
// directory.
-@import "at.js/dist/css/jquery.atwho";
+@import "@gitlab/at.js/dist/css/jquery.atwho";
@import "dropzone/dist/basic";
@import "select2/select2";
diff --git a/app/assets/stylesheets/components/date_time_picker.scss b/app/assets/stylesheets/components/date_time_picker.scss
new file mode 100644
index 00000000000..21f085cdaf1
--- /dev/null
+++ b/app/assets/stylesheets/components/date_time_picker.scss
@@ -0,0 +1,5 @@
+.date-time-picker {
+ .date-time-picker-menu {
+ width: 400px;
+ }
+}
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 249e9a24b17..9032dd28b80 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -15,6 +15,7 @@
@import 'framework/badges';
@import 'framework/calendar';
@import 'framework/callout';
+@import 'framework/carousel';
@import 'framework/common';
@import 'framework/dropdowns';
@import 'framework/files';
diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss
index 53a8f7c483a..0e4080ce201 100644
--- a/app/assets/stylesheets/framework/blocks.scss
+++ b/app/assets/stylesheets/framework/blocks.scss
@@ -69,7 +69,6 @@
&.footer-block {
margin-top: $gl-padding-24;
border-bottom: 0;
- margin-bottom: -$gl-padding;
}
&.content-component-block {
@@ -326,11 +325,11 @@
}
.btn {
- margin: $btn-side-margin 5px;
+ margin: $gl-padding-8 $gl-padding-4;
@include media-breakpoint-down(xs) {
width: 100%;
- margin: $btn-side-margin 0;
+ margin: $gl-padding-8 0;
}
}
}
diff --git a/app/assets/stylesheets/framework/carousel.scss b/app/assets/stylesheets/framework/carousel.scss
new file mode 100644
index 00000000000..d51a9f9c173
--- /dev/null
+++ b/app/assets/stylesheets/framework/carousel.scss
@@ -0,0 +1,202 @@
+// Notes on the classes:
+//
+// 1. .carousel.pointer-event should ideally be pan-y (to allow for users to scroll vertically)
+// even when their scroll action started on a carousel, but for compatibility (with Firefox)
+// we're preventing all actions instead
+// 2. The .carousel-item-left and .carousel-item-right is used to indicate where
+// the active slide is heading.
+// 3. .active.carousel-item is the current slide.
+// 4. .active.carousel-item-left and .active.carousel-item-right is the current
+// slide in its in-transition state. Only one of these occurs at a time.
+// 5. .carousel-item-next.carousel-item-left and .carousel-item-prev.carousel-item-right
+// is the upcoming slide in transition.
+
+.carousel {
+ position: relative;
+
+ &.pointer-event {
+ touch-action: pan-y;
+ }
+}
+
+
+.carousel-inner {
+ position: relative;
+ width: 100%;
+ overflow: hidden;
+ @include clearfix();
+}
+
+.carousel-item {
+ position: relative;
+ display: none;
+ float: left;
+ width: 100%;
+ margin-right: -100%;
+ backface-visibility: hidden;
+ @include transition($carousel-transition);
+}
+
+.carousel-item.active,
+.carousel-item-next,
+.carousel-item-prev {
+ display: block;
+}
+
+.carousel-item-next:not(.carousel-item-left),
+.active.carousel-item-right {
+ transform: translateX(100%);
+}
+
+.carousel-item-prev:not(.carousel-item-right),
+.active.carousel-item-left {
+ transform: translateX(-100%);
+}
+
+
+//
+// Alternate transitions
+//
+
+.carousel-fade {
+ .carousel-item {
+ opacity: 0;
+ transition-property: opacity;
+ transform: none;
+ }
+
+ .carousel-item.active,
+ .carousel-item-next.carousel-item-left,
+ .carousel-item-prev.carousel-item-right {
+ z-index: 1;
+ opacity: 1;
+ }
+
+ .active.carousel-item-left,
+ .active.carousel-item-right {
+ z-index: 0;
+ opacity: 0;
+ @include transition(0s $carousel-transition-duration opacity);
+ }
+}
+
+
+//
+// Left/right controls for nav
+//
+
+.carousel-control-prev,
+.carousel-control-next {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ z-index: 1;
+ // Use flex for alignment (1-3)
+ display: flex; // 1. allow flex styles
+ align-items: center; // 2. vertically center contents
+ justify-content: center; // 3. horizontally center contents
+ width: $carousel-control-width;
+ color: $carousel-control-color;
+ text-align: center;
+ opacity: $carousel-control-opacity;
+ @include transition($carousel-control-transition);
+
+ // Hover/focus state
+ @include hover-focus {
+ color: $carousel-control-color;
+ text-decoration: none;
+ outline: 0;
+ opacity: $carousel-control-hover-opacity;
+ }
+}
+
+.carousel-control-prev {
+ left: 0;
+ @if $enable-gradients {
+ background: linear-gradient(90deg, rgba($black, 0.25), rgba($black, 0.001));
+ }
+}
+
+.carousel-control-next {
+ right: 0;
+ @if $enable-gradients {
+ background: linear-gradient(270deg, rgba($black, 0.25), rgba($black, 0.001));
+ }
+}
+
+// Icons for within
+.carousel-control-prev-icon,
+.carousel-control-next-icon {
+ display: inline-block;
+ width: $carousel-control-icon-width;
+ height: $carousel-control-icon-width;
+ background: no-repeat 50% / 100% 100%;
+}
+
+.carousel-control-prev-icon {
+ background-image: $carousel-control-prev-icon-bg;
+}
+
+.carousel-control-next-icon {
+ background-image: $carousel-control-next-icon-bg;
+}
+
+
+// Optional indicator pips
+//
+// Add an ordered list with the following class and add a list item for each
+// slide your carousel holds.
+
+.carousel-indicators {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 15;
+ display: flex;
+ justify-content: center;
+ padding-left: 0; // override <ol> default
+ // Use the .carousel-control's width as margin so we don't overlay those
+ margin-right: $carousel-control-width;
+ margin-left: $carousel-control-width;
+ list-style: none;
+
+ li {
+ box-sizing: content-box;
+ flex: 0 1 auto;
+ width: $carousel-indicator-width;
+ height: $carousel-indicator-height;
+ margin-right: $carousel-indicator-spacer;
+ margin-left: $carousel-indicator-spacer;
+ text-indent: -999px;
+ cursor: pointer;
+ background-color: $carousel-indicator-active-bg;
+ background-clip: padding-box;
+ // Use transparent borders to increase the hit area by 10px on top and bottom.
+ border-top: $carousel-indicator-hit-area-height solid transparent;
+ border-bottom: $carousel-indicator-hit-area-height solid transparent;
+ opacity: 0.5;
+ @include transition($carousel-indicator-transition);
+ }
+
+ .active {
+ opacity: 1;
+ }
+}
+
+
+// Optional captions
+//
+//
+
+.carousel-caption {
+ position: absolute;
+ right: (100% - $carousel-caption-width) / 2;
+ bottom: 20px;
+ left: (100% - $carousel-caption-width) / 2;
+ z-index: 10;
+ padding-top: 20px;
+ padding-bottom: 20px;
+ color: $carousel-caption-color;
+ text-align: center;
+}
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index dc119b52f4e..408ca249be2 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -581,6 +581,7 @@ img.emoji {
.gl-line-height-24 { line-height: $gl-line-height-24; }
.gl-line-height-14 { line-height: $gl-line-height-14; }
+.gl-font-size-0 { font-size: 0; }
.gl-font-size-12 { font-size: $gl-font-size-12; }
.gl-font-size-14 { font-size: $gl-font-size-14; }
.gl-font-size-16 { font-size: $gl-font-size-16; }
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 21253e004ef..41f3603506f 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -158,6 +158,27 @@
}
}
+// Temporary hack until `gitlab-ui` issue is fixed.
+// https://gitlab.com/gitlab-org/gitlab-ui/issues/164
+.gl-dropdown .dropdown-menu-toggle {
+ .gl-dropdown-caret {
+ position: absolute;
+ right: $gl-padding-8;
+ top: $gl-padding-8;
+ }
+
+ // Add some child to the button so that the default height kicks in
+ // when there's no text (since the caret is now aboslute)
+ &::after {
+ border: 0;
+ content: ' ';
+ display: inline-block;
+ margin: 0;
+ padding: 0;
+ position: relative;
+ }
+}
+
@mixin dropdown-item-hover {
background-color: $gray-darker;
color: $gl-text-color;
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 1a017f03ebb..bb1c304b9fe 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -499,3 +499,15 @@ span.idiff {
background-color: transparent;
border: transparent;
}
+
+.code-navigation {
+ border-bottom: 1px $gray-darkest dashed;
+
+ &:hover {
+ border-bottom-color: $almost-black;
+ }
+}
+
+.code-navigation-popover {
+ max-width: 450px;
+}
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index b5d1c3f6732..4b45a169a31 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -190,6 +190,7 @@
min-width: 0;
border: 1px solid $border-color;
background-color: $white-light;
+ border-radius: $border-radius-default 0 0 $border-radius-default;
@include media-breakpoint-down(sm) {
flex: 1 1 auto;
@@ -287,7 +288,7 @@
.filtered-search-history-dropdown-toggle-button {
flex: 1;
width: auto;
- border-radius: 0;
+ border-radius: $border-radius-default 0 0 $border-radius-default;
border: 0;
border-right: 1px solid $border-color;
color: $gl-text-color-secondary;
@@ -296,8 +297,7 @@
&:hover,
&:focus {
color: $gl-text-color;
- border-color: $blue-300;
- outline: none;
+ border-color: $border-color;
}
svg {
diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss
index ee6e53adaf7..73a2170fc68 100644
--- a/app/assets/stylesheets/framework/highlight.scss
+++ b/app/assets/stylesheets/framework/highlight.scss
@@ -30,7 +30,6 @@
.line {
display: block;
width: 100%;
- min-height: 1.5em;
padding-left: 10px;
padding-right: 10px;
white-space: pre;
@@ -48,10 +47,10 @@
font-family: $monospace-font;
display: block;
font-size: $code-font-size !important;
- min-height: 1.5em;
white-space: nowrap;
- i {
+ i,
+ svg {
float: left;
margin-top: 3px;
margin-right: 5px;
@@ -62,12 +61,20 @@
&:focus {
outline: none;
- i {
+ i,
+ svg {
visibility: visible;
}
}
}
}
+
+ pre .line,
+ .line-numbers a {
+ font-size: 0.8125rem;
+ line-height: 1.1875rem;
+ min-height: 1.1875rem;
+ }
}
// Vertically aligns <table> line numbers (eg. blame view)
diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss
index 757264add93..ac8437c23ca 100644
--- a/app/assets/stylesheets/framework/modal.scss
+++ b/app/assets/stylesheets/framework/modal.scss
@@ -70,6 +70,7 @@
margin: 0;
}
+ .btn + .btn,
.btn + .btn-group,
.btn-group + .btn,
.btn-group + .btn-group {
diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss
index bd0134a82d3..a8244219b10 100644
--- a/app/assets/stylesheets/framework/selects.scss
+++ b/app/assets/stylesheets/framework/selects.scss
@@ -63,7 +63,8 @@
display: block;
}
- .select2-choices {
+ .select2-choices,
+ .select2-choice {
border-color: $red-500;
}
}
diff --git a/app/assets/stylesheets/framework/snippets.scss b/app/assets/stylesheets/framework/snippets.scss
index 404f60f17ee..dbcb5086d70 100644
--- a/app/assets/stylesheets/framework/snippets.scss
+++ b/app/assets/stylesheets/framework/snippets.scss
@@ -1,6 +1,7 @@
.snippet-row {
.title {
margin-bottom: 2px;
+ font-weight: $gl-font-weight-bold;
}
.snippet-filename {
@@ -11,6 +12,10 @@
.snippet-info {
color: $gl-text-color-secondary;
}
+
+ a {
+ color: $gl-text-color;
+ }
}
.snippet-form-holder .file-holder .file-title {
@@ -27,10 +32,6 @@
.snippet-file-content {
border-radius: 3px;
-
- .file-title-flex-parent .btn-clipboard {
- line-height: 28px;
- }
}
.snippet-header {
diff --git a/app/assets/stylesheets/framework/spinner.scss b/app/assets/stylesheets/framework/spinner.scss
index 91fe75075dc..5e05311041c 100644
--- a/app/assets/stylesheets/framework/spinner.scss
+++ b/app/assets/stylesheets/framework/spinner.scss
@@ -49,3 +49,9 @@
@include spinner-color($white);
}
}
+
+.btn {
+ .spinner {
+ vertical-align: text-bottom;
+ }
+}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 90600ecf615..e4853ca7bf5 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -579,7 +579,7 @@ $calendar-border-color: rgba(#000, 0.1);
$calendar-user-contrib-text: #959494;
/*
- * Cycle Analytics
+ * Value Stream Analytics
*/
$cycle-analytics-box-padding: 30px;
$cycle-analytics-box-text-color: #8c8c8c;
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index 420271c9a1e..9c64714e5dd 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -25,10 +25,6 @@ $ide-commit-header-height: 48px;
@include str-truncated(250px);
}
-.editable-mode {
- display: inline-block;
-}
-
.ide-view {
position: relative;
margin-top: 0;
@@ -164,6 +160,11 @@ $ide-commit-header-height: 48px;
height: 0;
}
+// stylelint-disable selector-class-pattern
+// stylelint-disable selector-max-compound-selectors
+// stylelint-disable stylelint-gitlab/duplicate-selectors
+// stylelint-disable stylelint-gitlab/utility-classes
+
.blob-editor-container {
flex: 1;
height: 0;
@@ -295,8 +296,8 @@ $ide-commit-header-height: 48px;
height: 100%;
min-height: 0; // firefox fix
- &.is-readonly,
- .editor.original {
+ &.is-readonly .vs,
+ .vs .editor.original {
.monaco-editor,
.monaco-editor-background,
.monaco-editor .inputarea.ime-input {
@@ -305,6 +306,11 @@ $ide-commit-header-height: 48px;
}
}
+// stylelint-enable selector-class-pattern
+// stylelint-enable selector-max-compound-selectors
+// stylelint-enable stylelint-gitlab/duplicate-selectors
+// stylelint-enable stylelint-gitlab/utility-classes
+
.preview-container {
flex-grow: 1;
position: relative;
@@ -332,23 +338,6 @@ $ide-commit-header-height: 48px;
padding: $gl-padding;
max-width: 100%;
max-height: 100%;
-
- img {
- max-width: 90%;
- }
-
- .isZoomable {
- cursor: pointer;
- cursor: zoom-in;
-
- &.isZoomed {
- cursor: pointer;
- cursor: zoom-out;
- max-width: none;
- max-height: none;
- margin-right: $gl-padding;
- }
- }
}
.file-info {
@@ -361,13 +350,9 @@ $ide-commit-header-height: 48px;
.ide-mode-tabs {
border-bottom: 1px solid $white-dark;
- .nav-links {
- border-bottom: 0;
-
- li a {
- padding: $gl-padding-8 $gl-padding;
- line-height: $gl-btn-line-height;
- }
+ li a {
+ padding: $gl-padding-8 $gl-padding;
+ line-height: $gl-btn-line-height;
}
}
@@ -564,12 +549,6 @@ $ide-commit-header-height: 48px;
background: $gray-100;
outline: 0;
-
- .multi-file-discard-btn {
- > .btn {
- display: flex;
- }
- }
}
&:active {
@@ -596,18 +575,6 @@ $ide-commit-header-height: 48px;
}
}
-.multi-file-discard-btn {
- > .btn {
- display: none;
- width: $ide-commit-row-height;
- height: $ide-commit-row-height;
- }
-
- svg {
- top: 0;
- }
-}
-
.multi-file-commit-form {
position: relative;
background-color: $white-light;
@@ -721,7 +688,7 @@ $ide-commit-header-height: 48px;
font-weight: normal;
&.is-disabled {
- .ide-radio-label {
+ .ide-option-label {
text-decoration: line-through;
}
}
@@ -1060,8 +1027,6 @@ $ide-commit-header-height: 48px;
}
.ide-external-link {
- position: relative;
-
svg {
display: none;
position: absolute;
@@ -1164,22 +1129,12 @@ $ide-commit-header-height: 48px;
align-items: center;
}
}
-
- .card-body {
- padding: 0;
- }
}
.ide-stage-collapse-icon {
margin: auto 0 auto auto;
}
-.ide-stage-title {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
.ide-job-header {
min-height: 60px;
}
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index 31e87d1a7cf..42d7b0d08f7 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -287,6 +287,10 @@
cursor: help;
}
+ .issue-blocked-icon {
+ color: $red-500;
+ }
+
@include media-breakpoint-down(md) {
padding: $gl-padding-8;
}
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index be0311f584f..781b6c09458 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -321,6 +321,16 @@
}
}
+.gpg-popover-certificate-details {
+ ul {
+ padding-left: $gl-padding;
+ }
+
+ li.unstyled {
+ list-style-type: none;
+ }
+}
+
.gpg-popover-status {
display: flex;
align-items: center;
diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss
index 76cd4f34865..89b673397a2 100644
--- a/app/assets/stylesheets/pages/cycle_analytics.scss
+++ b/app/assets/stylesheets/pages/cycle_analytics.scss
@@ -109,14 +109,6 @@
top: $gl-padding-top;
}
- .fa-spinner {
- font-size: 28px;
- position: relative;
- margin-left: -20px;
- left: 50%;
- margin-top: 36px;
- }
-
.stage-panel-body {
display: flex;
flex-wrap: wrap;
@@ -200,7 +192,7 @@
.stage-events {
width: 60%;
overflow: scroll;
- height: 467px;
+ min-height: 467px;
}
.stage-event-list {
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index f394e4ab58a..24c6fec064a 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -14,9 +14,9 @@
cursor: pointer;
@media (min-width: map-get($grid-breakpoints, md)) {
- // The `-1` below is to prevent two borders from clashing up against eachother -
+ // The `+11` is to ensure the file header border shows when scrolled -
// the bottom of the compare-versions header and the top of the file header
- $mr-file-header-top: $mr-version-controls-height + $header-height + $mr-tabs-height - 1;
+ $mr-file-header-top: $mr-version-controls-height + $header-height + $mr-tabs-height + 11;
position: -webkit-sticky;
position: sticky;
@@ -63,11 +63,6 @@
background-color: $gray-normal;
}
- a,
- button {
- color: $gray-700;
- }
-
svg {
vertical-align: middle;
top: -1px;
@@ -552,7 +547,7 @@ table.code {
.diff-stats {
align-items: center;
- padding: 0 0.25rem;
+ padding: 0 1rem;
.diff-stats-group {
padding: 0 0.25rem;
@@ -564,7 +559,7 @@ table.code {
&.is-compare-versions-header {
.diff-stats-group {
- padding: 0 0.5rem;
+ padding: 0 0.25rem;
}
}
}
@@ -1059,8 +1054,8 @@ table.code {
.diff-tree-list {
position: -webkit-sticky;
position: sticky;
- $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px;
- top: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px;
+ $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 11px;
+ top: $top-pos;
max-height: calc(100vh - #{$top-pos});
z-index: 202;
@@ -1097,10 +1092,7 @@ table.code {
.tree-list-scroll {
max-height: 100%;
- padding-top: $grid-size;
padding-bottom: $grid-size;
- border-top: 1px solid $border-color;
- border-bottom: 1px solid $border-color;
overflow-y: scroll;
overflow-x: auto;
}
diff --git a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss
index 5a80ea79600..710d89d9341 100644
--- a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss
+++ b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss
@@ -3,31 +3,8 @@
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;
- padding: 0 $gl-padding;
-
- @include media-breakpoint-down(md) {
- font-size: $gl-font-size-large;
- }
+ max-width: 960px;
}
.signup-box {
@@ -49,4 +26,35 @@
color: $red-700;
}
}
+
+ .omniauth-divider {
+ &::before,
+ &::after {
+ content: '';
+ flex: 1;
+ border-bottom: 1px solid $gray-dark;
+ margin: $gl-padding-24 0;
+ }
+
+ &::before {
+ margin-right: $gl-padding;
+ }
+
+ &::after {
+ margin-left: $gl-padding;
+ }
+ }
+
+ .omniauth-btn {
+ width: 48%;
+
+ @include media-breakpoint-down(md) {
+ width: 100%;
+ }
+
+ img {
+ width: $default-icon-size;
+ height: $default-icon-size;
+ }
+ }
}
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index 1cf72c51ca7..3085f5e89b5 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -382,8 +382,6 @@ table.pipeline-project-metrics tr td {
}
.group-row-contents {
- padding: $gl-padding;
-
&:hover {
border-color: $blue-200;
background-color: $blue-50;
@@ -410,13 +408,7 @@ table.pipeline-project-metrics tr td {
.title {
margin-top: -$gl-padding-8; // negative margin required for flex-wrap
- font-size: $gl-font-size-large;
- }
-
- @include media-breakpoint-down(md) {
- .title {
- font-size: $gl-font-size;
- }
+ font-size: $gl-font-size;
}
&.has-more-items {
@@ -483,7 +475,6 @@ table.pipeline-project-metrics tr td {
.last-updated {
position: relative;
- right: 12px;
min-width: 250px;
text-align: right;
color: $gl-text-color-secondary;
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index c023c9e5cbd..5ca75c28ac3 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -3,6 +3,8 @@
*
*/
+$mr-widget-min-height: 69px;
+
.space-children {
@include clearfix;
@@ -555,12 +557,11 @@
}
.mr-source-target {
- display: flex;
flex-wrap: wrap;
border-radius: $border-radius-default;
padding: $gl-padding;
border: 1px solid $border-color;
- min-height: 69px;
+ min-height: $mr-widget-min-height;
@include media-breakpoint-up(md) {
align-items: center;
@@ -599,6 +600,22 @@
}
}
+.mr-pipeline-suggest {
+ flex-wrap: wrap;
+ border-radius: $border-radius-default;
+ padding: $gl-padding;
+ border: 1px solid $border-color;
+ min-height: $mr-widget-min-height;
+
+ @include media-breakpoint-up(md) {
+ align-items: center;
+ }
+
+ .circle-icon-container {
+ color: $gl-text-color-quaternary;
+ }
+}
+
.card-new-merge-request {
.card-header {
padding: 5px 10px;
@@ -708,7 +725,7 @@
.mr-version-controls {
position: relative;
z-index: 203;
- background: $gray-light;
+ background: $white-light;
color: $gl-text-color;
margin-top: -1px;
@@ -732,7 +749,7 @@
}
.content-block {
- padding: $gl-padding-top $gl-padding;
+ padding: $gl-padding;
border-bottom: 0;
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 1da9f691639..1a06ae1ed41 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -311,13 +311,18 @@ $note-form-margin-left: 72px;
overflow: hidden;
.description-version {
+ position: relative;
+
+ .btn.delete-description-history {
+ position: absolute;
+ top: 18px;
+ right: 0;
+ }
+
pre {
max-height: $dropdown-max-height-lg;
white-space: pre-wrap;
-
- &.loading-state {
- height: 94px;
- }
+ padding-right: 30px;
}
}
diff --git a/app/assets/stylesheets/pages/pages.scss b/app/assets/stylesheets/pages/pages.scss
index 374227fe16a..93caa345f8a 100644
--- a/app/assets/stylesheets/pages/pages.scss
+++ b/app/assets/stylesheets/pages/pages.scss
@@ -56,4 +56,15 @@
border-top-right-radius: $border-radius-default;
}
+ &.floating-status-badge {
+ position: absolute;
+ right: $gl-padding-24;
+ bottom: $gl-padding-4;
+ margin-bottom: 0;
+ }
+}
+
+.form-control.has-floating-status-badge {
+ position: relative;
+ padding-right: 120px;
}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 8b2c67378d9..f8832047d49 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -1006,14 +1006,6 @@ 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 e20e58e21cf..8133a167687 100644
--- a/app/assets/stylesheets/pages/prometheus.scss
+++ b/app/assets/stylesheets/pages/prometheus.scss
@@ -47,16 +47,22 @@
}
.prometheus-graphs-header {
- .time-window-dropdown-menu {
- padding: $gl-padding $gl-padding 0 $gl-padding-12;
+ .monitor-environment-dropdown-header header,
+ .monitor-dashboard-dropdown-header header {
+ font-size: $gl-font-size;
}
- .time-window-dropdown-menu-container {
- width: 360px;
- }
+ .monitor-environment-dropdown-menu,
+ .monitor-dashboard-dropdown-menu {
+ &.show {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
- .custom-time-range-form-group > label {
- padding-bottom: $gl-padding;
+ .no-matches-message {
+ padding: $gl-padding-8 $gl-padding-12;
+ }
}
}
diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss
index 79ad0bd7735..db1b8c559e5 100644
--- a/app/assets/stylesheets/pages/tree.scss
+++ b/app/assets/stylesheets/pages/tree.scss
@@ -17,14 +17,12 @@
.tree-controls {
text-align: right;
- .btn {
+ > .btn,
+ .project-action-button > .btn,
+ .git-clone-holder > .btn {
margin-left: 8px;
}
- .btn-group {
- margin-left: 10px;
- }
-
.control {
float: left;
margin-left: 10px;
diff --git a/app/assets/stylesheets/pages/trials.scss b/app/assets/stylesheets/pages/trials.scss
new file mode 100644
index 00000000000..3fb9054b2b8
--- /dev/null
+++ b/app/assets/stylesheets/pages/trials.scss
@@ -0,0 +1,15 @@
+/*
+* A CSS cross-browser fix for Select2 failire to display HTML5 required warnings
+* MR link https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22716
+*/
+.gl-select2-html5-required-fix div.select2-container+select.select2 {
+ display: block !important;
+ width: 1px;
+ height: 1px;
+ z-index: -1;
+ opacity: 0;
+ margin: -3px auto 0;
+ background-image: none;
+ background-color: transparent;
+ border: 0;
+}
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 1517015dda0..0fd6aafef0d 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -28,6 +28,13 @@
}
}
+@for $i from 1 through 12 {
+ #{'.tab-width-#{$i}'} {
+ -moz-tab-size: $i;
+ tab-size: $i;
+ }
+}
+
.border-width-1px { border-width: 1px; }
.border-bottom-width-1px { border-bottom-width: 1px; }
.border-style-dashed { border-style: dashed; }
@@ -40,7 +47,10 @@
.mh-50vh { max-height: 50vh; }
+.font-size-inherit { font-size: inherit; }
+
.gl-w-64 { width: px-to-rem($grid-size * 8); }
+.gl-h-32 { height: px-to-rem($grid-size * 4); }
.gl-h-64 { height: px-to-rem($grid-size * 8); }
.gl-text-purple { color: $purple; }
@@ -55,8 +65,8 @@
.gl-bg-green-100 { @include gl-bg-green-100;}
.gl-text-blue-500 { @include gl-text-blue-500; }
+.gl-text-gray-700 { @include gl-text-gray-700; }
.gl-text-gray-900 { @include gl-text-gray-900; }
.gl-text-red-700 { @include gl-text-red-700; }
.gl-text-orange-700 { @include gl-text-orange-700; }
.gl-text-green-700 { @include gl-text-green-700; }
-
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 3047ee02680..54c9bde067d 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -3,18 +3,14 @@
class Admin::ApplicationSettingsController < Admin::ApplicationController
include InternalRedirect
+ # NOTE: Use @application_setting in this controller when you need to access
+ # application_settings after it has been modified. This is because the
+ # ApplicationSetting model uses Gitlab::ThreadMemoryCache for caching and the
+ # cache might be stale immediately after an update.
+ # https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/30233
before_action :set_application_setting
+
before_action :whitelist_query_limiting, only: [:usage_data]
- before_action :validate_self_monitoring_feature_flag_enabled, only: [
- :create_self_monitoring_project,
- :status_create_self_monitoring_project,
- :delete_self_monitoring_project,
- :status_delete_self_monitoring_project
- ]
-
- before_action do
- push_frontend_feature_flag(:self_monitoring_project)
- end
VALID_SETTING_PANELS = %w(general integrations repository
ci_cd reporting metrics_and_profiling
@@ -31,10 +27,6 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
define_method(action) { perform_update if submitted? }
end
- def show
- render :general
- end
-
def update
perform_update
end
@@ -64,10 +56,10 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
end
def clear_repository_check_states
- RepositoryCheck::ClearWorker.perform_async
+ RepositoryCheck::ClearWorker.perform_async # rubocop:disable CodeReuse/Worker
redirect_to(
- admin_application_settings_path,
+ general_admin_application_settings_path,
notice: _('Started asynchronous removal of all repository check states.')
)
end
@@ -79,8 +71,9 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
redirect_to ::Gitlab::LetsEncrypt.terms_of_service_url
end
+ # Specs are in spec/requests/self_monitoring_project_spec.rb
def create_self_monitoring_project
- job_id = SelfMonitoringProjectCreateWorker.perform_async
+ job_id = SelfMonitoringProjectCreateWorker.perform_async # rubocop:disable CodeReuse/Worker
render status: :accepted, json: {
job_id: job_id,
@@ -88,6 +81,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
}
end
+ # Specs are in spec/requests/self_monitoring_project_spec.rb
def status_create_self_monitoring_project
job_id = params[:job_id].to_s
@@ -98,10 +92,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
}
end
- if Gitlab::CurrentSettings.instance_administration_project_id.present?
- return render status: :ok, json: self_monitoring_data
-
- elsif SelfMonitoringProjectCreateWorker.in_progress?(job_id)
+ if SelfMonitoringProjectCreateWorker.in_progress?(job_id) # rubocop:disable CodeReuse/Worker
::Gitlab::PollingInterval.set_header(response, interval: 3_000)
return render status: :accepted, json: {
@@ -109,14 +100,19 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
}
end
+ if @application_setting.self_monitoring_project_id.present?
+ return render status: :ok, json: self_monitoring_data
+ end
+
render status: :bad_request, json: {
message: _('Self-monitoring project does not exist. Please check logs ' \
'for any error messages')
}
end
+ # Specs are in spec/requests/self_monitoring_project_spec.rb
def delete_self_monitoring_project
- job_id = SelfMonitoringProjectDeleteWorker.perform_async
+ job_id = SelfMonitoringProjectDeleteWorker.perform_async # rubocop:disable CodeReuse/Worker
render status: :accepted, json: {
job_id: job_id,
@@ -124,6 +120,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
}
end
+ # Specs are in spec/requests/self_monitoring_project_spec.rb
def status_delete_self_monitoring_project
job_id = params[:job_id].to_s
@@ -134,12 +131,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
}
end
- if Gitlab::CurrentSettings.instance_administration_project_id.nil?
- return render status: :ok, json: {
- message: _('Self-monitoring project has been successfully deleted')
- }
-
- elsif SelfMonitoringProjectDeleteWorker.in_progress?(job_id)
+ if SelfMonitoringProjectDeleteWorker.in_progress?(job_id) # rubocop:disable CodeReuse/Worker
::Gitlab::PollingInterval.set_header(response, interval: 3_000)
return render status: :accepted, json: {
@@ -147,6 +139,12 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
}
end
+ if @application_setting.self_monitoring_project_id.nil?
+ return render status: :ok, json: {
+ message: _('Self-monitoring project has been successfully deleted')
+ }
+ end
+
render status: :bad_request, json: {
message: _('Self-monitoring project was not deleted. Please check logs ' \
'for any error messages')
@@ -155,27 +153,13 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
private
- def validate_self_monitoring_feature_flag_enabled
- self_monitoring_project_not_implemented unless Feature.enabled?(:self_monitoring_project)
- end
-
def self_monitoring_data
{
- project_id: Gitlab::CurrentSettings.instance_administration_project_id,
- project_full_path: Gitlab::CurrentSettings.instance_administration_project&.full_path
+ project_id: @application_setting.self_monitoring_project_id,
+ project_full_path: @application_setting.self_monitoring_project&.full_path
}
end
- def self_monitoring_project_not_implemented
- render(
- status: :not_implemented,
- json: {
- message: _('Self-monitoring is not enabled on this GitLab server, contact your administrator.'),
- documentation_url: help_page_path('administration/monitoring/gitlab_instance_administration_project/index')
- }
- )
- end
-
def set_application_setting
@application_setting = ApplicationSetting.current_without_cache
end
@@ -244,7 +228,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
session[:ask_for_usage_stats_consent] = current_user.requires_usage_stats_consent?
end
- redirect_path = referer_path(request) || admin_application_settings_path
+ redirect_path = referer_path(request) || general_admin_application_settings_path
respond_to do |format|
if successful
diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb
index 907b295870d..c017ecee054 100644
--- a/app/controllers/admin/applications_controller.rb
+++ b/app/controllers/admin/applications_controller.rb
@@ -55,6 +55,8 @@ class Admin::ApplicationsController < Admin::ApplicationController
# Only allow a trusted parameter "white list" through.
def application_params
- params.require(:doorkeeper_application).permit(:name, :redirect_uri, :trusted, :scopes)
+ params
+ .require(:doorkeeper_application)
+ .permit(:name, :redirect_uri, :trusted, :scopes, :confidential)
end
end
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index 5455cefdc8e..0245c00aacb 100644
--- a/app/controllers/admin/groups_controller.rb
+++ b/app/controllers/admin/groups_controller.rb
@@ -6,8 +6,7 @@ class Admin::GroupsController < Admin::ApplicationController
before_action :group, only: [:edit, :update, :destroy, :project_update, :members_update]
def index
- @groups = Group.with_statistics.with_route
- @groups = @groups.sort_by_attribute(@sort = params[:sort])
+ @groups = groups.sort_by_attribute(@sort = params[:sort])
@groups = @groups.search(params[:name]) if params[:name].present?
@groups = @groups.page(params[:page])
end
@@ -75,6 +74,10 @@ class Admin::GroupsController < Admin::ApplicationController
private
+ def groups
+ Group.with_statistics.with_route
+ end
+
def group
@group ||= Group.find_by_full_path(params[:id])
end
diff --git a/app/controllers/admin/logs_controller.rb b/app/controllers/admin/logs_controller.rb
index 14245300633..3ae0aef0fa4 100644
--- a/app/controllers/admin/logs_controller.rb
+++ b/app/controllers/admin/logs_controller.rb
@@ -10,7 +10,7 @@ class Admin::LogsController < Admin::ApplicationController
def loggers
@loggers ||= [
- Gitlab::AppLogger,
+ Gitlab::AppJsonLogger,
Gitlab::GitLogger,
Gitlab::EnvironmentLogger,
Gitlab::SidekiqLogger,
diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb
index cdedc34e634..7015da8bd50 100644
--- a/app/controllers/admin/projects_controller.rb
+++ b/app/controllers/admin/projects_controller.rb
@@ -55,7 +55,7 @@ class Admin::ProjectsController < Admin::ApplicationController
# rubocop: enable CodeReuse/ActiveRecord
def repository_check
- RepositoryCheck::SingleRepositoryWorker.perform_async(@project.id)
+ RepositoryCheck::SingleRepositoryWorker.perform_async(@project.id) # rubocop:disable CodeReuse/Worker
redirect_to(
admin_project_path(@project),
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index 783c59822f1..9eaa55039c8 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -66,7 +66,7 @@ class Admin::RunnersController < Admin::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def assign_builds_and_projects
- @builds = runner.builds.order('id DESC').first(30)
+ @builds = runner.builds.order('id DESC').preload_project_and_pipeline_project.first(30)
@projects =
if params[:search].present?
::Project.search(params[:search])
@@ -75,7 +75,8 @@ class Admin::RunnersController < Admin::ApplicationController
end
@projects = @projects.where.not(id: runner.projects.select(:id)) if runner.projects.any?
- @projects = @projects.page(params[:page]).per(30)
+ @projects = @projects.inc_routes
+ @projects = @projects.page(params[:page]).per(30).without_count
end
# rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/controllers/admin/serverless/domains_controller.rb b/app/controllers/admin/serverless/domains_controller.rb
new file mode 100644
index 00000000000..c37aec13105
--- /dev/null
+++ b/app/controllers/admin/serverless/domains_controller.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+class Admin::Serverless::DomainsController < Admin::ApplicationController
+ before_action :check_feature_flag
+ before_action :domain, only: [:update, :verify]
+
+ def index
+ @domain = PagesDomain.instance_serverless.first_or_initialize
+ end
+
+ def create
+ if PagesDomain.instance_serverless.count > 0
+ return redirect_to admin_serverless_domains_path, notice: _('An instance-level serverless domain already exists.')
+ end
+
+ @domain = PagesDomain.instance_serverless.create(create_params)
+
+ if @domain.persisted?
+ redirect_to admin_serverless_domains_path, notice: _('Domain was successfully created.')
+ else
+ render 'index'
+ end
+ end
+
+ def update
+ if domain.update(update_params)
+ redirect_to admin_serverless_domains_path, notice: _('Domain was successfully updated.')
+ else
+ render 'index'
+ end
+ end
+
+ def verify
+ result = VerifyPagesDomainService.new(domain).execute
+
+ if result[:status] == :success
+ flash[:notice] = _('Successfully verified domain ownership')
+ else
+ flash[:alert] = _('Failed to verify domain ownership')
+ end
+
+ redirect_to admin_serverless_domains_path
+ end
+
+ private
+
+ def domain
+ @domain = PagesDomain.instance_serverless.find(params[:id])
+ end
+
+ def check_feature_flag
+ render_404 unless Feature.enabled?(:serverless_domain)
+ end
+
+ def update_params
+ params.require(:pages_domain).permit(:user_provided_certificate, :user_provided_key)
+ end
+
+ def create_params
+ params.require(:pages_domain).permit(:domain, :user_provided_certificate, :user_provided_key)
+ end
+end
diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb
index e31e0e09978..50b79cde5c5 100644
--- a/app/controllers/admin/services_controller.rb
+++ b/app/controllers/admin/services_controller.rb
@@ -19,7 +19,7 @@ class Admin::ServicesController < Admin::ApplicationController
def update
if service.update(service_params[:service])
- PropagateServiceTemplateWorker.perform_async(service.id) if service.active?
+ PropagateServiceTemplateWorker.perform_async(service.id) if service.active? # rubocop:disable CodeReuse/Worker
redirect_to admin_application_settings_services_path,
notice: 'Application settings saved successfully'
diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb
index a41d8a22650..689e502a221 100644
--- a/app/controllers/admin/spam_logs_controller.rb
+++ b/app/controllers/admin/spam_logs_controller.rb
@@ -24,7 +24,7 @@ class Admin::SpamLogsController < Admin::ApplicationController
def mark_as_ham
spam_log = SpamLog.find(params[:id])
- if HamService.new(spam_log).mark_as_ham!
+ if Spam::HamService.new(spam_log).execute
redirect_to admin_spam_logs_path, notice: _('Spam log successfully submitted as ham.')
else
redirect_to admin_spam_logs_path, alert: _('Error with Akismet. Please check the logs for more info.')
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 9fbfc59f630..8414095d454 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -75,7 +75,9 @@ class Admin::UsersController < Admin::ApplicationController
end
def block
- if update_user { |user| user.block }
+ result = Users::BlockService.new(current_user).execute(user)
+
+ if result[:status] = :success
redirect_back_or_admin_user(notice: _("Successfully blocked"))
else
redirect_back_or_admin_user(alert: _("Error occurred. User was not blocked"))
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index acbc25220a0..7cb629dee21 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -37,6 +37,7 @@ class ApplicationController < ActionController::Base
around_action :set_current_context
around_action :set_locale
around_action :set_session_storage
+ around_action :set_current_admin
after_action :set_page_title_header, if: :json_request?
after_action :limit_session_time, if: -> { !current_user }
@@ -120,7 +121,7 @@ class ApplicationController < ActionController::Base
def render(*args)
super.tap do
# Set a header for custom error pages to prevent them from being intercepted by gitlab-workhorse
- if (400..599).cover?(response.status) && workhorse_excluded_content_types.include?(response.content_type)
+ if (400..599).cover?(response.status) && workhorse_excluded_content_types.include?(response.media_type)
response.headers['X-GitLab-Custom-Error'] = '1'
end
end
@@ -454,6 +455,7 @@ class ApplicationController < ActionController::Base
user: -> { auth_user },
project: -> { @project },
namespace: -> { @group },
+ caller_id: full_action_name,
&block)
end
@@ -472,6 +474,13 @@ class ApplicationController < ActionController::Base
response.headers['Page-Title'] = URI.escape(page_title('GitLab'))
end
+ def set_current_admin(&block)
+ return yield unless Feature.enabled?(:user_mode_in_session)
+ return yield unless current_user
+
+ Gitlab::Auth::CurrentUserMode.with_current_admin(current_user, &block)
+ end
+
def html_request?
request.format.html?
end
@@ -551,6 +560,10 @@ class ApplicationController < ActionController::Base
end
end
+ def full_action_name
+ "#{self.class.name}##{action_name}"
+ end
+
# A user requires a role and have the setup_for_company attribute set 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.
diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb
index 1d6711e3c22..5e14339bb07 100644
--- a/app/controllers/boards/issues_controller.rb
+++ b/app/controllers/boards/issues_controller.rb
@@ -20,6 +20,9 @@ module Boards
skip_before_action :authenticate_user!, only: [:index]
before_action :validate_id_list, only: [:bulk_move]
before_action :can_move_issues?, only: [:bulk_move]
+ before_action do
+ push_frontend_feature_flag(:board_search_optimization, board.group)
+ end
# rubocop: disable CodeReuse/ActiveRecord
def index
@@ -83,8 +86,12 @@ module Boards
head(:forbidden) unless can?(current_user, :admin_issue, board)
end
+ def serializer_options(issues)
+ {}
+ end
+
def render_issues(issues, metadata)
- data = { issues: serialize_as_json(issues) }
+ data = { issues: serialize_as_json(issues, opts: serializer_options(issues)) }
data.merge!(metadata)
render json: data
@@ -130,8 +137,10 @@ module Boards
IssueSerializer.new(current_user: current_user)
end
- def serialize_as_json(resource)
- serializer.represent(resource, serializer: 'board', include_full_project_path: board.group_board?)
+ def serialize_as_json(resource, opts: {})
+ opts.merge!(include_full_project_path: board.group_board?, serializer: 'board')
+
+ serializer.represent(resource, opts)
end
def whitelist_query_limiting
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index 52a5f801bad..2c9ee69c8c4 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -12,9 +12,6 @@ class Clusters::ClustersController < Clusters::BaseController
before_action :authorize_update_cluster!, only: [:update]
before_action :authorize_admin_cluster!, only: [:destroy, :clear_cache]
before_action :update_applications_status, only: [:cluster_status]
- before_action only: [:show] do
- push_frontend_feature_flag(:enable_cluster_application_elastic_stack)
- end
helper_method :token_in_session
diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb
index 8c8f0b3a22e..6f0c7abac16 100644
--- a/app/controllers/concerns/authenticates_with_two_factor.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor.rb
@@ -21,21 +21,28 @@ module AuthenticatesWithTwoFactor
# Set @user for Devise views
@user = user # rubocop:disable Gitlab/ModuleWithInstanceVariables
- return locked_user_redirect(user) unless user.can?(:log_in)
+ return handle_locked_user(user) unless user.can?(:log_in)
session[:otp_user_id] = user.id
setup_u2f_authentication(user)
render 'devise/sessions/two_factor'
end
+ def handle_locked_user(user)
+ clear_two_factor_attempt!
+
+ locked_user_redirect(user)
+ end
+
def locked_user_redirect(user)
- flash.now[:alert] = _('Invalid Login or password')
+ flash.now[:alert] = locked_user_redirect_alert(user)
+
render 'devise/sessions/new'
end
def authenticate_with_two_factor
user = self.resource = find_user
- return locked_user_redirect(user) unless user.can?(:log_in)
+ return handle_locked_user(user) unless user.can?(:log_in)
if user_params[:otp_attempt].present? && session[:otp_user_id]
authenticate_with_two_factor_via_otp(user)
@@ -48,6 +55,14 @@ module AuthenticatesWithTwoFactor
private
+ def locked_user_redirect_alert(user)
+ user.access_locked? ? _('Your account is locked.') : _('Invalid Login or password')
+ end
+
+ def clear_two_factor_attempt!
+ session.delete(:otp_user_id)
+ end
+
def authenticate_with_two_factor_via_otp(user)
if valid_otp_attempt?(user)
# Remove any lingering user data from login
diff --git a/app/controllers/concerns/confirm_email_warning.rb b/app/controllers/concerns/confirm_email_warning.rb
index 32e1a46e580..f1c0bcd491d 100644
--- a/app/controllers/concerns/confirm_email_warning.rb
+++ b/app/controllers/concerns/confirm_email_warning.rb
@@ -10,7 +10,7 @@ module ConfirmEmailWarning
protected
def show_confirm_warning?
- html_request? && request.get? && Feature.enabled?(:soft_email_confirmation)
+ html_request? && request.get?
end
def set_confirm_warning
diff --git a/app/controllers/concerns/cycle_analytics_params.rb b/app/controllers/concerns/cycle_analytics_params.rb
index a78d803927c..3e67f1f54cb 100644
--- a/app/controllers/concerns/cycle_analytics_params.rb
+++ b/app/controllers/concerns/cycle_analytics_params.rb
@@ -10,9 +10,9 @@ module CycleAnalyticsParams
end
def cycle_analytics_group_params
- return {} unless params[:cycle_analytics].present?
+ return {} unless params.present?
- params[:cycle_analytics].permit(:start_date, :created_after, :created_before, project_ids: [])
+ params.permit(:group_id, :start_date, :created_after, :created_before, project_ids: [])
end
def options(params)
diff --git a/app/controllers/concerns/invisible_captcha.rb b/app/controllers/concerns/invisible_captcha.rb
index d56f1d7fa5f..45c0a5c58ef 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) || experiment_enabled?(:signup_flow)
+ return unless Feature.enabled?(:invisible_captcha)
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) || experiment_enabled?(:signup_flow)
+ return unless Feature.enabled?(:invisible_captcha)
invisible_captcha_timestamp_counter.increment
log_request('Invisible_Captcha_Timestamp_Request')
diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb
index 61072eec535..3152d959ae4 100644
--- a/app/controllers/concerns/lfs_request.rb
+++ b/app/controllers/concerns/lfs_request.rb
@@ -112,10 +112,6 @@ module LfsRequest
has_authentication_ability?(:build_download_code) && can?(user, :build_download_code, project)
end
- def storage_project
- @storage_project ||= project.lfs_storage_project
- end
-
def objects
@objects ||= (params[:objects] || []).to_a
end
diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb
index 993f091b0e6..1cf9046e30f 100644
--- a/app/controllers/concerns/membership_actions.rb
+++ b/app/controllers/concerns/membership_actions.rb
@@ -21,9 +21,9 @@ module MembershipActions
member = Members::UpdateService
.new(current_user, update_params)
.execute(member)
- .present(current_user: current_user)
- present_members([member])
+ member = present_members([member]).first
+
respond_to do |format|
format.js { render 'shared/members/update', locals: { member: member } }
end
diff --git a/app/controllers/concerns/metrics_dashboard.rb b/app/controllers/concerns/metrics_dashboard.rb
index dc392147cb8..fa79f3bc4e6 100644
--- a/app/controllers/concerns/metrics_dashboard.rb
+++ b/app/controllers/concerns/metrics_dashboard.rb
@@ -5,6 +5,7 @@
module MetricsDashboard
include RenderServiceResults
include ChecksCollaboration
+ include EnvironmentsHelper
extend ActiveSupport::Concern
@@ -15,8 +16,9 @@ module MetricsDashboard
metrics_dashboard_params.to_h.symbolize_keys
)
- if include_all_dashboards? && result
- result[:all_dashboards] = all_dashboards
+ if result
+ result[:all_dashboards] = all_dashboards if include_all_dashboards?
+ result[:metrics_data] = metrics_data(project_for_dashboard, environment_for_dashboard) if project_for_dashboard && environment_for_dashboard
end
respond_to do |format|
@@ -76,10 +78,14 @@ module MetricsDashboard
defined?(project) ? project : nil
end
+ def environment_for_dashboard
+ defined?(environment) ? environment : nil
+ end
+
def dashboard_success_response(result)
{
status: :ok,
- json: result.slice(:all_dashboards, :dashboard, :status)
+ json: result.slice(:all_dashboards, :dashboard, :status, :metrics_data)
}
end
diff --git a/app/controllers/concerns/page_limiter.rb b/app/controllers/concerns/page_limiter.rb
new file mode 100644
index 00000000000..3c280fa4f12
--- /dev/null
+++ b/app/controllers/concerns/page_limiter.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+# Include this in your controller and call `limit_pages` in order
+# to configure the limiter.
+#
+# Examples:
+# class MyController < ApplicationController
+# include PageLimiter
+#
+# before_action only: [:index] do
+# limit_pages(500)
+# end
+#
+# # You can override the default response
+# rescue_from PageOutOfBoundsError, with: :page_out_of_bounds
+#
+# def page_out_of_bounds(error)
+# # Page limit number is available as error.message
+# head :ok
+# end
+#
+
+module PageLimiter
+ extend ActiveSupport::Concern
+
+ PageLimiterError = Class.new(StandardError)
+ PageLimitNotANumberError = Class.new(PageLimiterError)
+ PageLimitNotSensibleError = Class.new(PageLimiterError)
+ PageOutOfBoundsError = Class.new(PageLimiterError)
+
+ included do
+ rescue_from PageOutOfBoundsError, with: :default_page_out_of_bounds_response
+ end
+
+ def limit_pages(max_page_number)
+ check_page_number!(max_page_number)
+ end
+
+ private
+
+ # If the page exceeds the defined maximum, raise a PageOutOfBoundsError
+ # If the page doesn't exceed the limit, it does nothing.
+ def check_page_number!(max_page_number)
+ raise PageLimitNotANumberError unless max_page_number.is_a?(Integer)
+ raise PageLimitNotSensibleError unless max_page_number > 0
+
+ if params[:page].present? && params[:page].to_i > max_page_number
+ record_page_limit_interception
+ raise PageOutOfBoundsError.new(max_page_number)
+ end
+ end
+
+ # By default just return a HTTP status code and an empty response
+ def default_page_out_of_bounds_response
+ head :bad_request
+ end
+
+ # Record the page limit being hit in Prometheus
+ def record_page_limit_interception
+ dd = DeviceDetector.new(request.user_agent)
+
+ Gitlab::Metrics.counter(:gitlab_page_out_of_bounds,
+ controller: params[:controller],
+ action: params[:action],
+ bot: dd.bot?
+ ).increment
+ end
+end
diff --git a/app/controllers/concerns/send_file_upload.rb b/app/controllers/concerns/send_file_upload.rb
index 28e4cece548..2f5dc09be4a 100644
--- a/app/controllers/concerns/send_file_upload.rb
+++ b/app/controllers/concerns/send_file_upload.rb
@@ -3,7 +3,7 @@
module SendFileUpload
def send_upload(file_upload, send_params: {}, redirect_params: {}, attachment: nil, proxy: false, disposition: 'attachment')
if attachment
- response_disposition = ::Gitlab::ContentDisposition.format(disposition: disposition, filename: attachment)
+ response_disposition = ActionDispatch::Http::ContentDisposition.format(disposition: disposition, filename: attachment)
# Response-Content-Type will not override an existing Content-Type in
# Google Cloud Storage, so the metadata needs to be cleared on GCS for
@@ -15,7 +15,7 @@ module SendFileUpload
# cross-origin JavaScript protection.
send_params[:content_type] = 'text/plain' if File.extname(attachment) == '.js'
- send_params.merge!(filename: attachment, disposition: utf8_encoded_disposition(disposition, attachment))
+ send_params.merge!(filename: attachment, disposition: disposition)
end
if file_upload.file_storage?
@@ -28,18 +28,6 @@ module SendFileUpload
end
end
- # Since Rails 5 doesn't properly support support non-ASCII filenames,
- # we have to add our own to ensure RFC 5987 compliance. However, Rails
- # 5 automatically appends `filename#{filename}` here:
- # https://github.com/rails/rails/blob/v5.0.7/actionpack/lib/action_controller/metal/data_streaming.rb#L137
- # Rails 6 will have https://github.com/rails/rails/pull/33829, so we
- # can get rid of this special case handling when we upgrade.
- def utf8_encoded_disposition(disposition, filename)
- content = ::Gitlab::ContentDisposition.new(disposition: disposition, filename: filename)
-
- "#{disposition}; #{content.utf8_filename}"
- end
-
def guess_content_type(filename)
types = MIME::Types.type_for(filename)
diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb
index 21ee76d31b2..f99345fa99d 100644
--- a/app/controllers/confirmations_controller.rb
+++ b/app/controllers/confirmations_controller.rb
@@ -11,7 +11,7 @@ class ConfirmationsController < Devise::ConfirmationsController
protected
def after_resending_confirmation_instructions_path_for(resource)
- Feature.enabled?(:soft_email_confirmation) ? stored_location_for(resource) || dashboard_projects_path : users_almost_there_path
+ stored_location_for(resource) || dashboard_projects_path
end
def after_confirmation_path_for(resource_name, resource)
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index 9659d7719b9..039991e07a2 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -66,6 +66,8 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
@total_user_projects_count = ProjectsFinder.new(params: { non_public: true }, current_user: current_user).execute
@total_starred_projects_count = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute
+ finder_params[:use_cte] = true if use_cte_for_finder?
+
projects = ProjectsFinder
.new(params: finder_params, current_user: current_user)
.execute
@@ -77,6 +79,11 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
end
# rubocop: enable CodeReuse/ActiveRecord
+ def use_cte_for_finder?
+ # The starred action loads public projects, which causes the CTE to be less efficient
+ action_name == 'index' && Feature.enabled?(:use_cte_for_projects_finder, default_enabled: true)
+ end
+
def load_events
projects = load_projects(params.merge(non_public: true))
diff --git a/app/controllers/dashboard/snippets_controller.rb b/app/controllers/dashboard/snippets_controller.rb
index 6feade3df03..aa09fcdbe61 100644
--- a/app/controllers/dashboard/snippets_controller.rb
+++ b/app/controllers/dashboard/snippets_controller.rb
@@ -7,6 +7,10 @@ class Dashboard::SnippetsController < Dashboard::ApplicationController
skip_cross_project_access_check :index
def index
+ @snippet_counts = Snippets::CountService
+ .new(current_user, author: current_user)
+ .execute
+
@snippets = SnippetsFinder.new(current_user, author: current_user, scope: params[:scope])
.execute
.page(params[:page])
diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb
index 271f2b4b57d..a8a76b47bbe 100644
--- a/app/controllers/explore/projects_controller.rb
+++ b/app/controllers/explore/projects_controller.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class Explore::ProjectsController < Explore::ApplicationController
+ include PageLimiter
include ParamsBackwardCompatibility
include RendersMemberAccess
include SortingHelper
@@ -9,6 +10,13 @@ class Explore::ProjectsController < Explore::ApplicationController
before_action :set_non_archived_param
before_action :set_sorting
+ # Limit taken from https://gitlab.com/gitlab-org/gitlab/issues/38357
+ before_action only: [:index, :trending, :starred] do
+ limit_pages(200)
+ end
+
+ rescue_from PageOutOfBoundsError, with: :page_out_of_bounds
+
def index
@projects = load_projects
@@ -53,10 +61,14 @@ class Explore::ProjectsController < Explore::ApplicationController
private
- # rubocop: disable CodeReuse/ActiveRecord
- def load_projects
+ def load_project_counts
@total_user_projects_count = ProjectsFinder.new(params: { non_public: true }, current_user: current_user).execute
@total_starred_projects_count = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def load_projects
+ load_project_counts
projects = ProjectsFinder.new(current_user: current_user, params: params)
.execute
@@ -80,4 +92,21 @@ class Explore::ProjectsController < Explore::ApplicationController
def sorting_field
Project::SORTING_PREFERENCE_FIELD
end
+
+ def page_out_of_bounds(error)
+ load_project_counts
+ @max_page_number = error.message
+
+ respond_to do |format|
+ format.html do
+ render "page_out_of_bounds", status: :bad_request
+ end
+
+ format.json do
+ render json: {
+ html: view_to_html_string("explore/projects/page_out_of_bounds")
+ }, status: :bad_request
+ end
+ end
+ end
end
diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb
index d03a50f6f77..0760bdf1e01 100644
--- a/app/controllers/groups/application_controller.rb
+++ b/app/controllers/groups/application_controller.rb
@@ -20,6 +20,14 @@ class Groups::ApplicationController < ApplicationController
@projects ||= GroupProjectsFinder.new(group: group, current_user: current_user).execute
end
+ def group_projects_with_subgroups
+ @group_projects_with_subgroups ||= GroupProjectsFinder.new(
+ group: group,
+ current_user: current_user,
+ options: { include_subgroups: true }
+ ).execute
+ end
+
def authorize_admin_group!
unless can?(current_user, :admin_group, group)
return render_404
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index 8c9bf17f017..fab84fb8299 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -4,6 +4,7 @@ class Groups::BoardsController < Groups::ApplicationController
include BoardsActions
include RecordUserLastActivity
+ before_action :authorize_read_board!, only: [:index, :show]
before_action :assign_endpoint_vars
before_action do
push_frontend_feature_flag(:multi_select_board, default_enabled: true)
@@ -16,4 +17,8 @@ class Groups::BoardsController < Groups::ApplicationController
@namespace_path = group.to_param
@labels_endpoint = group_labels_url(group)
end
+
+ def authorize_read_board!
+ access_denied! unless can?(current_user, :read_board, group)
+ end
end
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index 7eba73daa3c..a478e9fffb8 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -103,8 +103,15 @@ class Groups::MilestonesController < Groups::ApplicationController
end
def group_projects_with_access
- group_projects.with_issues_available_for_user(current_user)
- .or(group_projects.with_merge_requests_available_for_user(current_user))
+ group_projects_with_subgroups.with_issues_or_mrs_available_for_user(current_user)
+ end
+
+ def group_ids(include_ancestors: false)
+ if include_ancestors
+ group.self_and_hierarchy.public_or_visible_to_user(current_user).select(:id)
+ else
+ group.self_and_descendants.public_or_visible_to_user(current_user).select(:id)
+ end
end
def milestone
@@ -119,7 +126,7 @@ class Groups::MilestonesController < Groups::ApplicationController
end
def search_params
- groups = request.format.json? ? group.self_and_ancestors.select(:id) : group.id
+ groups = request.format.json? ? group_ids(include_ancestors: true) : group_ids
params.permit(:state, :search_title).merge(group_ids: groups)
end
diff --git a/app/controllers/groups/registry/repositories_controller.rb b/app/controllers/groups/registry/repositories_controller.rb
index cfddd8a3ba9..84c25cfb180 100644
--- a/app/controllers/groups/registry/repositories_controller.rb
+++ b/app/controllers/groups/registry/repositories_controller.rb
@@ -7,20 +7,31 @@ module Groups
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
+ track_event(:list_repositories)
+
+ serializer = ContainerRepositoriesSerializer
.new(current_user: current_user)
- .represent_read_only(@images)
+
+ if Feature.enabled?(:vue_container_registry_explorer)
+ render json: serializer.with_pagination(request, response)
+ .represent_read_only(@images)
+ else
+ render json: serializer.represent_read_only(@images)
+ end
end
end
end
+ # The show action renders index to allow frontend routing to work on page refresh
+ def show
+ render :index
+ end
+
private
def feature_flag_group_container_registry_browser!
diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb
index ca35b07111c..4c9aac9a327 100644
--- a/app/controllers/ide_controller.rb
+++ b/app/controllers/ide_controller.rb
@@ -3,10 +3,6 @@
class IdeController < ApplicationController
layout 'fullscreen'
- before_action do
- push_frontend_feature_flag(:stage_all_by_default, default_enabled: true)
- end
-
def index
Gitlab::UsageDataCounters::WebIdeCounter.increment_views_count
end
diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb
index 9b45be6db99..04919a4b9d0 100644
--- a/app/controllers/import/base_controller.rb
+++ b/app/controllers/import/base_controller.rb
@@ -1,18 +1,20 @@
# frozen_string_literal: true
class Import::BaseController < ApplicationController
+ before_action :import_rate_limit, only: [:create]
+
private
# rubocop: disable CodeReuse/ActiveRecord
def find_already_added_projects(import_type)
- current_user.created_projects.where(import_type: import_type).includes(:import_state)
+ current_user.created_projects.where(import_type: import_type).with_import_state
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def find_jobs(import_type)
current_user.created_projects
- .includes(:import_state)
+ .with_import_state
.where(import_type: import_type)
.to_json(only: [:id], methods: [:import_status])
end
@@ -37,4 +39,18 @@ class Import::BaseController < ApplicationController
def project_save_error(project)
project.errors.full_messages.join(', ')
end
+
+ def import_rate_limit
+ key = "project_import".to_sym
+
+ if rate_limiter.throttled?(key, scope: [current_user, key])
+ rate_limiter.log_request(request, "#{key}_request_limit".to_sym, current_user)
+
+ redirect_back_or_default(options: { alert: _('This endpoint has been requested too many times. Try again later.') })
+ end
+ end
+
+ def rate_limiter
+ ::Gitlab::ApplicationRateLimiter
+ end
end
diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb
index dc72a4e4fd9..5fb7b5dccc5 100644
--- a/app/controllers/import/bitbucket_server_controller.rb
+++ b/app/controllers/import/bitbucket_server_controller.rb
@@ -82,7 +82,7 @@ class Import::BitbucketServerController < Import::BaseController
# rubocop: disable CodeReuse/ActiveRecord
def filter_added_projects(import_type, import_sources)
- current_user.created_projects.where(import_type: import_type, import_source: import_sources).includes(:import_state)
+ current_user.created_projects.where(import_type: import_type, import_source: import_sources).with_import_state
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/import/manifest_controller.rb b/app/controllers/import/manifest_controller.rb
index 7ba8b3ce938..9aec870c6ea 100644
--- a/app/controllers/import/manifest_controller.rb
+++ b/app/controllers/import/manifest_controller.rb
@@ -87,7 +87,7 @@ class Import::ManifestController < Import::BaseController
group.all_projects
.where(import_type: 'manifest')
.where(creator_id: current_user)
- .includes(:import_state)
+ .with_import_state
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb
index bbf0bdd3662..2c3e60d12b7 100644
--- a/app/controllers/oauth/applications_controller.rb
+++ b/app/controllers/oauth/applications_controller.rb
@@ -8,8 +8,12 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
include Gitlab::Experimentation::ControllerConcern
include InitializesCurrentUserMode
- before_action :verify_user_oauth_applications_enabled, except: :index
- before_action :authenticate_user!
+ # Defined by the `Doorkeeper::ApplicationsController` and is redundant as we call `authenticate_user!` below. Not
+ # defining or skipping this will result in a `403` response to all requests.
+ skip_before_action :authenticate_admin!
+
+ prepend_before_action :verify_user_oauth_applications_enabled, except: :index
+ prepend_before_action :authenticate_user!
before_action :add_gon_variables
before_action :load_scopes, only: [:index, :create, :edit, :update]
diff --git a/app/controllers/oauth/token_info_controller.rb b/app/controllers/oauth/token_info_controller.rb
new file mode 100644
index 00000000000..492c24b53b1
--- /dev/null
+++ b/app/controllers/oauth/token_info_controller.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class Oauth::TokenInfoController < Doorkeeper::TokenInfoController
+ def show
+ if doorkeeper_token && doorkeeper_token.accessible?
+ token_json = doorkeeper_token.as_json
+
+ # maintain backwards compatibility
+ render json: token_json.merge(
+ 'scopes' => token_json[:scope],
+ 'expires_in_seconds' => token_json[:expires_in]
+ ), status: :ok
+ else
+ error = Doorkeeper::OAuth::ErrorResponse.new(name: :invalid_request)
+ response.headers.merge!(error.headers)
+ render json: error.body, status: error.status
+ end
+ end
+end
diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb
index d295b64082c..064b2a2cc12 100644
--- a/app/controllers/profiles/notifications_controller.rb
+++ b/app/controllers/profiles/notifications_controller.rb
@@ -4,13 +4,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)
+ @group_notifications = current_user.notification_settings.preload_source_route.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)
+ .preload_source_route
.select { |notification| current_user.can?(:read_project, notification.source) }
@global_notification_setting = current_user.global_notification_setting
end
diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb
index 2166dd7dad7..1477d79c911 100644
--- a/app/controllers/profiles/preferences_controller.rb
+++ b/app/controllers/profiles/preferences_controller.rb
@@ -48,6 +48,7 @@ class Profiles::PreferencesController < Profiles::ApplicationController
:time_display_relative,
:time_format_in_24h,
:show_whitespace_in_diffs,
+ :tab_width,
:sourcegraph_enabled,
:render_whitespace_in_code
]
diff --git a/app/controllers/projects/alerting/notifications_controller.rb b/app/controllers/projects/alerting/notifications_controller.rb
new file mode 100644
index 00000000000..1fe31863469
--- /dev/null
+++ b/app/controllers/projects/alerting/notifications_controller.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Projects
+ module Alerting
+ class NotificationsController < Projects::ApplicationController
+ respond_to :json
+
+ skip_before_action :verify_authenticity_token
+ skip_before_action :project
+
+ prepend_before_action :repository, :project_without_auth
+
+ def create
+ token = extract_alert_manager_token(request)
+ result = notify_service.execute(token)
+
+ head(response_status(result))
+ end
+
+ private
+
+ def project_without_auth
+ @project ||= Project
+ .find_by_full_path("#{params[:namespace_id]}/#{params[:project_id]}")
+ end
+
+ def extract_alert_manager_token(request)
+ Doorkeeper::OAuth::Token.from_bearer_authorization(request)
+ end
+
+ def notify_service
+ Projects::Alerting::NotifyService
+ .new(project, current_user, notification_payload)
+ end
+
+ def response_status(result)
+ return :ok if result.success?
+
+ result.http_status
+ end
+
+ def notification_payload
+ params.permit![:notification]
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 3cd14cf845f..01e5103198b 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -29,6 +29,10 @@ class Projects::BlobController < Projects::ApplicationController
before_action :validate_diff_params, only: :diff
before_action :set_last_commit_sha, only: [:edit, :update]
+ before_action only: :show do
+ push_frontend_feature_flag(:code_navigation, @project)
+ end
+
def new
commit unless @repository.empty?
end
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index 70c4b536854..5c49fa842a4 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -16,7 +16,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
push_frontend_feature_flag(:prometheus_computed_alerts)
end
before_action do
- push_frontend_feature_flag(:auto_stop_environments)
+ push_frontend_feature_flag(:auto_stop_environments, default_enabled: true)
end
after_action :expire_etag_cache, only: [:cancel_auto_stop]
diff --git a/app/controllers/projects/error_tracking_controller.rb b/app/controllers/projects/error_tracking_controller.rb
index 88f739ce29e..b4b03e219ab 100644
--- a/app/controllers/projects/error_tracking_controller.rb
+++ b/app/controllers/projects/error_tracking_controller.rb
@@ -30,7 +30,7 @@ class Projects::ErrorTrackingController < Projects::ErrorTracking::BaseControlle
service = ErrorTracking::IssueUpdateService.new(project, current_user, issue_update_params)
result = service.execute
- return if handle_errors(result)
+ return if render_errors(result)
render json: {
result: result
@@ -47,7 +47,7 @@ class Projects::ErrorTrackingController < Projects::ErrorTracking::BaseControlle
)
result = service.execute
- return if handle_errors(result)
+ return if render_errors(result)
render json: {
errors: serialize_errors(result[:issues]),
@@ -60,14 +60,14 @@ class Projects::ErrorTrackingController < Projects::ErrorTracking::BaseControlle
service = ErrorTracking::IssueDetailsService.new(project, current_user, issue_details_params)
result = service.execute
- return if handle_errors(result)
+ return if render_errors(result)
render json: {
error: serialize_detailed_error(result[:issue])
}
end
- def handle_errors(result)
+ def render_errors(result)
unless result[:status] == :success
render json: { message: result[:message] },
status: result[:http_status] || :bad_request
@@ -75,7 +75,7 @@ class Projects::ErrorTrackingController < Projects::ErrorTracking::BaseControlle
end
def list_issues_params
- params.permit(:search_term, :sort, :cursor)
+ params.permit(:search_term, :sort, :cursor, :issue_status)
end
def issue_update_params
diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb
deleted file mode 100644
index 3f6e116a62b..00000000000
--- a/app/controllers/projects/git_http_client_controller.rb
+++ /dev/null
@@ -1,122 +0,0 @@
-# frozen_string_literal: true
-
-class Projects::GitHttpClientController < Projects::ApplicationController
- include ActionController::HttpAuthentication::Basic
- include KerberosSpnegoHelper
- include Gitlab::Utils::StrongMemoize
-
- attr_reader :authentication_result, :redirected_path
-
- delegate :actor, :authentication_abilities, to: :authentication_result, allow_nil: true
- delegate :type, to: :authentication_result, allow_nil: true, prefix: :auth_result
-
- alias_method :user, :actor
- alias_method :authenticated_user, :actor
-
- # Git clients will not know what authenticity token to send along
- skip_around_action :set_session_storage
- skip_before_action :verify_authenticity_token
- skip_before_action :repository
- before_action :authenticate_user
-
- private
-
- def download_request?
- raise NotImplementedError
- end
-
- def upload_request?
- raise NotImplementedError
- end
-
- def authenticate_user
- @authentication_result = Gitlab::Auth::Result.new
-
- if allow_basic_auth? && basic_auth_provided?
- login, password = user_name_and_password(request)
-
- if handle_basic_authentication(login, password)
- return # Allow access
- end
- elsif allow_kerberos_spnego_auth? && spnego_provided?
- kerberos_user = find_kerberos_user
-
- if kerberos_user
- @authentication_result = Gitlab::Auth::Result.new(
- kerberos_user, nil, :kerberos, Gitlab::Auth.full_authentication_abilities)
-
- send_final_spnego_response
- return # Allow access
- end
- elsif http_download_allowed?
-
- @authentication_result = Gitlab::Auth::Result.new(nil, project, :none, [:download_code])
-
- return # Allow access
- end
-
- send_challenges
- render plain: "HTTP Basic: Access denied\n", status: :unauthorized
- rescue Gitlab::Auth::MissingPersonalAccessTokenError
- render_missing_personal_access_token
- end
-
- def basic_auth_provided?
- has_basic_credentials?(request)
- end
-
- def send_challenges
- challenges = []
- challenges << 'Basic realm="GitLab"' if allow_basic_auth?
- challenges << spnego_challenge if allow_kerberos_spnego_auth?
- headers['Www-Authenticate'] = challenges.join("\n") if challenges.any?
- end
-
- def project
- parse_repo_path unless defined?(@project)
-
- @project
- end
-
- def parse_repo_path
- @project, @repo_type, @redirected_path = Gitlab::RepoPath.parse("#{params[:namespace_id]}/#{params[:project_id]}")
- end
-
- def render_missing_personal_access_token
- render plain: "HTTP Basic: Access denied\n" \
- "You must use a personal access token with 'read_repository' or 'write_repository' scope for Git over HTTP.\n" \
- "You can generate one at #{profile_personal_access_tokens_url}",
- status: :unauthorized
- end
-
- def repository
- strong_memoize(:repository) do
- repo_type.repository_for(project)
- end
- end
-
- def repo_type
- parse_repo_path unless defined?(@repo_type)
-
- @repo_type
- end
-
- def handle_basic_authentication(login, password)
- @authentication_result = Gitlab::Auth.find_for_git_client(
- login, password, project: project, ip: request.ip)
-
- @authentication_result.success?
- end
-
- def ci?
- authentication_result.ci?(project)
- end
-
- def http_download_allowed?
- Gitlab::ProtocolAccess.allowed?('http') &&
- download_request? &&
- project && Guest.can?(:download_code, project)
- end
-end
-
-Projects::GitHttpClientController.prepend_if_ee('EE::Projects::GitHttpClientController')
diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb
deleted file mode 100644
index 236f1b967de..00000000000
--- a/app/controllers/projects/git_http_controller.rb
+++ /dev/null
@@ -1,117 +0,0 @@
-# frozen_string_literal: true
-
-class Projects::GitHttpController < Projects::GitHttpClientController
- include WorkhorseRequest
-
- before_action :access_check
- prepend_before_action :deny_head_requests, only: [:info_refs]
-
- 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)
- def info_refs
- log_user_activity if upload_pack?
-
- render_ok
- end
-
- # POST /foo/bar.git/git-upload-pack (git pull)
- def git_upload_pack
- enqueue_fetch_statistics_update
-
- render_ok
- end
-
- # POST /foo/bar.git/git-receive-pack" (git push)
- def git_receive_pack
- render_ok
- end
-
- private
-
- def deny_head_requests
- head :forbidden if request.head?
- end
-
- def download_request?
- upload_pack?
- end
-
- def upload_pack?
- git_command == 'git-upload-pack'
- end
-
- def git_command
- if action_name == 'info_refs'
- params[:service]
- else
- action_name.dasherize
- end
- end
-
- def render_ok
- set_workhorse_internal_api_content_type
- render json: Gitlab::Workhorse.git_http_ok(repository, repo_type, user, action_name)
- end
-
- def render_403_with_exception(exception)
- render plain: exception.message, status: :forbidden
- end
-
- def render_404_with_exception(exception)
- render plain: exception.message, status: :not_found
- end
-
- def render_422_with_exception(exception)
- render plain: exception.message, status: :unprocessable_entity
- end
-
- def render_503_with_exception(exception)
- render plain: exception.message, status: :service_unavailable
- end
-
- def enqueue_fetch_statistics_update
- return if Gitlab::Database.read_only?
- return if repo_type.wiki?
- return unless project&.daily_statistics_enabled?
-
- ProjectDailyStatisticsWorker.perform_async(project.id)
- end
-
- def access
- @access ||= access_klass.new(access_actor, project, 'http',
- authentication_abilities: authentication_abilities,
- namespace_path: params[:namespace_id],
- project_path: project_path,
- redirected_path: redirected_path,
- auth_result_type: auth_result_type)
- end
-
- def access_actor
- return user if user
- return :ci if ci?
- end
-
- def access_check
- access.check(git_command, Gitlab::GitAccess::ANY)
- @project ||= access.project
- end
-
- def access_klass
- @access_klass ||= repo_type.access_checker_class
- end
-
- def project_path
- @project_path ||= params[:project_id].sub(/\.git$/, '')
- end
-
- def log_user_activity
- Users::ActivityService.new(user).execute
- end
-end
-
-Projects::GitHttpController.prepend_if_ee('EE::Projects::GitHttpController')
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 0944d7b47bf..b14a1179d46 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -44,7 +44,6 @@ class Projects::IssuesController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, project.group)
- push_frontend_feature_flag(:issue_link_types, project)
end
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
@@ -188,7 +187,7 @@ class Projects::IssuesController < Projects::ApplicationController
def import_csv
if uploader = UploadService.new(project, params[:file]).execute
- ImportIssuesCsvWorker.perform_async(current_user.id, project.id, uploader.upload.id)
+ ImportIssuesCsvWorker.perform_async(current_user.id, project.id, uploader.upload.id) # rubocop:disable CodeReuse/Worker
flash[:notice] = _("Your issues are being imported. Once finished, you'll get a confirmation email.")
else
diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb
deleted file mode 100644
index 1273c55b83a..00000000000
--- a/app/controllers/projects/lfs_api_controller.rb
+++ /dev/null
@@ -1,140 +0,0 @@
-# frozen_string_literal: true
-
-class Projects::LfsApiController < Projects::GitHttpClientController
- include LfsRequest
- include Gitlab::Utils::StrongMemoize
-
- LFS_TRANSFER_CONTENT_TYPE = 'application/octet-stream'
-
- skip_before_action :lfs_check_access!, only: [:deprecated]
- before_action :lfs_check_batch_operation!, only: [:batch]
-
- def batch
- unless objects.present?
- render_lfs_not_found
- return
- end
-
- if download_request?
- render json: { objects: download_objects! }
- elsif upload_request?
- render json: { objects: upload_objects! }
- else
- raise "Never reached"
- end
- end
-
- def deprecated
- render(
- json: {
- message: _('Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.'),
- documentation_url: "#{Gitlab.config.gitlab.url}/help"
- },
- status: :not_implemented
- )
- end
-
- private
-
- def download_request?
- params[:operation] == 'download'
- end
-
- def upload_request?
- params[:operation] == 'upload'
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def existing_oids
- @existing_oids ||= begin
- project.all_lfs_objects.where(oid: objects.map { |o| o['oid'].to_s }).pluck(:oid)
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def download_objects!
- objects.each do |object|
- if existing_oids.include?(object[:oid])
- object[:actions] = download_actions(object)
-
- if Guest.can?(:download_code, project)
- object[:authenticated] = true
- end
- else
- object[:error] = {
- code: 404,
- message: _("Object does not exist on the server or you don't have permissions to access it")
- }
- end
- end
- objects
- end
-
- def upload_objects!
- objects.each do |object|
- object[:actions] = upload_actions(object) unless existing_oids.include?(object[:oid])
- end
- objects
- end
-
- def download_actions(object)
- {
- download: {
- href: "#{project.http_url_to_repo}/gitlab-lfs/objects/#{object[:oid]}",
- header: {
- Authorization: authorization_header
- }.compact
- }
- }
- end
-
- def upload_actions(object)
- {
- upload: {
- href: "#{project.http_url_to_repo}/gitlab-lfs/objects/#{object[:oid]}/#{object[:size]}",
- header: {
- 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
- }.compact
- }
- }
- end
-
- def lfs_check_batch_operation!
- if batch_operation_disallowed?
- render(
- json: {
- message: lfs_read_only_message
- },
- content_type: LfsRequest::CONTENT_TYPE,
- status: :forbidden
- )
- end
- end
-
- # Overridden in EE
- def batch_operation_disallowed?
- upload_request? && Gitlab::Database.read_only?
- end
-
- # Overridden in EE
- 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/lfs_locks_api_controller.rb b/app/controllers/projects/lfs_locks_api_controller.rb
deleted file mode 100644
index 6aacb9d9a56..00000000000
--- a/app/controllers/projects/lfs_locks_api_controller.rb
+++ /dev/null
@@ -1,76 +0,0 @@
-# frozen_string_literal: true
-
-class Projects::LfsLocksApiController < Projects::GitHttpClientController
- include LfsRequest
-
- def create
- @result = Lfs::LockFileService.new(project, user, lfs_params).execute
-
- render_json(@result[:lock])
- end
-
- def unlock
- @result = Lfs::UnlockFileService.new(project, user, lfs_params).execute
-
- render_json(@result[:lock])
- end
-
- def index
- @result = Lfs::LocksFinderService.new(project, user, lfs_params).execute
-
- render_json(@result[:locks])
- end
-
- def verify
- @result = Lfs::LocksFinderService.new(project, user, {}).execute
-
- ours, theirs = split_by_owner(@result[:locks])
-
- render_json({ ours: ours, theirs: theirs }, false)
- end
-
- private
-
- def render_json(data, process = true)
- render json: build_payload(data, process),
- content_type: LfsRequest::CONTENT_TYPE,
- status: @result[:http_status]
- end
-
- def build_payload(data, process)
- data = LfsFileLockSerializer.new.represent(data) if process
-
- return data if @result[:status] == :success
-
- # When the locking failed due to an existent Lock, the existent record
- # is returned in `@result[:lock]`
- error_payload(@result[:message], @result[:lock] ? data : {})
- end
-
- def error_payload(message, custom_attrs = {})
- custom_attrs.merge({
- message: message,
- documentation_url: help_url
- })
- end
-
- def split_by_owner(locks)
- groups = locks.partition { |lock| lock.user_id == user.id }
-
- groups.map! do |records|
- LfsFileLockSerializer.new.represent(records, root: false)
- end
- end
-
- def download_request?
- params[:action] == 'index'
- end
-
- def upload_request?
- %w(create unlock verify).include?(params[:action])
- end
-
- def lfs_params
- params.permit(:id, :path, :force)
- end
-end
diff --git a/app/controllers/projects/lfs_storage_controller.rb b/app/controllers/projects/lfs_storage_controller.rb
deleted file mode 100644
index 013e01b82aa..00000000000
--- a/app/controllers/projects/lfs_storage_controller.rb
+++ /dev/null
@@ -1,89 +0,0 @@
-# frozen_string_literal: true
-
-class Projects::LfsStorageController < Projects::GitHttpClientController
- include LfsRequest
- include WorkhorseRequest
- include SendFileUpload
-
- skip_before_action :verify_workhorse_api!, only: :download
-
- def download
- lfs_object = LfsObject.find_by_oid(oid)
- unless lfs_object && lfs_object.file.exists?
- render_lfs_not_found
- return
- end
-
- send_upload(lfs_object.file, send_params: { content_type: "application/octet-stream" })
- end
-
- def upload_authorize
- set_workhorse_internal_api_content_type
-
- authorized = LfsObjectUploader.workhorse_authorize(has_length: true)
- authorized.merge!(LfsOid: oid, LfsSize: size)
-
- render json: authorized
- end
-
- def upload_finalize
- if store_file!(oid, size)
- head 200
- else
- render plain: 'Unprocessable entity', status: :unprocessable_entity
- end
- rescue ActiveRecord::RecordInvalid
- render_lfs_forbidden
- rescue UploadedFile::InvalidPathError
- render_lfs_forbidden
- rescue ObjectStorage::RemoteStoreError
- render_lfs_forbidden
- end
-
- private
-
- def download_request?
- action_name == 'download'
- end
-
- def upload_request?
- %w[upload_authorize upload_finalize].include? action_name
- end
-
- def oid
- params[:oid].to_s
- end
-
- def size
- params[:size].to_i
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def store_file!(oid, size)
- object = LfsObject.find_by(oid: oid, size: size)
- unless object&.file&.exists?
- object = create_file!(oid, size)
- end
-
- return unless object
-
- link_to_project!(object)
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def create_file!(oid, size)
- uploaded_file = UploadedFile.from_params(
- params, :file, LfsObjectUploader.workhorse_local_upload_path)
- return unless uploaded_file
-
- LfsObject.create!(oid: oid, size: size, file: uploaded_file)
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def link_to_project!(object)
- if object && !object.projects.exists?(storage_project.id)
- object.lfs_objects_projects.create!(project: storage_project)
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-end
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index c0c8474232a..953b2ffeb0b 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -18,7 +18,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
end
def diffs_batch
- return render_404 unless Feature.enabled?(:diffs_batch_load, @merge_request.project)
+ return render_404 unless Feature.enabled?(:diffs_batch_load, @merge_request.project, default_enabled: true)
diffs = @compare.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)
@@ -64,6 +64,10 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
options = additional_attributes.merge(diff_view: diff_view)
+ if @merge_request.project.context_commits_enabled?
+ options[:context_commits] = @merge_request.context_commits
+ end
+
render json: DiffsSerializer.new(request).represent(diffs, options)
end
@@ -107,6 +111,11 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
end
end
+ if Gitlab::Utils.to_boolean(params[:diff_head]) && @merge_request.diffable_merge_ref?
+ return CompareService.new(@project, @merge_request.merge_ref_head.sha)
+ .execute(@project, @merge_request.target_branch)
+ end
+
if @start_sha
@merge_request_diff.compare_with(@start_sha)
else
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 17025670488..c5f017efe8d 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -19,13 +19,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
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)
+ push_frontend_feature_flag(:diffs_batch_load, @project, default_enabled: true)
push_frontend_feature_flag(:single_mr_diff_view, @project)
+ push_frontend_feature_flag(:suggest_pipeline) if experiment_enabled?(:suggest_pipeline)
end
before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, @project.group)
- push_frontend_feature_flag(:async_mr_widget, @project)
end
around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions]
@@ -45,7 +45,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
def show
close_merge_request_if_no_source_project
- @merge_request.check_mergeability
+ @merge_request.check_mergeability(async: true)
respond_to do |format|
format.html do
@@ -117,6 +117,15 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
}
end
+ def context_commits
+ return render_404 unless project.context_commits_enabled?
+
+ # Get commits from repository
+ # or from cache if already merged
+ commits = ContextCommitsFinder.new(project, @merge_request, { search: params[:search], limit: params[:limit], offset: params[:offset] }).execute
+ render json: CommitEntity.represent(commits, { type: :full, request: merge_request })
+ end
+
def test_reports
reports_response(@merge_request.compare_test_reports)
end
diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb
index 6a7e2b69652..ead839e8441 100644
--- a/app/controllers/projects/pipeline_schedules_controller.rb
+++ b/app/controllers/projects/pipeline_schedules_controller.rb
@@ -47,7 +47,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
end
def play
- job_id = RunPipelineScheduleWorker.perform_async(schedule.id, current_user.id)
+ job_id = RunPipelineScheduleWorker.perform_async(schedule.id, current_user.id) # rubocop:disable CodeReuse/Worker
if job_id
pipelines_link_start = "<a href=\"#{project_pipelines_path(@project)}\">"
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index a62eb94a3e4..6d902e099d9 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -179,6 +179,16 @@ class Projects::PipelinesController < Projects::ApplicationController
end
end
+ def test_reports_count
+ return unless Feature.enabled?(:junit_pipeline_view, project)
+
+ begin
+ render json: { total_count: pipeline.test_reports_count }.to_json
+ rescue Gitlab::Ci::Parsers::ParserError
+ render json: { total_count: 0 }.to_json
+ end
+ end
+
private
def serialize_pipelines
diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb
index 7bd084458d1..109c8b7005f 100644
--- a/app/controllers/projects/project_members_controller.rb
+++ b/app/controllers/projects/project_members_controller.rb
@@ -8,27 +8,26 @@ class Projects::ProjectMembersController < Projects::ApplicationController
# Authorize
before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access]
- # rubocop: disable CodeReuse/ActiveRecord
def index
@sort = params[:sort].presence || sort_value_name
+
+ @skip_groups = @project.invited_group_ids
+ @skip_groups += @project.group.self_and_ancestors_ids if @project.group
+
@group_links = @project.project_group_links
+ @group_links = @group_links.search(params[:search]) if params[:search].present?
- @skip_groups = @group_links.pluck(:group_id)
- @skip_groups << @project.namespace_id unless @project.personal?
- @skip_groups += @project.group.ancestors.pluck(:id) if @project.group
+ @project_members = MembersFinder.new(@project, current_user)
+ .execute(include_relations: requested_relations, params: params.merge(sort: @sort))
- @project_members = MembersFinder.new(@project, current_user).execute(include_relations: requested_relations)
+ @project_members = present_members(@project_members.page(params[:page]))
- if params[:search].present?
- @project_members = @project_members.joins(:user).merge(User.search(params[:search]))
- @group_links = @group_links.where(group_id: @project.invited_groups.search(params[:search]).select(:id))
- end
+ @requesters = present_members(
+ AccessRequestsFinder.new(@project).execute(current_user)
+ )
- @project_members = present_members(@project_members.sort_by_attribute(@sort).page(params[:page]))
- @requesters = present_members(AccessRequestsFinder.new(@project).execute(current_user))
@project_member = @project.project_members.new
end
- # rubocop: enable CodeReuse/ActiveRecord
def import
@projects = current_user.authorized_projects.order_id_desc
diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb
index 9405fd526ae..e524d1c29a2 100644
--- a/app/controllers/projects/registry/repositories_controller.rb
+++ b/app/controllers/projects/registry/repositories_controller.rb
@@ -7,21 +7,32 @@ module Projects
before_action :ensure_root_container_repository!, only: [:index]
def index
- @images = project.container_repositories
- track_event(:list_repositories)
-
respond_to do |format|
format.html
format.json do
- render json: ContainerRepositoriesSerializer
+ @images = project.container_repositories
+
+ track_event(:list_repositories)
+
+ serializer = ContainerRepositoriesSerializer
.new(project: project, current_user: current_user)
- .represent(@images)
+
+ if Feature.enabled?(:vue_container_registry_explorer)
+ render json: serializer.with_pagination(request, response).represent(@images)
+ else
+ render json: serializer.represent(@images)
+ end
end
end
end
+ # The show action renders index to allow frontend routing to work on page refresh
+ def show
+ render :index
+ end
+
def destroy
- DeleteContainerRepositoryWorker.perform_async(current_user.id, image.id)
+ DeleteContainerRepositoryWorker.perform_async(current_user.id, image.id) # rubocop:disable CodeReuse/Worker
track_event(:delete_repository)
respond_to do |format|
diff --git a/app/controllers/projects/registry/tags_controller.rb b/app/controllers/projects/registry/tags_controller.rb
index e572c56adf5..c42e3f6bdba 100644
--- a/app/controllers/projects/registry/tags_controller.rb
+++ b/app/controllers/projects/registry/tags_controller.rb
@@ -31,12 +31,7 @@ module Projects
end
def bulk_destroy
- unless params[:ids].present?
- head :bad_request
- return
- end
-
- tag_names = params[:ids] || []
+ tag_names = params.require(:ids) || []
if tag_names.size > LIMIT
head :bad_request
return
diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb
index 08a57a9b146..7ad841d645d 100644
--- a/app/controllers/projects/releases_controller.rb
+++ b/app/controllers/projects/releases_controller.rb
@@ -3,11 +3,12 @@
class Projects::ReleasesController < Projects::ApplicationController
# Authorize
before_action :require_non_empty_project, except: [:index]
- before_action :release, only: %i[edit update]
+ before_action :release, only: %i[edit show update]
before_action :authorize_read_release!
before_action do
push_frontend_feature_flag(:release_issue_summary, project)
push_frontend_feature_flag(:release_evidence_collection, project, default_enabled: true)
+ push_frontend_feature_flag(:release_show_page, project)
end
before_action :authorize_update_release!, only: %i[edit update]
before_action :authorize_read_release_evidence!, only: [:evidence]
@@ -29,6 +30,16 @@ class Projects::ReleasesController < Projects::ApplicationController
end
end
+ def show
+ return render_404 unless Feature.enabled?(:release_show_page, project)
+
+ respond_to do |format|
+ format.html do
+ render :show
+ end
+ end
+ end
+
protected
def releases
@@ -37,7 +48,9 @@ class Projects::ReleasesController < Projects::ApplicationController
def edit
respond_to do |format|
- format.html { render 'edit' }
+ format.html do
+ render :edit
+ end
end
end
diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb
index 2ed29b937ad..d0fb814948f 100644
--- a/app/controllers/projects/repositories_controller.rb
+++ b/app/controllers/projects/repositories_controller.rb
@@ -21,6 +21,8 @@ class Projects::RepositoriesController < Projects::ApplicationController
end
def archive
+ return render_404 if html_request?
+
set_cache_headers
return if archive_not_modified?
@@ -81,7 +83,7 @@ class Projects::RepositoriesController < Projects::ApplicationController
def assign_archive_vars
if params[:id]
- @ref, @filename = extract_ref(params[:id])
+ @ref, @filename = extract_ref_and_filename(params[:id])
else
@ref = params[:ref]
@filename = nil
@@ -89,6 +91,26 @@ class Projects::RepositoriesController < Projects::ApplicationController
rescue InvalidPathError
render_404
end
+
+ # path can be of the form:
+ # master
+ # master/first.zip
+ # master/first/second.tar.gz
+ # master/first/second/third.zip
+ #
+ # In the archive case, we know that the last value is always the filename, so we
+ # do a greedy match to extract the ref. This avoid having to pull all ref names
+ # from Redis.
+ def extract_ref_and_filename(id)
+ path = id.strip
+ data = path.match(/(.*)\/(.*)/)
+
+ if data
+ [data[1], data[2]]
+ else
+ [path, nil]
+ end
+ end
end
Projects::RepositoriesController.prepend_if_ee('EE::Projects::RepositoriesController')
diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb
index 4b0d001fca6..0b55414d390 100644
--- a/app/controllers/projects/serverless/functions_controller.rb
+++ b/app/controllers/projects/serverless/functions_controller.rb
@@ -8,11 +8,15 @@ module Projects
def index
respond_to do |format|
format.json do
- functions = finder.execute
+ functions = finder.execute.select do |function|
+ can?(@current_user, :read_cluster, function.cluster)
+ end
+
+ serialized_functions = serialize_function(functions)
render json: {
knative_installed: finder.knative_installed,
- functions: serialize_function(functions)
+ functions: serialized_functions
}.to_json
end
@@ -23,11 +27,14 @@ module Projects
end
def show
- @service = serialize_function(finder.service(params[:environment_id], params[:id]))
- @prometheus = finder.has_prometheus?(params[:environment_id])
+ function = finder.service(params[:environment_id], params[:id])
+ return not_found unless function && can?(@current_user, :read_cluster, function.cluster)
+ @service = serialize_function(function)
return not_found if @service.nil?
+ @prometheus = finder.has_prometheus?(params[:environment_id])
+
respond_to do |format|
format.json do
render json: @service
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index daaca9e1268..c916140211e 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -8,6 +8,8 @@ class Projects::ServicesController < Projects::ApplicationController
before_action :ensure_service_enabled
before_action :service
before_action :web_hook_logs, only: [:edit, :update]
+ before_action :set_deprecation_notice_for_prometheus_service, only: [:edit, :update]
+ before_action :redirect_deprecated_prometheus_service, only: [:update]
respond_to :html
@@ -93,4 +95,16 @@ class Projects::ServicesController < Projects::ApplicationController
.as_json(only: @service.json_fields)
.merge(errors: @service.errors.as_json)
end
+
+ def redirect_deprecated_prometheus_service
+ redirect_to edit_project_service_path(project, @service) if @service.is_a?(::PrometheusService) && Feature.enabled?(:settings_operations_prometheus_service, project)
+ end
+
+ def set_deprecation_notice_for_prometheus_service
+ return if !@service.is_a?(::PrometheusService) || !Feature.enabled?(:settings_operations_prometheus_service, project)
+
+ operations_link_start = "<a href=\"#{project_settings_operations_path(project)}\">"
+ message = s_('PrometheusService|You can now manage your Prometheus settings on the %{operations_link_start}Operations%{operations_link_end} page. Fields on this page has been deprecated.') % { operations_link_start: operations_link_start, operations_link_end: "</a>" }
+ flash.now[:alert] = message.html_safe
+ end
end
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index 6af815b8daa..bf0c2d885f8 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -69,7 +69,9 @@ module Projects
return
end
+ # rubocop:disable CodeReuse/Worker
CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false)
+ # rubocop:enable CodeReuse/Worker
pipelines_link_start = '<a href="%{url}">'.html_safe % { url: project_pipelines_path(@project) }
flash[:toast] = _("A new Auto DevOps pipeline has been created, go to %{pipelines_link_start}Pipelines page%{pipelines_link_end} for details") % { pipelines_link_start: pipelines_link_start, pipelines_link_end: "</a>".html_safe }
diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb
index 1571cb8cd34..12b4f9ac56c 100644
--- a/app/controllers/projects/settings/operations_controller.rb
+++ b/app/controllers/projects/settings/operations_controller.rb
@@ -19,19 +19,36 @@ module Projects
# overridden in EE
def track_events(result)
+ if result[:status] == :success
+ ::Gitlab::Tracking::IncidentManagement.track_from_params(
+ update_params[:incident_management_setting_attributes]
+ )
+ end
end
private
- # overridden in EE
def render_update_response(result)
respond_to do |format|
+ format.html do
+ render_update_html_response(result)
+ end
+
format.json do
render_update_json_response(result)
end
end
end
+ def render_update_html_response(result)
+ if result[:status] == :success
+ flash[:notice] = _('Your changes have been saved')
+ redirect_to project_settings_operations_path(@project)
+ else
+ render 'show'
+ end
+ end
+
def render_update_json_response(result)
if result[:status] == :success
flash[:notice] = _('Your changes have been saved')
@@ -60,7 +77,9 @@ module Projects
# overridden in EE
def permitted_project_params
- {
+ project_params = {
+ incident_management_setting_attributes: ::Gitlab::Tracking::IncidentManagement.tracking_keys.keys,
+
metrics_setting_attributes: [:external_dashboard_url],
error_tracking_setting_attributes: [
@@ -72,6 +91,12 @@ module Projects
grafana_integration_attributes: [:token, :grafana_url, :enabled]
}
+
+ if Feature.enabled?(:settings_operations_prometheus_service, project)
+ project_params[:prometheus_integration_attributes] = [:manual_configuration, :api_url]
+ end
+
+ project_params
end
end
end
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index 0c634bbea03..63f5d5073a7 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -25,7 +25,7 @@ module Projects
result = Projects::UpdateService.new(project, current_user, cleanup_params).execute
if result[:status] == :success
- RepositoryCleanupWorker.perform_async(project.id, current_user.id)
+ RepositoryCleanupWorker.perform_async(project.id, current_user.id) # rubocop:disable CodeReuse/Worker
flash[:notice] = _('Repository cleanup has started. You will receive an email once the cleanup operation is complete.')
else
flash[:alert] = _('Failed to upload object map file')
diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb
index daddd9dd485..b9c7468890b 100644
--- a/app/controllers/projects/snippets_controller.rb
+++ b/app/controllers/projects/snippets_controller.rb
@@ -15,21 +15,25 @@ class Projects::SnippetsController < Projects::ApplicationController
before_action :check_snippets_available!
before_action :snippet, only: [:show, :edit, :destroy, :update, :raw, :toggle_award_emoji, :mark_as_spam]
- # Allow read any snippet
- before_action :authorize_read_project_snippet!, except: [:new, :create, :index]
+ # Allow create snippet
+ before_action :authorize_create_snippet!, only: [:new, :create]
- # Allow write(create) snippet
- before_action :authorize_create_project_snippet!, only: [:new, :create]
+ # Allow read any snippet
+ before_action :authorize_read_snippet!, except: [:new, :create, :index]
# Allow modify snippet
- before_action :authorize_update_project_snippet!, only: [:edit, :update]
+ before_action :authorize_update_snippet!, only: [:edit, :update]
# Allow destroy snippet
- before_action :authorize_admin_project_snippet!, only: [:destroy]
+ before_action :authorize_admin_snippet!, only: [:destroy]
respond_to :html
def index
+ @snippet_counts = Snippets::CountService
+ .new(current_user, project: @project)
+ .execute
+
@snippets = SnippetsFinder.new(current_user, project: @project, scope: params[:scope])
.execute
.page(params[:page])
@@ -115,16 +119,16 @@ class Projects::SnippetsController < Projects::ApplicationController
project_snippet_path(@project, @snippet)
end
- def authorize_read_project_snippet!
- return render_404 unless can?(current_user, :read_project_snippet, @snippet)
+ def authorize_read_snippet!
+ return render_404 unless can?(current_user, :read_snippet, @snippet)
end
- def authorize_update_project_snippet!
- return render_404 unless can?(current_user, :update_project_snippet, @snippet)
+ def authorize_update_snippet!
+ return render_404 unless can?(current_user, :update_snippet, @snippet)
end
- def authorize_admin_project_snippet!
- return render_404 unless can?(current_user, :admin_project_snippet, @snippet)
+ def authorize_admin_snippet!
+ return render_404 unless can?(current_user, :admin_snippet, @snippet)
end
def snippet_params
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index aba28e5c835..b8fe2a47b30 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -32,7 +32,7 @@ class Projects::TreeController < Projects::ApplicationController
respond_to do |format|
format.html do
- lfs_blob_ids if Feature.disabled?(:vue_file_list, @project)
+ lfs_blob_ids if Feature.disabled?(:vue_file_list, @project, default_enabled: true)
@last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index d39a4c373ff..31b86946ca2 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -296,7 +296,7 @@ class ProjectsController < Projects::ApplicationController
private
def show_blob_ids?
- repo_exists? && project_view_files? && Feature.disabled?(:vue_file_list, @project)
+ repo_exists? && project_view_files? && Feature.disabled?(:vue_file_list, @project, default_enabled: true)
end
# Render project landing depending of which features are available
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index c0ba87bf3ed..1c6cbf72cfa 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -13,6 +13,7 @@ class RegistrationsController < Devise::RegistrationsController
before_action :whitelist_query_limiting, only: [:destroy]
before_action :ensure_terms_accepted,
if: -> { action_name == 'create' && Gitlab::CurrentSettings.current_application_settings.enforce_terms? }
+ before_action :load_recaptcha, only: :new
def new
if experiment_enabled?(:signup_flow)
@@ -35,7 +36,7 @@ class RegistrationsController < Devise::RegistrationsController
end
# Do not show the signed_up notice message when the signup_flow experiment is enabled.
- # Instead, show it after succesfully updating the role.
+ # Instead, show it after successfully updating the role.
flash[:notice] = nil if experiment_enabled?(:signup_flow)
rescue Gitlab::Access::AccessDeniedError
redirect_to(new_user_session_path)
@@ -53,10 +54,7 @@ class RegistrationsController < Devise::RegistrationsController
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.setup_for_company.nil?
-
- current_user.name = nil if current_user.name == current_user.username
- render layout: 'devise_experimental_separate_sign_up_flow'
+ return redirect_to stored_location_or_dashboard(current_user) if current_user.role.present? && !current_user.setup_for_company.nil?
end
def update_registration
@@ -66,9 +64,9 @@ class RegistrationsController < Devise::RegistrationsController
if result[:status] == :success
track_experiment_event(:signup_flow, 'end') # We want this event to be tracked when the user is _in_ the experimental group
set_flash_message! :notice, :signed_up
- redirect_to stored_location_or_dashboard_or_almost_there_path(current_user)
+ redirect_to stored_location_or_dashboard(current_user)
else
- render :welcome, layout: 'devise_experimental_separate_sign_up_flow'
+ render :welcome
end
end
@@ -113,12 +111,14 @@ class RegistrationsController < Devise::RegistrationsController
return users_sign_up_welcome_path if experiment_enabled?(:signup_flow)
- stored_location_or_dashboard_or_almost_there_path(user)
+ stored_location_or_dashboard(user)
end
def after_inactive_sign_up_path_for(resource)
+ # With the current `allow_unconfirmed_access_for` Devise setting in config/initializers/8_devise.rb,
+ # this method is never called. Leaving this here in case that value is set to 0.
Gitlab::AppLogger.info(user_created_message)
- Feature.enabled?(:soft_email_confirmation) ? dashboard_projects_path : users_almost_there_path
+ users_almost_there_path
end
private
@@ -140,7 +140,6 @@ 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!
@@ -181,16 +180,12 @@ class RegistrationsController < Devise::RegistrationsController
Gitlab::Utils.to_boolean(params[:terms_opt_in])
end
- def confirmed_or_unconfirmed_access_allowed(user)
- 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
+ def load_recaptcha
+ Gitlab::Recaptcha.load_configurations!
end
# Part of an experiment to build a new sign up flow. Will be resolved
diff --git a/app/controllers/repositories/application_controller.rb b/app/controllers/repositories/application_controller.rb
new file mode 100644
index 00000000000..528cc310038
--- /dev/null
+++ b/app/controllers/repositories/application_controller.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Repositories
+ class ApplicationController < ::ApplicationController
+ skip_before_action :authenticate_user!
+ end
+end
diff --git a/app/controllers/repositories/git_http_client_controller.rb b/app/controllers/repositories/git_http_client_controller.rb
new file mode 100644
index 00000000000..76eb7c67205
--- /dev/null
+++ b/app/controllers/repositories/git_http_client_controller.rb
@@ -0,0 +1,125 @@
+# frozen_string_literal: true
+
+module Repositories
+ class GitHttpClientController < Repositories::ApplicationController
+ include ActionController::HttpAuthentication::Basic
+ include KerberosSpnegoHelper
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :authentication_result, :redirected_path
+
+ delegate :actor, :authentication_abilities, to: :authentication_result, allow_nil: true
+ delegate :type, to: :authentication_result, allow_nil: true, prefix: :auth_result
+
+ alias_method :user, :actor
+ alias_method :authenticated_user, :actor
+
+ # Git clients will not know what authenticity token to send along
+ skip_around_action :set_session_storage
+ skip_before_action :verify_authenticity_token
+
+ before_action :parse_repo_path
+ before_action :authenticate_user
+
+ private
+
+ def download_request?
+ raise NotImplementedError
+ end
+
+ def upload_request?
+ raise NotImplementedError
+ end
+
+ def authenticate_user
+ @authentication_result = Gitlab::Auth::Result.new
+
+ if allow_basic_auth? && basic_auth_provided?
+ login, password = user_name_and_password(request)
+
+ if handle_basic_authentication(login, password)
+ return # Allow access
+ end
+ elsif allow_kerberos_spnego_auth? && spnego_provided?
+ kerberos_user = find_kerberos_user
+
+ if kerberos_user
+ @authentication_result = Gitlab::Auth::Result.new(
+ kerberos_user, nil, :kerberos, Gitlab::Auth.full_authentication_abilities)
+
+ send_final_spnego_response
+ return # Allow access
+ end
+ elsif http_download_allowed?
+
+ @authentication_result = Gitlab::Auth::Result.new(nil, project, :none, [:download_code])
+
+ return # Allow access
+ end
+
+ send_challenges
+ render plain: "HTTP Basic: Access denied\n", status: :unauthorized
+ rescue Gitlab::Auth::MissingPersonalAccessTokenError
+ render_missing_personal_access_token
+ end
+
+ def basic_auth_provided?
+ has_basic_credentials?(request)
+ end
+
+ def send_challenges
+ challenges = []
+ challenges << 'Basic realm="GitLab"' if allow_basic_auth?
+ challenges << spnego_challenge if allow_kerberos_spnego_auth?
+ headers['Www-Authenticate'] = challenges.join("\n") if challenges.any?
+ end
+
+ def project
+ parse_repo_path unless defined?(@project)
+
+ @project
+ end
+
+ def parse_repo_path
+ @project, @repo_type, @redirected_path = Gitlab::RepoPath.parse("#{params[:namespace_id]}/#{params[:repository_id]}")
+ end
+
+ def render_missing_personal_access_token
+ render plain: "HTTP Basic: Access denied\n" \
+ "You must use a personal access token with 'read_repository' or 'write_repository' scope for Git over HTTP.\n" \
+ "You can generate one at #{profile_personal_access_tokens_url}",
+ status: :unauthorized
+ end
+
+ def repository
+ strong_memoize(:repository) do
+ repo_type.repository_for(project)
+ end
+ end
+
+ def repo_type
+ parse_repo_path unless defined?(@repo_type)
+
+ @repo_type
+ end
+
+ def handle_basic_authentication(login, password)
+ @authentication_result = Gitlab::Auth.find_for_git_client(
+ login, password, project: project, ip: request.ip)
+
+ @authentication_result.success?
+ end
+
+ def ci?
+ authentication_result.ci?(project)
+ end
+
+ def http_download_allowed?
+ Gitlab::ProtocolAccess.allowed?('http') &&
+ download_request? &&
+ project && Guest.can?(:download_code, project)
+ end
+ end
+end
+
+Repositories::GitHttpClientController.prepend_if_ee('EE::Repositories::GitHttpClientController')
diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb
new file mode 100644
index 00000000000..75c79881264
--- /dev/null
+++ b/app/controllers/repositories/git_http_controller.rb
@@ -0,0 +1,119 @@
+# frozen_string_literal: true
+
+module Repositories
+ class GitHttpController < Repositories::GitHttpClientController
+ include WorkhorseRequest
+
+ before_action :access_check
+ prepend_before_action :deny_head_requests, only: [:info_refs]
+
+ 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)
+ def info_refs
+ log_user_activity if upload_pack?
+
+ render_ok
+ end
+
+ # POST /foo/bar.git/git-upload-pack (git pull)
+ def git_upload_pack
+ enqueue_fetch_statistics_update
+
+ render_ok
+ end
+
+ # POST /foo/bar.git/git-receive-pack" (git push)
+ def git_receive_pack
+ render_ok
+ end
+
+ private
+
+ def deny_head_requests
+ head :forbidden if request.head?
+ end
+
+ def download_request?
+ upload_pack?
+ end
+
+ def upload_pack?
+ git_command == 'git-upload-pack'
+ end
+
+ def git_command
+ if action_name == 'info_refs'
+ params[:service]
+ else
+ action_name.dasherize
+ end
+ end
+
+ def render_ok
+ set_workhorse_internal_api_content_type
+ render json: Gitlab::Workhorse.git_http_ok(repository, repo_type, user, action_name)
+ end
+
+ def render_403_with_exception(exception)
+ render plain: exception.message, status: :forbidden
+ end
+
+ def render_404_with_exception(exception)
+ render plain: exception.message, status: :not_found
+ end
+
+ def render_422_with_exception(exception)
+ render plain: exception.message, status: :unprocessable_entity
+ end
+
+ def render_503_with_exception(exception)
+ render plain: exception.message, status: :service_unavailable
+ end
+
+ def enqueue_fetch_statistics_update
+ return if Gitlab::Database.read_only?
+ return unless repo_type.project?
+ return unless project&.daily_statistics_enabled?
+
+ ProjectDailyStatisticsWorker.perform_async(project.id) # rubocop:disable CodeReuse/Worker
+ end
+
+ def access
+ @access ||= access_klass.new(access_actor, project, 'http',
+ authentication_abilities: authentication_abilities,
+ namespace_path: params[:namespace_id],
+ project_path: project_path,
+ redirected_path: redirected_path,
+ auth_result_type: auth_result_type)
+ end
+
+ def access_actor
+ return user if user
+ return :ci if ci?
+ end
+
+ def access_check
+ access.check(git_command, Gitlab::GitAccess::ANY)
+ @project ||= access.project
+ end
+
+ def access_klass
+ @access_klass ||= repo_type.access_checker_class
+ end
+
+ def project_path
+ @project_path ||= params[:repository_id].sub(/\.git$/, '')
+ end
+
+ def log_user_activity
+ Users::ActivityService.new(user).execute
+ end
+ end
+end
+
+Repositories::GitHttpController.prepend_if_ee('EE::Repositories::GitHttpController')
diff --git a/app/controllers/repositories/lfs_api_controller.rb b/app/controllers/repositories/lfs_api_controller.rb
new file mode 100644
index 00000000000..b1e0d1848d7
--- /dev/null
+++ b/app/controllers/repositories/lfs_api_controller.rb
@@ -0,0 +1,142 @@
+# frozen_string_literal: true
+
+module Repositories
+ class LfsApiController < Repositories::GitHttpClientController
+ include LfsRequest
+ include Gitlab::Utils::StrongMemoize
+
+ LFS_TRANSFER_CONTENT_TYPE = 'application/octet-stream'
+
+ skip_before_action :lfs_check_access!, only: [:deprecated]
+ before_action :lfs_check_batch_operation!, only: [:batch]
+
+ def batch
+ unless objects.present?
+ render_lfs_not_found
+ return
+ end
+
+ if download_request?
+ render json: { objects: download_objects! }
+ elsif upload_request?
+ render json: { objects: upload_objects! }
+ else
+ raise "Never reached"
+ end
+ end
+
+ def deprecated
+ render(
+ json: {
+ message: _('Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.'),
+ documentation_url: "#{Gitlab.config.gitlab.url}/help"
+ },
+ status: :not_implemented
+ )
+ end
+
+ private
+
+ def download_request?
+ params[:operation] == 'download'
+ end
+
+ def upload_request?
+ params[:operation] == 'upload'
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def existing_oids
+ @existing_oids ||= begin
+ project.all_lfs_objects.where(oid: objects.map { |o| o['oid'].to_s }).pluck(:oid)
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def download_objects!
+ objects.each do |object|
+ if existing_oids.include?(object[:oid])
+ object[:actions] = download_actions(object)
+
+ if Guest.can?(:download_code, project)
+ object[:authenticated] = true
+ end
+ else
+ object[:error] = {
+ code: 404,
+ message: _("Object does not exist on the server or you don't have permissions to access it")
+ }
+ end
+ end
+ objects
+ end
+
+ def upload_objects!
+ objects.each do |object|
+ object[:actions] = upload_actions(object) unless existing_oids.include?(object[:oid])
+ end
+ objects
+ end
+
+ def download_actions(object)
+ {
+ download: {
+ href: "#{project.http_url_to_repo}/gitlab-lfs/objects/#{object[:oid]}",
+ header: {
+ Authorization: authorization_header
+ }.compact
+ }
+ }
+ end
+
+ def upload_actions(object)
+ {
+ upload: {
+ href: "#{project.http_url_to_repo}/gitlab-lfs/objects/#{object[:oid]}/#{object[:size]}",
+ header: {
+ 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
+ }.compact
+ }
+ }
+ end
+
+ def lfs_check_batch_operation!
+ if batch_operation_disallowed?
+ render(
+ json: {
+ message: lfs_read_only_message
+ },
+ content_type: LfsRequest::CONTENT_TYPE,
+ status: :forbidden
+ )
+ end
+ end
+
+ # Overridden in EE
+ def batch_operation_disallowed?
+ upload_request? && Gitlab::Database.read_only?
+ end
+
+ # Overridden in EE
+ 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
+end
+
+Repositories::LfsApiController.prepend_if_ee('EE::Repositories::LfsApiController')
diff --git a/app/controllers/repositories/lfs_locks_api_controller.rb b/app/controllers/repositories/lfs_locks_api_controller.rb
new file mode 100644
index 00000000000..19fc09ad4de
--- /dev/null
+++ b/app/controllers/repositories/lfs_locks_api_controller.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+module Repositories
+ class LfsLocksApiController < Repositories::GitHttpClientController
+ include LfsRequest
+
+ def create
+ @result = Lfs::LockFileService.new(project, user, lfs_params).execute
+
+ render_json(@result[:lock])
+ end
+
+ def unlock
+ @result = Lfs::UnlockFileService.new(project, user, lfs_params).execute
+
+ render_json(@result[:lock])
+ end
+
+ def index
+ @result = Lfs::LocksFinderService.new(project, user, lfs_params).execute
+
+ render_json(@result[:locks])
+ end
+
+ def verify
+ @result = Lfs::LocksFinderService.new(project, user, {}).execute
+
+ ours, theirs = split_by_owner(@result[:locks])
+
+ render_json({ ours: ours, theirs: theirs }, false)
+ end
+
+ private
+
+ def render_json(data, process = true)
+ render json: build_payload(data, process),
+ content_type: LfsRequest::CONTENT_TYPE,
+ status: @result[:http_status]
+ end
+
+ def build_payload(data, process)
+ data = LfsFileLockSerializer.new.represent(data) if process
+
+ return data if @result[:status] == :success
+
+ # When the locking failed due to an existent Lock, the existent record
+ # is returned in `@result[:lock]`
+ error_payload(@result[:message], @result[:lock] ? data : {})
+ end
+
+ def error_payload(message, custom_attrs = {})
+ custom_attrs.merge({
+ message: message,
+ documentation_url: help_url
+ })
+ end
+
+ def split_by_owner(locks)
+ groups = locks.partition { |lock| lock.user_id == user.id }
+
+ groups.map! do |records|
+ LfsFileLockSerializer.new.represent(records, root: false)
+ end
+ end
+
+ def download_request?
+ params[:action] == 'index'
+ end
+
+ def upload_request?
+ %w(create unlock verify).include?(params[:action])
+ end
+
+ def lfs_params
+ params.permit(:id, :path, :force)
+ end
+ end
+end
diff --git a/app/controllers/repositories/lfs_storage_controller.rb b/app/controllers/repositories/lfs_storage_controller.rb
new file mode 100644
index 00000000000..ec5ca5bbeec
--- /dev/null
+++ b/app/controllers/repositories/lfs_storage_controller.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+module Repositories
+ class LfsStorageController < Repositories::GitHttpClientController
+ include LfsRequest
+ include WorkhorseRequest
+ include SendFileUpload
+
+ skip_before_action :verify_workhorse_api!, only: :download
+
+ def download
+ lfs_object = LfsObject.find_by_oid(oid)
+ unless lfs_object && lfs_object.file.exists?
+ render_lfs_not_found
+ return
+ end
+
+ send_upload(lfs_object.file, send_params: { content_type: "application/octet-stream" })
+ end
+
+ def upload_authorize
+ set_workhorse_internal_api_content_type
+
+ authorized = LfsObjectUploader.workhorse_authorize(has_length: true)
+ authorized.merge!(LfsOid: oid, LfsSize: size)
+
+ render json: authorized
+ end
+
+ def upload_finalize
+ if store_file!(oid, size)
+ head 200
+ else
+ render plain: 'Unprocessable entity', status: :unprocessable_entity
+ end
+ rescue ActiveRecord::RecordInvalid
+ render_lfs_forbidden
+ rescue UploadedFile::InvalidPathError
+ render_lfs_forbidden
+ rescue ObjectStorage::RemoteStoreError
+ render_lfs_forbidden
+ end
+
+ private
+
+ def download_request?
+ action_name == 'download'
+ end
+
+ def upload_request?
+ %w[upload_authorize upload_finalize].include? action_name
+ end
+
+ def oid
+ params[:oid].to_s
+ end
+
+ def size
+ params[:size].to_i
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def store_file!(oid, size)
+ object = LfsObject.find_by(oid: oid, size: size)
+ unless object&.file&.exists?
+ object = create_file!(oid, size)
+ end
+
+ return unless object
+
+ link_to_project!(object)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def create_file!(oid, size)
+ uploaded_file = UploadedFile.from_params(
+ params, :file, LfsObjectUploader.workhorse_local_upload_path)
+ return unless uploaded_file
+
+ LfsObject.create!(oid: oid, size: size, file: uploaded_file)
+ end
+
+ def link_to_project!(object)
+ return unless object
+
+ LfsObjectsProject.safe_find_or_create_by!(
+ project: project,
+ lfs_object: object
+ )
+ end
+ end
+end
diff --git a/app/controllers/root_controller.rb b/app/controllers/root_controller.rb
index 5b06f4f4b51..24452f9a188 100644
--- a/app/controllers/root_controller.rb
+++ b/app/controllers/root_controller.rb
@@ -52,7 +52,7 @@ class RootController < Dashboard::ProjectsController
end
def redirect_to_home_page_url?
- # If user is not signed-in and tries to access root_path - redirect him to landing page
+ # If user is not signed-in and tries to access root_path - redirect them to landing page
# Don't redirect to the default URL to prevent endless redirections
return false unless Gitlab::CurrentSettings.home_page_url.present?
diff --git a/app/controllers/sent_notifications_controller.rb b/app/controllers/sent_notifications_controller.rb
index 893f5145e99..20134de81a0 100644
--- a/app/controllers/sent_notifications_controller.rb
+++ b/app/controllers/sent_notifications_controller.rb
@@ -6,14 +6,24 @@ class SentNotificationsController < ApplicationController
def unsubscribe
@sent_notification = SentNotification.for(params[:id])
- return render_404 unless @sent_notification && @sent_notification.unsubscribable?
+ return render_404 unless unsubscribe_prerequisites_met?
+
return unsubscribe_and_redirect if current_user || params[:force]
end
private
+ def unsubscribe_prerequisites_met?
+ @sent_notification.present? &&
+ @sent_notification.unsubscribable? &&
+ noteable.present?
+ end
+
+ def noteable
+ @sent_notification.noteable
+ end
+
def unsubscribe_and_redirect
- noteable = @sent_notification.noteable
noteable.unsubscribe(@sent_notification.recipient, @sent_notification.project)
flash[:notice] = _("You have been unsubscribed from this thread.")
diff --git a/app/controllers/snippets/notes_controller.rb b/app/controllers/snippets/notes_controller.rb
index 551b37cb3d3..a7e8ef0798b 100644
--- a/app/controllers/snippets/notes_controller.rb
+++ b/app/controllers/snippets/notes_controller.rb
@@ -33,7 +33,7 @@ class Snippets::NotesController < ApplicationController
end
def authorize_read_snippet!
- return render_404 unless can?(current_user, :read_personal_snippet, snippet)
+ return render_404 unless can?(current_user, :read_snippet, snippet)
end
def authorize_create_note!
diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb
index fc073e47368..b6ad5fd02b0 100644
--- a/app/controllers/snippets_controller.rb
+++ b/app/controllers/snippets_controller.rb
@@ -126,7 +126,7 @@ class SnippetsController < ApplicationController
end
def authorize_read_snippet!
- return if can?(current_user, :read_personal_snippet, @snippet)
+ return if can?(current_user, :read_snippet, @snippet)
if current_user
render_404
@@ -136,15 +136,15 @@ class SnippetsController < ApplicationController
end
def authorize_update_snippet!
- return render_404 unless can?(current_user, :update_personal_snippet, @snippet)
+ return render_404 unless can?(current_user, :update_snippet, @snippet)
end
def authorize_admin_snippet!
- return render_404 unless can?(current_user, :admin_personal_snippet, @snippet)
+ return render_404 unless can?(current_user, :admin_snippet, @snippet)
end
def authorize_create_snippet!
- return render_404 unless can?(current_user, :create_personal_snippet)
+ return render_404 unless can?(current_user, :create_snippet)
end
def snippet_params
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index 67d33648470..0b092d2622b 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -41,6 +41,8 @@ class UploadsController < ApplicationController
case model
when Note
can?(current_user, :read_project, model.project)
+ when Snippet, ProjectSnippet
+ can?(current_user, :read_snippet, model)
when User
# We validate the current user has enough (writing)
# access to itself when a secret is given.
diff --git a/app/controllers/user_callouts_controller.rb b/app/controllers/user_callouts_controller.rb
index ebf1dd8ca02..4ee75218db1 100644
--- a/app/controllers/user_callouts_controller.rb
+++ b/app/controllers/user_callouts_controller.rb
@@ -2,7 +2,10 @@
class UserCalloutsController < ApplicationController
def create
- if ensure_callout.persisted?
+ callout = ensure_callout
+
+ if callout.persisted?
+ callout.update(dismissed_at: Time.now)
respond_to do |format|
format.json { head :ok }
end
diff --git a/app/finders/concerns/finder_with_cross_project_access.rb b/app/finders/concerns/finder_with_cross_project_access.rb
index 06ebb286086..a55fb58a1bc 100644
--- a/app/finders/concerns/finder_with_cross_project_access.rb
+++ b/app/finders/concerns/finder_with_cross_project_access.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# Module to prepend into finders to specify wether or not the finder requires
+# Module to prepend into finders to specify whether or not the finder requires
# cross project access
#
# This module depends on the finder implementing the following methods:
diff --git a/app/finders/concerns/time_frame_filter.rb b/app/finders/concerns/time_frame_filter.rb
new file mode 100644
index 00000000000..e0baba25b64
--- /dev/null
+++ b/app/finders/concerns/time_frame_filter.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module TimeFrameFilter
+ def by_timeframe(items)
+ return items unless params[:start_date] && params[:start_date]
+
+ start_date = params[:start_date].to_date
+ end_date = params[:end_date].to_date
+
+ items.within_timeframe(start_date, end_date)
+ rescue ArgumentError
+ items
+ end
+end
diff --git a/app/finders/context_commits_finder.rb b/app/finders/context_commits_finder.rb
new file mode 100644
index 00000000000..f1b3eb43e84
--- /dev/null
+++ b/app/finders/context_commits_finder.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+class ContextCommitsFinder
+ def initialize(project, merge_request, params = {})
+ @project = project
+ @merge_request = merge_request
+ @search = params[:search]
+ @limit = (params[:limit] || 40).to_i
+ @offset = (params[:offset] || 0).to_i
+ end
+
+ def execute
+ commits = init_collection
+ commits = filter_existing_commits(commits)
+
+ commits
+ end
+
+ private
+
+ attr_reader :project, :merge_request, :search, :limit, :offset
+
+ def init_collection
+ commits =
+ if search.present?
+ search_commits
+ else
+ project.repository.commits(merge_request.source_branch, { limit: limit, offset: offset })
+ end
+
+ commits
+ end
+
+ def filter_existing_commits(commits)
+ commits.select! { |commit| already_included_ids.exclude?(commit.id) }
+
+ commits
+ end
+
+ def search_commits
+ key = search.strip
+ commits = []
+ if Commit.valid_hash?(key)
+ mr_existing_commits_ids = merge_request.commits.map(&:id)
+ if mr_existing_commits_ids.exclude? key
+ commit_by_sha = project.repository.commit(key)
+ commits = [commit_by_sha] if commit_by_sha
+ end
+ else
+ commits = project.repository.find_commits_by_message(search, nil, nil, 20)
+ end
+
+ commits
+ end
+
+ def already_included_ids
+ mr_existing_commits_ids = merge_request.commits.map(&:id)
+ mr_context_commits_ids = merge_request.context_commits.map(&:id)
+
+ mr_existing_commits_ids + mr_context_commits_ids
+ end
+end
diff --git a/app/finders/contributed_projects_finder.rb b/app/finders/contributed_projects_finder.rb
index f8c7f0c3167..a351d30229e 100644
--- a/app/finders/contributed_projects_finder.rb
+++ b/app/finders/contributed_projects_finder.rb
@@ -12,16 +12,14 @@ class ContributedProjectsFinder < UnionFinder
# visible by this user.
#
# Returns an ActiveRecord::Relation.
- # rubocop: disable CodeReuse/ActiveRecord
def execute(current_user = nil)
# Do not show contributed projects if the user profile is private.
return Project.none unless can_read_profile?(current_user)
segments = all_projects(current_user)
- find_union(segments, Project).includes(:namespace).order_id_desc
+ find_union(segments, Project).with_namespace.order_id_desc
end
- # rubocop: enable CodeReuse/ActiveRecord
private
diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb
index 6d059e10d05..7755cbdf9e5 100644
--- a/app/finders/events_finder.rb
+++ b/app/finders/events_finder.rb
@@ -43,16 +43,17 @@ class EventsFinder
events = sort(events)
events = events.with_associations if params[:with_associations]
-
paginated_filtered_by_user_visibility(events)
end
private
def get_events
- return EventCollection.new(current_user.authorized_projects).all_project_events if scope == 'all'
-
- source.events
+ if current_user && scope == 'all'
+ EventCollection.new(current_user.authorized_projects).all_project_events
+ else
+ source.events
+ end
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 194d7da1cab..6d5b1ca3bc5 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -314,18 +314,21 @@ class IssuableFinder
params[:assignee_username].present?
end
- # rubocop: disable CodeReuse/ActiveRecord
def assignee
- return @assignee if defined?(@assignee)
+ assignees.first
+ end
- @assignee =
+ # rubocop: disable CodeReuse/ActiveRecord
+ def assignees
+ strong_memoize(:assignees) do
if assignee_id?
- User.find_by(id: params[:assignee_id])
+ User.where(id: params[:assignee_id])
elsif assignee_username?
- User.find_by_username(params[:assignee_username])
+ User.where(username: params[:assignee_username])
else
- nil
+ User.none
end
+ end
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -415,7 +418,7 @@ class IssuableFinder
# These are "helper" params that are required inside the NOT to get the right results. They usually come in
# at the top-level params, but if they do come in inside the `:not` params, they should take precedence.
not_helpers = params.slice(*NEGATABLE_PARAMS_HELPER_KEYS).merge(params[:not].slice(*NEGATABLE_PARAMS_HELPER_KEYS))
- not_param = { key => value }.with_indifferent_access.merge(not_helpers)
+ not_param = { key => value }.with_indifferent_access.merge(not_helpers).merge(not_query: true)
items_to_negate = self.class.new(current_user, not_param).execute
@@ -543,6 +546,8 @@ class IssuableFinder
# rubocop: enable CodeReuse/ActiveRecord
def by_assignee(items)
+ return items.assigned_to(assignees) if not_query? && assignees.any?
+
if filter_by_no_assignee?
items.unassigned
elsif filter_by_any_assignee?
@@ -624,7 +629,7 @@ class IssuableFinder
elsif filter_by_any_label?
items.any_label
else
- items.with_label(label_names, params[:sort])
+ items.with_label(label_names, params[:sort], not_query: not_query?)
end
items
@@ -673,4 +678,8 @@ class IssuableFinder
def min_access_level
ProjectFeature.required_minimum_access_level(klass)
end
+
+ def not_query?
+ !!params[:not_query]
+ end
end
diff --git a/app/finders/keys_finder.rb b/app/finders/keys_finder.rb
index 6fd914c88cd..0263d809246 100644
--- a/app/finders/keys_finder.rb
+++ b/app/finders/keys_finder.rb
@@ -8,16 +8,13 @@ class KeysFinder
'md5' => 'fingerprint'
}.freeze
- def initialize(current_user, params)
- @current_user = current_user
+ def initialize(params)
@params = params
end
def execute
- raise GitLabAccessDeniedError unless current_user.admin?
-
keys = by_key_type
- keys = by_user(keys)
+ keys = by_users(keys)
keys = sort(keys)
by_fingerprint(keys)
@@ -25,7 +22,7 @@ class KeysFinder
private
- attr_reader :current_user, :params
+ attr_reader :params
def by_key_type
if params[:key_type] == 'ssh'
@@ -39,10 +36,10 @@ class KeysFinder
keys.order_last_used_at_desc
end
- def by_user(keys)
- return keys unless params[:user]
+ def by_users(keys)
+ return keys unless params[:users]
- keys.for_user(params[:user])
+ keys.for_user(params[:users])
end
def by_fingerprint(keys)
diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb
index a919ff5bf8a..0617f34dc8c 100644
--- a/app/finders/members_finder.rb
+++ b/app/finders/members_finder.rb
@@ -1,7 +1,9 @@
# frozen_string_literal: true
class MembersFinder
- attr_reader :project, :current_user, :group
+ # Params can be any of the following:
+ # sort: string
+ # search: string
def initialize(project, current_user)
@project = project
@@ -9,28 +11,39 @@ class MembersFinder
@group = project.group
end
- def execute(include_relations: [:inherited, :direct])
+ def execute(include_relations: [:inherited, :direct], params: {})
+ members = find_members(include_relations, params)
+
+ filter_members(members, params)
+ end
+
+ def can?(*args)
+ Ability.allowed?(*args)
+ end
+
+ private
+
+ attr_reader :project, :current_user, :group
+
+ def find_members(include_relations, params)
project_members = project.project_members
project_members = project_members.non_invite unless can?(current_user, :admin_project, project)
return project_members if include_relations == [:direct]
union_members = group_union_members(include_relations)
-
union_members << project_members if include_relations.include?(:direct)
- if union_members.any?
- distinct_union_of_members(union_members)
- else
- project_members
- end
- end
+ return project_members unless union_members.any?
- def can?(*args)
- Ability.allowed?(*args)
+ distinct_union_of_members(union_members)
end
- private
+ def filter_members(members, params)
+ members = members.search(params[:search]) if params[:search].present?
+ members = members.sort_by_attribute(params[:sort]) if params[:sort].present?
+ members
+ end
def group_union_members(include_relations)
[].tap do |members|
diff --git a/app/finders/milestones_finder.rb b/app/finders/milestones_finder.rb
index 77b55cbb838..cfe648d9f79 100644
--- a/app/finders/milestones_finder.rb
+++ b/app/finders/milestones_finder.rb
@@ -11,6 +11,7 @@
class MilestonesFinder
include FinderMethods
+ include TimeFrameFilter
attr_reader :params
@@ -24,6 +25,7 @@ class MilestonesFinder
items = by_title(items)
items = by_search_title(items)
items = by_state(items)
+ items = by_timeframe(items)
order(items)
end
diff --git a/app/finders/personal_projects_finder.rb b/app/finders/personal_projects_finder.rb
index 20f5b221a89..e7094d73905 100644
--- a/app/finders/personal_projects_finder.rb
+++ b/app/finders/personal_projects_finder.rb
@@ -17,15 +17,13 @@ class PersonalProjectsFinder < UnionFinder
# min_access_level: integer
#
# Returns an ActiveRecord::Relation.
- # rubocop: disable CodeReuse/ActiveRecord
def execute(current_user = nil)
return Project.none unless can?(current_user, :read_user_profile, @user)
segments = all_projects(current_user)
- find_union(segments, Project).includes(:namespace).order_updated_desc
+ find_union(segments, Project).with_namespace.order_updated_desc
end
- # rubocop: enable CodeReuse/ActiveRecord
private
diff --git a/app/finders/pipelines_finder.rb b/app/finders/pipelines_finder.rb
index 48da44123f6..0599daab564 100644
--- a/app/finders/pipelines_finder.rb
+++ b/app/finders/pipelines_finder.rb
@@ -39,7 +39,7 @@ class PipelinesFinder
# rubocop: disable CodeReuse/ActiveRecord
def from_ids(ids)
- pipelines.unscoped.where(id: ids)
+ pipelines.unscoped.where(project_id: project.id, id: ids)
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/finders/projects/prometheus/alerts_finder.rb b/app/finders/projects/prometheus/alerts_finder.rb
new file mode 100644
index 00000000000..3e3b72647c5
--- /dev/null
+++ b/app/finders/projects/prometheus/alerts_finder.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+module Projects
+ module Prometheus
+ # Find Prometheus alerts by +project+, +environment+, +id+,
+ # or any combo thereof.
+ #
+ # Optionally filter by +metric+.
+ #
+ # Arguments:
+ # params:
+ # project: Project | integer
+ # environment: Environment | integer
+ # metric: PrometheusMetric | integer
+ class AlertsFinder
+ def initialize(params = {})
+ unless params[:project] || params[:environment] || params[:id]
+ raise ArgumentError,
+ 'Please provide one or more of the following params: :project, :environment, :id'
+ end
+
+ @params = params
+ end
+
+ # Find all matching alerts
+ #
+ # @return [ActiveRecord::Relation<PrometheusAlert>]
+ def execute
+ relation = by_project(PrometheusAlert)
+ relation = by_environment(relation)
+ relation = by_metric(relation)
+ relation = by_id(relation)
+ relation = ordered(relation)
+
+ relation
+ end
+
+ private
+
+ attr_reader :params
+
+ def by_project(relation)
+ return relation unless params[:project]
+
+ relation.for_project(params[:project])
+ end
+
+ def by_environment(relation)
+ return relation unless params[:environment]
+
+ relation.for_environment(params[:environment])
+ end
+
+ def by_metric(relation)
+ return relation unless params[:metric]
+
+ relation.for_metric(params[:metric])
+ end
+
+ def by_id(relation)
+ return relation unless params[:id]
+
+ relation.id_in(params[:id])
+ end
+
+ def ordered(relation)
+ relation.order_by('id_asc')
+ end
+ end
+ end
+end
diff --git a/app/finders/projects/serverless/functions_finder.rb b/app/finders/projects/serverless/functions_finder.rb
index e8c50ef1a88..3b4ecbb5387 100644
--- a/app/finders/projects/serverless/functions_finder.rb
+++ b/app/finders/projects/serverless/functions_finder.rb
@@ -4,9 +4,15 @@ module Projects
module Serverless
class FunctionsFinder
include Gitlab::Utils::StrongMemoize
+ include ReactiveCaching
attr_reader :project
+ self.reactive_cache_key = ->(finder) { finder.cache_key }
+ self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
+
+ MAX_CLUSTERS = 10
+
def initialize(project)
@project = project
end
@@ -15,8 +21,9 @@ module Projects
knative_services.flatten.compact
end
- # Possible return values: Clusters::KnativeServicesFinder::KNATIVE_STATE
def knative_installed
+ return knative_installed_from_cluster?(*cache_key) if available_environments.empty?
+
states = services_finders.map do |finder|
finder.knative_detected.tap do |state|
return state if state == ::Clusters::KnativeServicesFinder::KNATIVE_STATES['checking'] # rubocop:disable Cop/AvoidReturnFromBlocks
@@ -45,32 +52,73 @@ module Projects
end
end
+ def self.from_cache(project_id)
+ project = Project.find(project_id)
+
+ new(project)
+ end
+
+ def cache_key(*args)
+ [project.id]
+ end
+
+ def calculate_reactive_cache(*)
+ # rubocop: disable CodeReuse/ActiveRecord
+ project.all_clusters.enabled.take(MAX_CLUSTERS).any? do |cluster|
+ cluster.kubeclient.knative_client.discover
+ rescue Kubeclient::ResourceNotFoundError
+ next
+ end
+ end
+
private
+ def knative_installed_from_cluster?(*cache_key)
+ cached_data = with_reactive_cache_memoized(*cache_key) { |data| data }
+
+ return ::Clusters::KnativeServicesFinder::KNATIVE_STATES['checking'] if cached_data.nil?
+
+ cached_data ? true : false
+ end
+
+ def with_reactive_cache_memoized(*cache_key)
+ strong_memoize(:reactive_cache) do
+ with_reactive_cache(*cache_key) { |data| data }
+ end
+ end
+
def knative_service(environment_scope, name)
finders_for_scope(environment_scope).map do |finder|
services = finder
.services
.select { |svc| svc["metadata"]["name"] == name }
- add_metadata(finder, services).first unless services.nil?
+ attributes = add_metadata(finder, services).first
+ next unless attributes
+
+ Gitlab::Serverless::Service.new(attributes)
end
end
def knative_services
services_finders.map do |finder|
- services = finder.services
+ attributes = add_metadata(finder, finder.services)
- add_metadata(finder, services) unless services.nil?
+ attributes&.map do |attributes|
+ Gitlab::Serverless::Service.new(attributes)
+ end
end
end
def add_metadata(finder, services)
+ return if services.nil?
+
add_pod_count = services.one?
services.each do |s|
s["environment_scope"] = finder.cluster.environment_scope
- s["cluster_id"] = finder.cluster.id
+ s["environment"] = finder.environment
+ s["cluster"] = finder.cluster
if add_pod_count
s["podcount"] = finder
@@ -95,6 +143,10 @@ module Projects
environment_scope == finder.cluster.environment_scope
end
end
+
+ def id
+ nil
+ end
end
end
end
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index ac18c17dc61..c319d2fed87 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -44,6 +44,8 @@ class ProjectsFinder < UnionFinder
init_collection
end
+ use_cte = params.delete(:use_cte)
+ collection = Project.wrap_with_cte(collection) if use_cte
collection = filter_projects(collection)
sort(collection)
end
@@ -177,7 +179,7 @@ class ProjectsFinder < UnionFinder
end
def sort(items)
- params[:sort].present? ? items.sort_by_attribute(params[:sort]) : items.order_id_desc
+ params[:sort].present? ? items.sort_by_attribute(params[:sort]) : items.projects_order_id_desc
end
def by_archived(projects)
diff --git a/app/finders/protected_branches_finder.rb b/app/finders/protected_branches_finder.rb
new file mode 100644
index 00000000000..68e8d2a9f54
--- /dev/null
+++ b/app/finders/protected_branches_finder.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+# ProtectedBranchesFinder
+#
+# Used to filter protected branches by set of params
+#
+# Arguments:
+# project - which project to scope to
+# params:
+# search: string
+class ProtectedBranchesFinder
+ LIMIT = 100
+
+ attr_accessor :project, :params
+
+ def initialize(project, params = {})
+ @project = project
+ @params = params
+ end
+
+ def execute
+ protected_branches = project.limited_protected_branches(LIMIT)
+ protected_branches = by_name(protected_branches)
+
+ protected_branches
+ end
+
+ private
+
+ def by_name(protected_branches)
+ return protected_branches unless params[:search].present?
+
+ protected_branches.by_name(params[:search])
+ end
+end
diff --git a/app/graphql/mutations/issues/update.rb b/app/graphql/mutations/issues/update.rb
new file mode 100644
index 00000000000..119bc51e4a4
--- /dev/null
+++ b/app/graphql/mutations/issues/update.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Issues
+ class Update < Base
+ graphql_name 'UpdateIssue'
+
+ # Add arguments here instead of creating separate mutations
+
+ def resolve(project_path:, iid:, **args)
+ issue = authorized_find!(project_path: project_path, iid: iid)
+ project = issue.project
+
+ ::Issues::UpdateService.new(project, current_user, args).execute(issue)
+
+ {
+ issue: issue,
+ errors: issue.errors.full_messages
+ }
+ end
+ end
+ end
+end
+
+Mutations::Issues::Update.prepend_if_ee('::EE::Mutations::Issues::Update')
diff --git a/app/graphql/mutations/notes/update.rb b/app/graphql/mutations/notes/update.rb
deleted file mode 100644
index ebf57b800c0..00000000000
--- a/app/graphql/mutations/notes/update.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-module Mutations
- module Notes
- class Update < Base
- graphql_name 'UpdateNote'
-
- authorize :admin_note
-
- argument :id,
- GraphQL::ID_TYPE,
- required: true,
- description: 'The global id of the note to update'
-
- argument :body,
- GraphQL::STRING_TYPE,
- required: true,
- description: copy_field_description(Types::Notes::NoteType, :body)
-
- def resolve(args)
- note = authorized_find!(id: args[:id])
-
- check_object_is_note!(note)
-
- note = ::Notes::UpdateService.new(
- note.project,
- current_user,
- { note: args[:body] }
- ).execute(note)
-
- {
- note: note.reset,
- errors: errors_on_object(note)
- }
- end
- end
- end
-end
diff --git a/app/graphql/mutations/notes/update/base.rb b/app/graphql/mutations/notes/update/base.rb
new file mode 100644
index 00000000000..9a53337f253
--- /dev/null
+++ b/app/graphql/mutations/notes/update/base.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Notes
+ module Update
+ # This is a Base class for the Note update mutations and is not
+ # mounted as a GraphQL mutation itself.
+ class Base < Mutations::Notes::Base
+ authorize :admin_note
+
+ argument :id,
+ GraphQL::ID_TYPE,
+ required: true,
+ description: 'The global id of the note to update'
+
+ def resolve(args)
+ note = authorized_find!(id: args[:id])
+
+ pre_update_checks!(note, args)
+
+ updated_note = ::Notes::UpdateService.new(
+ note.project,
+ current_user,
+ note_params(note, args)
+ ).execute(note)
+
+ # It's possible for updated_note to be `nil`, in the situation
+ # where the note is deleted within `Notes::UpdateService` due to
+ # the body of the note only containing Quick Actions.
+ {
+ note: updated_note&.reset,
+ errors: updated_note ? errors_on_object(updated_note) : []
+ }
+ end
+
+ private
+
+ def pre_update_checks!(_note, _args)
+ raise NotImplementedError
+ end
+
+ def note_params(_note, args)
+ { note: args[:body] }.compact
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/notes/update/image_diff_note.rb b/app/graphql/mutations/notes/update/image_diff_note.rb
new file mode 100644
index 00000000000..7aad3af1e04
--- /dev/null
+++ b/app/graphql/mutations/notes/update/image_diff_note.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Notes
+ module Update
+ class ImageDiffNote < Mutations::Notes::Update::Base
+ graphql_name 'UpdateImageDiffNote'
+
+ argument :body,
+ GraphQL::STRING_TYPE,
+ required: false,
+ description: copy_field_description(Types::Notes::NoteType, :body)
+
+ argument :position,
+ Types::Notes::UpdateDiffImagePositionInputType,
+ required: false,
+ description: copy_field_description(Types::Notes::NoteType, :position)
+
+ def ready?(**args)
+ # As both arguments are optional, validate here that one of the
+ # arguments are present.
+ #
+ # This may be able to be done using InputUnions in the future
+ # if this RFC is merged:
+ # https://github.com/graphql/graphql-spec/blob/master/rfcs/InputUnion.md
+ if args.values_at(:body, :position).compact.blank?
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ 'body or position arguments are required'
+ end
+
+ super(args)
+ end
+
+ private
+
+ def pre_update_checks!(note, args)
+ unless note.is_a?(DiffNote) && note.position.on_image?
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable,
+ 'Resource is not an ImageDiffNote'
+ end
+ end
+
+ def note_params(note, args)
+ super(note, args).merge(
+ position: position_params(note, args)
+ ).compact
+ end
+
+ def position_params(note, args)
+ new_position = args[:position]&.to_h&.compact
+ return unless new_position
+
+ original_position = note.position.to_h
+
+ Gitlab::Diff::Position.new(original_position.merge(new_position))
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/notes/update/note.rb b/app/graphql/mutations/notes/update/note.rb
new file mode 100644
index 00000000000..03a174fc8d9
--- /dev/null
+++ b/app/graphql/mutations/notes/update/note.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Notes
+ module Update
+ class Note < Mutations::Notes::Update::Base
+ graphql_name 'UpdateNote'
+
+ argument :body,
+ GraphQL::STRING_TYPE,
+ required: true,
+ description: copy_field_description(Types::Notes::NoteType, :body)
+
+ private
+
+ def pre_update_checks!(note, _args)
+ check_object_is_note!(note)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb
index 4e0e65d09a9..266a123de82 100644
--- a/app/graphql/mutations/snippets/create.rb
+++ b/app/graphql/mutations/snippets/create.rb
@@ -67,11 +67,11 @@ module Mutations
end
def authorized_resource?(project)
- Ability.allowed?(context[:current_user], :create_project_snippet, project)
+ Ability.allowed?(context[:current_user], :create_snippet, project)
end
def can_create_personal_snippet?
- Ability.allowed?(context[:current_user], :create_personal_snippet)
+ Ability.allowed?(context[:current_user], :create_snippet)
end
end
end
diff --git a/app/graphql/mutations/todos/restore_many.rb b/app/graphql/mutations/todos/restore_many.rb
new file mode 100644
index 00000000000..8a6265207cd
--- /dev/null
+++ b/app/graphql/mutations/todos/restore_many.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Todos
+ class RestoreMany < ::Mutations::Todos::Base
+ graphql_name 'TodoRestoreMany'
+
+ MAX_UPDATE_AMOUNT = 50
+
+ argument :ids,
+ [GraphQL::ID_TYPE],
+ required: true,
+ description: 'The global ids of the todos to restore (a maximum of 50 is supported at once)'
+
+ field :updated_ids, [GraphQL::ID_TYPE],
+ null: false,
+ description: 'The ids of the updated todo items'
+
+ def resolve(ids:)
+ check_update_amount_limit!(ids)
+
+ todos = authorized_find_all_pending_by_current_user(model_ids_of(ids))
+ updated_ids = restore(todos)
+
+ {
+ updated_ids: gids_of(updated_ids),
+ errors: errors_on_objects(todos)
+ }
+ end
+
+ private
+
+ def gids_of(ids)
+ ids.map { |id| ::URI::GID.build(app: GlobalID.app, model_name: Todo.name, model_id: id, params: nil).to_s }
+ end
+
+ def model_ids_of(ids)
+ ids.map do |gid|
+ parsed_gid = ::URI::GID.parse(gid)
+ parsed_gid.model_id.to_i if accessible_todo?(parsed_gid)
+ end.compact
+ end
+
+ def accessible_todo?(gid)
+ gid.app == GlobalID.app && todo?(gid)
+ end
+
+ def todo?(gid)
+ GlobalID.parse(gid)&.model_class&.ancestors&.include?(Todo)
+ end
+
+ def raise_too_many_todos_requested_error
+ raise Gitlab::Graphql::Errors::ArgumentError, 'Too many todos requested.'
+ end
+
+ def check_update_amount_limit!(ids)
+ raise_too_many_todos_requested_error if ids.size > MAX_UPDATE_AMOUNT
+ end
+
+ def errors_on_objects(todos)
+ todos.flat_map { |todo| errors_on_object(todo) }
+ end
+
+ def authorized_find_all_pending_by_current_user(ids)
+ return Todo.none if ids.blank? || current_user.nil?
+
+ Todo.for_ids(ids).for_user(current_user).done
+ end
+
+ def restore(todos)
+ TodoService.new.mark_todos_as_pending(todos, current_user)
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb
index f2b015edfa1..66cb224f157 100644
--- a/app/graphql/resolvers/base_resolver.rb
+++ b/app/graphql/resolvers/base_resolver.rb
@@ -58,5 +58,9 @@ module Resolvers
def single?
false
end
+
+ def current_user
+ context[:current_user]
+ end
end
end
diff --git a/app/graphql/resolvers/boards_resolver.rb b/app/graphql/resolvers/boards_resolver.rb
new file mode 100644
index 00000000000..45c03bf0bef
--- /dev/null
+++ b/app/graphql/resolvers/boards_resolver.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class BoardsResolver < BaseResolver
+ type Types::BoardType, null: true
+
+ def resolve(**args)
+ # The project or group could have been loaded in batch by `BatchLoader`.
+ # At this point we need the `id` of the project/group to query for boards, so
+ # make sure it's loaded and not `nil` before continuing.
+ parent = object.respond_to?(:sync) ? object.sync : object
+
+ return Board.none unless parent
+
+ Boards::ListService.new(parent, context[:current_user]).execute(create_default_board: false)
+ end
+ end
+end
diff --git a/app/graphql/resolvers/concerns/time_frame_arguments.rb b/app/graphql/resolvers/concerns/time_frame_arguments.rb
new file mode 100644
index 00000000000..ef333dd05a5
--- /dev/null
+++ b/app/graphql/resolvers/concerns/time_frame_arguments.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module TimeFrameArguments
+ extend ActiveSupport::Concern
+
+ included do
+ argument :start_date, Types::TimeType,
+ required: false,
+ description: 'List items within a time frame where items.start_date is between startDate and endDate parameters (endDate parameter must be present)'
+
+ argument :end_date, Types::TimeType,
+ required: false,
+ description: 'List items within a time frame where items.end_date is between startDate and endDate parameters (startDate parameter must be present)'
+ end
+
+ def validate_timeframe_params!(args)
+ return unless args[:start_date].present? || args[:end_date].present?
+
+ error_message =
+ if args[:start_date].nil? || args[:end_date].nil?
+ "Both startDate and endDate must be present."
+ elsif args[:start_date] > args[:end_date]
+ "startDate is after endDate"
+ end
+
+ if error_message
+ raise Gitlab::Graphql::Errors::ArgumentError, error_message
+ end
+ end
+end
diff --git a/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb
index 63455ff3acb..72c5c19c25c 100644
--- a/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb
+++ b/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb
@@ -8,7 +8,6 @@ module Resolvers
description: 'ID of the Sentry issue'
def resolve(**args)
- project = object
current_user = context[:current_user]
issue_id = GlobalID.parse(args[:id]).model_id
@@ -23,6 +22,14 @@ module Resolvers
issue
end
+
+ private
+
+ def project
+ return object.gitlab_project if object.respond_to?(:gitlab_project)
+
+ object
+ end
end
end
end
diff --git a/app/graphql/resolvers/error_tracking/sentry_error_collection_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_error_collection_resolver.rb
new file mode 100644
index 00000000000..e4b4854c273
--- /dev/null
+++ b/app/graphql/resolvers/error_tracking/sentry_error_collection_resolver.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module ErrorTracking
+ class SentryErrorCollectionResolver < BaseResolver
+ def resolve(**args)
+ project = object
+
+ service = ::ErrorTracking::ListIssuesService.new(
+ project,
+ context[:current_user]
+ )
+
+ Gitlab::ErrorTracking::ErrorCollection.new(
+ external_url: service.external_url,
+ project: project
+ )
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb
new file mode 100644
index 00000000000..f5356660569
--- /dev/null
+++ b/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module ErrorTracking
+ class SentryErrorStackTraceResolver < BaseResolver
+ argument :id, GraphQL::ID_TYPE,
+ required: true,
+ description: 'ID of the Sentry issue'
+
+ def resolve(**args)
+ issue_id = GlobalID.parse(args[:id]).model_id
+
+ # Get data from Sentry
+ response = ::ErrorTracking::IssueLatestEventService.new(
+ project,
+ current_user,
+ { issue_id: issue_id }
+ ).execute
+
+ event = response[:latest_event]
+ event.gitlab_project = project if event
+
+ event
+ end
+
+ private
+
+ def project
+ return object.gitlab_project if object.respond_to?(:gitlab_project)
+
+ object
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb
new file mode 100644
index 00000000000..79f99709505
--- /dev/null
+++ b/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module ErrorTracking
+ class SentryErrorsResolver < BaseResolver
+ def resolve(**args)
+ args[:cursor] = args.delete(:after)
+ project = object.project
+
+ result = ::ErrorTracking::ListIssuesService.new(
+ project,
+ context[:current_user],
+ args
+ ).execute
+
+ next_cursor = result[:pagination]&.dig('next', 'cursor')
+ previous_cursor = result[:pagination]&.dig('previous', 'cursor')
+ issues = result[:issues]
+
+ # ReactiveCache is still fetching data
+ return if issues.nil?
+
+ Gitlab::Graphql::ExternallyPaginatedArray.new(previous_cursor, next_cursor, *issues)
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/milestone_resolver.rb b/app/graphql/resolvers/milestone_resolver.rb
new file mode 100644
index 00000000000..2e7b6fdfd5f
--- /dev/null
+++ b/app/graphql/resolvers/milestone_resolver.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class MilestoneResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+ include TimeFrameArguments
+
+ argument :state, Types::MilestoneStateEnum,
+ required: false,
+ description: 'Filter milestones by state'
+
+ type Types::MilestoneType, null: true
+
+ def resolve(**args)
+ validate_timeframe_params!(args)
+
+ authorize!
+
+ MilestonesFinder.new(milestones_finder_params(args)).execute
+ end
+
+ private
+
+ def milestones_finder_params(args)
+ {
+ state: args[:state] || 'all',
+ start_date: args[:start_date],
+ end_date: args[:end_date]
+ }.merge(parent_id_parameter)
+ end
+
+ def parent
+ @parent ||= object.respond_to?(:sync) ? object.sync : object
+ end
+
+ def parent_id_parameter
+ if parent.is_a?(Group)
+ { group_ids: parent.id }
+ elsif parent.is_a?(Project)
+ { project_ids: parent.id }
+ end
+ end
+
+ # MilestonesFinder does not check for current_user permissions,
+ # so for now we need to keep it here.
+ def authorize!
+ Ability.allowed?(context[:current_user], :read_milestone, parent) || raise_resource_not_available_error!
+ end
+ end
+end
diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb
index efeee4a7a4d..3ade1300c2d 100644
--- a/app/graphql/types/base_field.rb
+++ b/app/graphql/types/base_field.rb
@@ -10,6 +10,8 @@ module Types
@calls_gitaly = !!kwargs.delete(:calls_gitaly)
@constant_complexity = !!kwargs[:complexity]
kwargs[:complexity] ||= field_complexity(kwargs[:resolver_class])
+ @feature_flag = kwargs[:feature_flag]
+ kwargs = check_feature_flag(kwargs)
super(*args, **kwargs, &block)
end
@@ -28,8 +30,27 @@ module Types
@constant_complexity
end
+ def visible?(context)
+ return false if feature_flag.present? && !Feature.enabled?(feature_flag)
+
+ super
+ end
+
private
+ attr_reader :feature_flag
+
+ def feature_documentation_message(key, description)
+ "#{description}. Available only when feature flag #{key} is enabled."
+ end
+
+ def check_feature_flag(args)
+ args[:description] = feature_documentation_message(args[:feature_flag], args[:description]) if args[:feature_flag].present?
+ args.delete(:feature_flag)
+
+ args
+ end
+
def field_complexity(resolver_class)
if resolver_class
field_resolver_complexity
diff --git a/app/graphql/types/blob_viewers/type_enum.rb b/app/graphql/types/blob_viewers/type_enum.rb
new file mode 100644
index 00000000000..35e659197e5
--- /dev/null
+++ b/app/graphql/types/blob_viewers/type_enum.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ module BlobViewers
+ class TypeEnum < BaseEnum
+ graphql_name 'BlobViewersType'
+ description 'Types of blob viewers'
+
+ value 'rich', value: :rich
+ value 'simple', value: :simple
+ value 'auxiliary', value: :auxiliary
+ end
+ end
+end
diff --git a/app/graphql/types/board_type.rb b/app/graphql/types/board_type.rb
new file mode 100644
index 00000000000..9c95a987fe4
--- /dev/null
+++ b/app/graphql/types/board_type.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Types
+ class BoardType < BaseObject
+ graphql_name 'Board'
+ description 'Represents a project or group board'
+
+ authorize :read_board
+
+ field :id, type: GraphQL::ID_TYPE, null: false,
+ description: 'ID (global ID) of the board'
+ field :name, type: GraphQL::STRING_TYPE, null: true,
+ description: 'Name of the board'
+ end
+end
+
+Types::BoardType.prepend_if_ee('::EE::Types::BoardType')
diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb
index 87f84ec576f..eb25e3651a8 100644
--- a/app/graphql/types/commit_type.rb
+++ b/app/graphql/types/commit_type.rb
@@ -12,7 +12,7 @@ module Types
description: 'ID (global ID) of the commit'
field :sha, type: GraphQL::STRING_TYPE, null: false,
description: 'SHA1 ID of the commit'
- field :title, type: GraphQL::STRING_TYPE, null: true,
+ field :title, type: GraphQL::STRING_TYPE, null: true, calls_gitaly: true,
description: 'Title of the commit message'
field :description, type: GraphQL::STRING_TYPE, null: true,
description: 'Description of the commit message'
@@ -26,6 +26,11 @@ module Types
description: 'Rendered HTML of the commit signature'
field :author_name, type: GraphQL::STRING_TYPE, null: true,
description: 'Commit authors name'
+ field :author_gravatar, type: GraphQL::STRING_TYPE, null: true,
+ description: 'Commit authors gravatar',
+ resolve: -> (commit, args, context) do
+ GravatarService.new.execute(commit.author_email, 40)
+ end
# models/commit lazy loads the author by email
field :author, type: Types::UserType, null: true,
@@ -40,7 +45,7 @@ module Types
type: Types::Ci::PipelineType,
null: true,
description: "Latest pipeline of the commit",
- deprecation_reason: 'use pipelines',
+ deprecation_reason: 'Use pipelines',
resolver: Resolvers::CommitPipelinesResolver.last
end
end
diff --git a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb
index af6d8818d90..124398f28e7 100644
--- a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb
+++ b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb
@@ -4,96 +4,95 @@ module Types
module ErrorTracking
class SentryDetailedErrorType < ::Types::BaseObject
graphql_name 'SentryDetailedError'
+ description 'A Sentry error.'
- present_using SentryDetailedErrorPresenter
+ present_using SentryErrorPresenter
authorize :read_sentry_issue
field :id, GraphQL::ID_TYPE,
null: false,
- description: "ID (global ID) of the error"
+ description: 'ID (global ID) of the error'
field :sentry_id, GraphQL::STRING_TYPE,
method: :id,
null: false,
- description: "ID (Sentry ID) of the error"
+ description: 'ID (Sentry ID) of the error'
field :title, GraphQL::STRING_TYPE,
null: false,
- description: "Title of the error"
+ description: 'Title of the error'
field :type, GraphQL::STRING_TYPE,
null: false,
- description: "Type of the error"
+ description: 'Type of the error'
field :user_count, GraphQL::INT_TYPE,
null: false,
- description: "Count of users affected by the error"
+ description: 'Count of users affected by the error'
field :count, GraphQL::INT_TYPE,
null: false,
- description: "Count of occurrences"
+ description: 'Count of occurrences'
field :first_seen, Types::TimeType,
null: false,
- description: "Timestamp when the error was first seen"
+ description: 'Timestamp when the error was first seen'
field :last_seen, Types::TimeType,
null: false,
- description: "Timestamp when the error was last seen"
+ description: 'Timestamp when the error was last seen'
field :message, GraphQL::STRING_TYPE,
null: true,
- description: "Sentry metadata message of the error"
+ description: 'Sentry metadata message of the error'
field :culprit, GraphQL::STRING_TYPE,
null: false,
- description: "Culprit of the error"
+ description: 'Culprit of the error'
+ field :external_base_url, GraphQL::STRING_TYPE,
+ null: false,
+ description: 'External Base URL of the Sentry Instance'
field :external_url, GraphQL::STRING_TYPE,
null: false,
- description: "External URL of the error"
+ description: 'External URL of the error'
field :sentry_project_id, GraphQL::ID_TYPE,
method: :project_id,
null: false,
- description: "ID of the project (Sentry project)"
+ description: 'ID of the project (Sentry project)'
field :sentry_project_name, GraphQL::STRING_TYPE,
method: :project_name,
null: false,
- description: "Name of the project affected by the error"
+ description: 'Name of the project affected by the error'
field :sentry_project_slug, GraphQL::STRING_TYPE,
method: :project_slug,
null: false,
- description: "Slug of the project affected by the error"
+ description: 'Slug of the project affected by the error'
field :short_id, GraphQL::STRING_TYPE,
null: false,
- description: "Short ID (Sentry ID) of the error"
+ description: 'Short ID (Sentry ID) of the error'
field :status, Types::ErrorTracking::SentryErrorStatusEnum,
null: false,
- description: "Status of the error"
+ description: 'Status of the error'
field :frequency, [Types::ErrorTracking::SentryErrorFrequencyType],
null: false,
- description: "Last 24hr stats of the error"
+ description: 'Last 24hr stats of the error'
field :first_release_last_commit, GraphQL::STRING_TYPE,
null: true,
- description: "Commit the error was first seen"
+ description: 'Commit the error was first seen'
field :last_release_last_commit, GraphQL::STRING_TYPE,
null: true,
- description: "Commit the error was last seen"
+ description: 'Commit the error was last seen'
field :first_release_short_version, GraphQL::STRING_TYPE,
null: true,
- description: "Release version the error was first seen"
+ description: 'Release version the error was first seen'
field :last_release_short_version, GraphQL::STRING_TYPE,
null: true,
- description: "Release version the error was last seen"
+ description: 'Release version the error was last seen'
field :gitlab_commit, GraphQL::STRING_TYPE,
null: true,
- description: "GitLab commit SHA attributed to the Error based on the release version"
+ description: 'GitLab commit SHA attributed to the Error based on the release version'
field :gitlab_commit_path, GraphQL::STRING_TYPE,
null: true,
- description: "Path to the GitLab page for the GitLab commit attributed to the error"
-
- def first_seen
- DateTime.parse(object.first_seen)
- end
-
- def last_seen
- DateTime.parse(object.last_seen)
- end
-
- def project_id
- Gitlab::GlobalId.build(model_name: 'Project', id: object.project_id).to_s
- end
+ description: 'Path to the GitLab page for the GitLab commit attributed to the error'
+ field :gitlab_issue_path, GraphQL::STRING_TYPE,
+ method: :gitlab_issue,
+ null: true,
+ description: 'URL of GitLab Issue'
+ field :tags, Types::ErrorTracking::SentryErrorTagsType,
+ null: false,
+ description: 'Tags associated with the Sentry Error'
end
end
end
diff --git a/app/graphql/types/error_tracking/sentry_error_collection_type.rb b/app/graphql/types/error_tracking/sentry_error_collection_type.rb
new file mode 100644
index 00000000000..121146133cb
--- /dev/null
+++ b/app/graphql/types/error_tracking/sentry_error_collection_type.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Types
+ module ErrorTracking
+ class SentryErrorCollectionType < ::Types::BaseObject
+ graphql_name 'SentryErrorCollection'
+ description 'An object containing a collection of Sentry errors, and a detailed error.'
+
+ authorize :read_sentry_issue
+
+ field :errors,
+ Types::ErrorTracking::SentryErrorType.connection_type,
+ connection: false,
+ null: true,
+ description: "Collection of Sentry Errors",
+ extensions: [Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension],
+ resolver: Resolvers::ErrorTracking::SentryErrorsResolver do
+ argument :search_term,
+ String,
+ description: 'Search term for the Sentry error.',
+ required: false
+ argument :sort,
+ String,
+ description: 'Attribute to sort on. Options are frequency, first_seen, last_seen. last_seen is default.',
+ required: false
+ end
+ field :detailed_error, Types::ErrorTracking::SentryDetailedErrorType,
+ null: true,
+ description: 'Detailed version of a Sentry error on the project',
+ resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver
+ field :error_stack_trace, Types::ErrorTracking::SentryErrorStackTraceType,
+ null: true,
+ description: 'Stack Trace of Sentry Error',
+ resolver: Resolvers::ErrorTracking::SentryErrorStackTraceResolver
+ field :external_url,
+ GraphQL::STRING_TYPE,
+ null: true,
+ description: "External URL for Sentry"
+ end
+ end
+end
diff --git a/app/graphql/types/error_tracking/sentry_error_stack_trace_context_type.rb b/app/graphql/types/error_tracking/sentry_error_stack_trace_context_type.rb
new file mode 100644
index 00000000000..e6d02c948d5
--- /dev/null
+++ b/app/graphql/types/error_tracking/sentry_error_stack_trace_context_type.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Types
+ module ErrorTracking
+ # rubocop: disable Graphql/AuthorizeTypes
+ class SentryErrorStackTraceContextType < ::Types::BaseObject
+ graphql_name 'SentryErrorStackTraceContext'
+ description 'An object context for a Sentry error stack trace'
+
+ field :line,
+ GraphQL::INT_TYPE,
+ null: false,
+ description: 'Line number of the context'
+ field :code,
+ GraphQL::STRING_TYPE,
+ null: false,
+ description: 'Code number of the context'
+
+ def line
+ object[0]
+ end
+
+ def code
+ object[1]
+ end
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/error_tracking/sentry_error_stack_trace_entry_type.rb b/app/graphql/types/error_tracking/sentry_error_stack_trace_entry_type.rb
new file mode 100644
index 00000000000..0747e41e9fb
--- /dev/null
+++ b/app/graphql/types/error_tracking/sentry_error_stack_trace_entry_type.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module Types
+ module ErrorTracking
+ # rubocop: disable Graphql/AuthorizeTypes
+ class SentryErrorStackTraceEntryType < ::Types::BaseObject
+ graphql_name 'SentryErrorStackTraceEntry'
+ description 'An object containing a stack trace entry for a Sentry error.'
+
+ field :function, GraphQL::STRING_TYPE,
+ null: true,
+ description: 'Function in which the Sentry error occurred'
+ field :col, GraphQL::STRING_TYPE,
+ null: true,
+ description: 'Function in which the Sentry error occurred'
+ field :line, GraphQL::STRING_TYPE,
+ null: true,
+ description: 'Function in which the Sentry error occurred'
+ field :file_name, GraphQL::STRING_TYPE,
+ null: true,
+ description: 'File in which the Sentry error occurred'
+ field :trace_context, [Types::ErrorTracking::SentryErrorStackTraceContextType],
+ null: true,
+ description: 'Context of the Sentry error'
+
+ def function
+ object['function']
+ end
+
+ def col
+ object['colNo']
+ end
+
+ def line
+ object['lineNo']
+ end
+
+ def file_name
+ object['filename']
+ end
+
+ def trace_context
+ object['context']
+ end
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/error_tracking/sentry_error_stack_trace_type.rb b/app/graphql/types/error_tracking/sentry_error_stack_trace_type.rb
new file mode 100644
index 00000000000..0e6105d1ff2
--- /dev/null
+++ b/app/graphql/types/error_tracking/sentry_error_stack_trace_type.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Types
+ module ErrorTracking
+ class SentryErrorStackTraceType < ::Types::BaseObject
+ graphql_name 'SentryErrorStackTrace'
+ description 'An object containing a stack trace entry for a Sentry error.'
+
+ authorize :read_sentry_issue
+
+ field :issue_id, GraphQL::STRING_TYPE,
+ null: false,
+ description: 'ID of the Sentry error'
+ field :date_received, GraphQL::STRING_TYPE,
+ null: false,
+ description: 'Time the stack trace was received by Sentry'
+ field :stack_trace_entries, [Types::ErrorTracking::SentryErrorStackTraceEntryType],
+ null: false,
+ description: 'Stack trace entries for the Sentry error'
+ end
+ end
+end
diff --git a/app/graphql/types/error_tracking/sentry_error_tags_type.rb b/app/graphql/types/error_tracking/sentry_error_tags_type.rb
new file mode 100644
index 00000000000..e6d96571561
--- /dev/null
+++ b/app/graphql/types/error_tracking/sentry_error_tags_type.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Types
+ module ErrorTracking
+ # rubocop: disable Graphql/AuthorizeTypes
+ class SentryErrorTagsType < ::Types::BaseObject
+ graphql_name 'SentryErrorTags'
+ description 'State of a Sentry error'
+
+ field :level, GraphQL::STRING_TYPE,
+ null: true,
+ description: "Severity level of the Sentry Error"
+ field :logger, GraphQL::STRING_TYPE,
+ null: true,
+ description: "Logger of the Sentry Error"
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/error_tracking/sentry_error_type.rb b/app/graphql/types/error_tracking/sentry_error_type.rb
new file mode 100644
index 00000000000..7a842025e45
--- /dev/null
+++ b/app/graphql/types/error_tracking/sentry_error_type.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module Types
+ module ErrorTracking
+ # rubocop: disable Graphql/AuthorizeTypes
+ class SentryErrorType < ::Types::BaseObject
+ graphql_name 'SentryError'
+ description 'A Sentry error. A simplified version of SentryDetailedError.'
+
+ present_using SentryErrorPresenter
+
+ field :id, GraphQL::ID_TYPE,
+ null: false,
+ description: 'ID (global ID) of the error'
+ field :sentry_id, GraphQL::STRING_TYPE,
+ method: :id,
+ null: false,
+ description: 'ID (Sentry ID) of the error'
+ field :first_seen, Types::TimeType,
+ null: false,
+ description: 'Timestamp when the error was first seen'
+ field :last_seen, Types::TimeType,
+ null: false,
+ description: 'Timestamp when the error was last seen'
+ field :title, GraphQL::STRING_TYPE,
+ null: false,
+ description: 'Title of the error'
+ field :type, GraphQL::STRING_TYPE,
+ null: false,
+ description: 'Type of the error'
+ field :user_count, GraphQL::INT_TYPE,
+ null: false,
+ description: 'Count of users affected by the error'
+ field :count, GraphQL::INT_TYPE,
+ null: false,
+ description: 'Count of occurrences'
+ field :message, GraphQL::STRING_TYPE,
+ null: true,
+ description: 'Sentry metadata message of the error'
+ field :culprit, GraphQL::STRING_TYPE,
+ null: false,
+ description: 'Culprit of the error'
+ field :external_url, GraphQL::STRING_TYPE,
+ null: false,
+ description: 'External URL of the error'
+ field :short_id, GraphQL::STRING_TYPE,
+ null: false,
+ description: 'Short ID (Sentry ID) of the error'
+ field :status, Types::ErrorTracking::SentryErrorStatusEnum,
+ null: false,
+ description: 'Status of the error'
+ field :frequency, [Types::ErrorTracking::SentryErrorFrequencyType],
+ null: false,
+ description: 'Last 24hr stats of the error'
+ field :sentry_project_id, GraphQL::ID_TYPE,
+ method: :project_id,
+ null: false,
+ description: 'ID of the project (Sentry project)'
+ field :sentry_project_name, GraphQL::STRING_TYPE,
+ method: :project_name,
+ null: false,
+ description: 'Name of the project affected by the error'
+ field :sentry_project_slug, GraphQL::STRING_TYPE,
+ method: :project_slug,
+ null: false,
+ description: 'Slug of the project affected by the error'
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index 393948fcede..718770ebfbc 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -17,12 +17,35 @@ module Types
group.avatar_url(only_path: false)
end
+ field :share_with_group_lock, GraphQL::BOOLEAN_TYPE, null: true,
+ description: 'Indicates if sharing a project with another group within this group is prevented'
+
+ field :project_creation_level, GraphQL::STRING_TYPE, null: true, method: :project_creation_level_str,
+ description: 'The permission level required to create projects in the group'
+ field :subgroup_creation_level, GraphQL::STRING_TYPE, null: true, method: :subgroup_creation_level_str,
+ description: 'The permission level required to create subgroups within the group'
+
+ field :require_two_factor_authentication, GraphQL::BOOLEAN_TYPE, null: true,
+ description: 'Indicates if all users in this group are required to set up two-factor authentication'
+ field :two_factor_grace_period, GraphQL::INT_TYPE, null: true,
+ description: 'Time before two-factor authentication is enforced'
+
+ field :auto_devops_enabled, GraphQL::BOOLEAN_TYPE, null: true,
+ description: 'Indicates whether Auto DevOps is enabled for all projects within this group'
+
+ field :emails_disabled, GraphQL::BOOLEAN_TYPE, null: true,
+ description: 'Indicates if a group has email notifications disabled'
+
field :mentions_disabled, GraphQL::BOOLEAN_TYPE, null: true,
description: 'Indicates if a group is disabled from getting mentioned'
field :parent, GroupType, null: true,
description: 'Parent group',
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.parent_id).find }
+
+ field :milestones, Types::MilestoneType.connection_type, null: true,
+ description: 'Find milestones',
+ resolver: Resolvers::MilestoneResolver
end
end
diff --git a/app/graphql/types/milestone_state_enum.rb b/app/graphql/types/milestone_state_enum.rb
new file mode 100644
index 00000000000..032571ac88f
--- /dev/null
+++ b/app/graphql/types/milestone_state_enum.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module Types
+ class MilestoneStateEnum < BaseEnum
+ value 'active'
+ value 'closed'
+ end
+end
diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb
index 9c3afb28674..900f8c6f01d 100644
--- a/app/graphql/types/milestone_type.rb
+++ b/app/graphql/types/milestone_type.rb
@@ -3,25 +3,36 @@
module Types
class MilestoneType < BaseObject
graphql_name 'Milestone'
+ description 'Represents a milestone.'
+
+ present_using MilestonePresenter
authorize :read_milestone
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the milestone'
- field :description, GraphQL::STRING_TYPE, null: true,
- description: 'Description of the milestone'
+
field :title, GraphQL::STRING_TYPE, null: false,
description: 'Title of the milestone'
- field :state, GraphQL::STRING_TYPE, null: false,
+
+ field :description, GraphQL::STRING_TYPE, null: true,
+ description: 'Description of the milestone'
+
+ field :state, Types::MilestoneStateEnum, null: false,
description: 'State of the milestone'
+ field :web_path, GraphQL::STRING_TYPE, null: false, method: :milestone_path,
+ description: 'Web path of the milestone'
+
field :due_date, Types::TimeType, null: true,
description: 'Timestamp of the milestone due date'
+
field :start_date, Types::TimeType, null: true,
description: 'Timestamp of the milestone start date'
field :created_at, Types::TimeType, null: false,
description: 'Timestamp of milestone creation'
+
field :updated_at, Types::TimeType, null: false,
description: 'Timestamp of last milestone update'
end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 0a9c0143945..90e9e1ec0b9 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -4,13 +4,14 @@ module Types
class MutationType < BaseObject
include Gitlab::Graphql::MountMutation
- graphql_name "Mutation"
+ graphql_name 'Mutation'
mount_mutation Mutations::AwardEmojis::Add
mount_mutation Mutations::AwardEmojis::Remove
mount_mutation Mutations::AwardEmojis::Toggle
mount_mutation Mutations::Issues::SetConfidential
mount_mutation Mutations::Issues::SetDueDate
+ mount_mutation Mutations::Issues::Update
mount_mutation Mutations::MergeRequests::SetLabels
mount_mutation Mutations::MergeRequests::SetLocked
mount_mutation Mutations::MergeRequests::SetMilestone
@@ -20,11 +21,19 @@ module Types
mount_mutation Mutations::Notes::Create::Note, calls_gitaly: true
mount_mutation Mutations::Notes::Create::DiffNote, calls_gitaly: true
mount_mutation Mutations::Notes::Create::ImageDiffNote, calls_gitaly: true
- mount_mutation Mutations::Notes::Update
+ mount_mutation Mutations::Notes::Update::Note,
+ description: 'Updates a Note. If the body of the Note contains only quick actions, ' \
+ 'the Note will be destroyed during the update, and no Note will be ' \
+ 'returned'
+ mount_mutation Mutations::Notes::Update::ImageDiffNote,
+ description: 'Updates a DiffNote on an image (a `Note` where the `position.positionType` is `"image"`). ' \
+ 'If the body of the Note contains only quick actions, the Note will be ' \
+ 'destroyed during the update, and no Note will be returned'
mount_mutation Mutations::Notes::Destroy
mount_mutation Mutations::Todos::MarkDone
mount_mutation Mutations::Todos::Restore
mount_mutation Mutations::Todos::MarkAllDone
+ mount_mutation Mutations::Todos::RestoreMany
mount_mutation Mutations::Snippets::Destroy
mount_mutation Mutations::Snippets::Update
mount_mutation Mutations::Snippets::Create
diff --git a/app/graphql/types/notes/diff_position_type.rb b/app/graphql/types/notes/diff_position_type.rb
index 654562da0a7..cc00feba2e6 100644
--- a/app/graphql/types/notes/diff_position_type.rb
+++ b/app/graphql/types/notes/diff_position_type.rb
@@ -29,10 +29,10 @@ module Types
# Fields for image positions
field :x, GraphQL::INT_TYPE, null: true,
- description: 'X position on which the comment was made',
+ description: 'X position of the note',
resolve: -> (position, _args, _ctx) { position.x if position.on_image? }
field :y, GraphQL::INT_TYPE, null: true,
- description: 'Y position on which the comment was made',
+ description: 'Y position of the note',
resolve: -> (position, _args, _ctx) { position.y if position.on_image? }
field :width, GraphQL::INT_TYPE, null: true,
description: 'Total width of the image',
diff --git a/app/graphql/types/notes/update_diff_image_position_input_type.rb b/app/graphql/types/notes/update_diff_image_position_input_type.rb
new file mode 100644
index 00000000000..af99764f9f2
--- /dev/null
+++ b/app/graphql/types/notes/update_diff_image_position_input_type.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Types
+ module Notes
+ # InputType used for updateImageDiffNote mutation.
+ #
+ # rubocop: disable Graphql/AuthorizeTypes
+ class UpdateDiffImagePositionInputType < BaseInputObject
+ graphql_name 'UpdateDiffImagePositionInput'
+
+ argument :x, GraphQL::INT_TYPE,
+ required: false,
+ description: copy_field_description(Types::Notes::DiffPositionType, :x)
+
+ argument :y, GraphQL::INT_TYPE,
+ required: false,
+ description: copy_field_description(Types::Notes::DiffPositionType, :y)
+
+ argument :width, GraphQL::INT_TYPE,
+ required: false,
+ description: copy_field_description(Types::Notes::DiffPositionType, :width)
+
+ argument :height, GraphQL::INT_TYPE,
+ required: false,
+ description: copy_field_description(Types::Notes::DiffPositionType, :height)
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/permission_types/project.rb b/app/graphql/types/permission_types/project.rb
index 2879dbd2b5c..f773fce0c63 100644
--- a/app/graphql/types/permission_types/project.rb
+++ b/app/graphql/types/permission_types/project.rb
@@ -16,12 +16,13 @@ module Types
:create_deployment, :push_to_delete_protected_branch,
:admin_wiki, :admin_project, :update_pages,
:admin_remote_mirror, :create_label, :update_wiki, :destroy_wiki,
- :create_pages, :destroy_pages, :read_pages_content, :admin_operations
+ :create_pages, :destroy_pages, :read_pages_content, :admin_operations,
+ :read_merge_request
permission_field :create_snippet
def create_snippet
- Ability.allowed?(context[:current_user], :create_project_snippet, object)
+ Ability.allowed?(context[:current_user], :create_snippet, object)
end
end
end
diff --git a/app/graphql/types/permission_types/user.rb b/app/graphql/types/permission_types/user.rb
index dba4de2dacc..93d9787d58e 100644
--- a/app/graphql/types/permission_types/user.rb
+++ b/app/graphql/types/permission_types/user.rb
@@ -8,7 +8,7 @@ module Types
permission_field :create_snippet
def create_snippet
- Ability.allowed?(context[:current_user], :create_personal_snippet)
+ Ability.allowed?(context[:current_user], :create_snippet)
end
end
end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 5ece4926951..b44baa50955 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -173,6 +173,12 @@ module Types
null: true,
description: 'Snippets of the project',
resolver: Resolvers::Projects::SnippetsResolver
+
+ field :sentry_errors,
+ Types::ErrorTracking::SentryErrorCollectionType,
+ null: true,
+ description: 'Paginated collection of Sentry errors on the project',
+ resolver: Resolvers::ErrorTracking::SentryErrorCollectionResolver
end
end
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 199a6226c6d..e8f6eeff3e9 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -40,3 +40,5 @@ module Types
resolver: Resolvers::EchoResolver
end
end
+
+Types::QueryType.prepend_if_ee('EE::Types::QueryType')
diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb
index 3f780528945..c4d65174990 100644
--- a/app/graphql/types/snippet_type.rb
+++ b/app/graphql/types/snippet_type.rb
@@ -36,10 +36,6 @@ module Types
description: 'File Name of the snippet',
null: true
- field :content, GraphQL::STRING_TYPE,
- description: 'Content of the snippet',
- null: false
-
field :description, GraphQL::STRING_TYPE,
description: 'Description of the snippet',
null: true
@@ -64,6 +60,10 @@ module Types
description: 'Raw URL of the snippet',
null: false
+ field :blob, type: Types::Snippets::BlobType,
+ description: 'Snippet blob',
+ null: false
+
markdown_field :description_html, null: true, method: :description
end
end
diff --git a/app/graphql/types/snippets/blob_type.rb b/app/graphql/types/snippets/blob_type.rb
new file mode 100644
index 00000000000..feff5d20874
--- /dev/null
+++ b/app/graphql/types/snippets/blob_type.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Types
+ module Snippets
+ # rubocop: disable Graphql/AuthorizeTypes
+ class BlobType < BaseObject
+ graphql_name 'SnippetBlob'
+ description 'Represents the snippet blob'
+ present_using SnippetBlobPresenter
+
+ field :rich_data, GraphQL::STRING_TYPE,
+ description: 'Blob highlighted data',
+ null: true
+
+ field :plain_data, GraphQL::STRING_TYPE,
+ description: 'Blob plain highlighted data',
+ null: true
+
+ field :raw_path, GraphQL::STRING_TYPE,
+ description: 'Blob raw content endpoint path',
+ null: false
+
+ field :size, GraphQL::INT_TYPE,
+ description: 'Blob size',
+ null: false
+
+ field :binary, GraphQL::BOOLEAN_TYPE,
+ description: 'Shows whether the blob is binary',
+ method: :binary?,
+ null: false
+
+ field :name, GraphQL::STRING_TYPE,
+ description: 'Blob name',
+ null: true
+
+ field :path, GraphQL::STRING_TYPE,
+ description: 'Blob path',
+ null: true
+
+ field :simple_viewer, type: Types::Snippets::BlobViewerType,
+ description: 'Blob content simple viewer',
+ null: false
+
+ field :rich_viewer, type: Types::Snippets::BlobViewerType,
+ description: 'Blob content rich viewer',
+ null: true
+
+ field :mode, type: GraphQL::STRING_TYPE,
+ description: 'Blob mode',
+ null: true
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/snippets/blob_viewer_type.rb b/app/graphql/types/snippets/blob_viewer_type.rb
new file mode 100644
index 00000000000..3e653576d07
--- /dev/null
+++ b/app/graphql/types/snippets/blob_viewer_type.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Types
+ module Snippets
+ class BlobViewerType < BaseObject # rubocop:disable Graphql/AuthorizeTypes
+ graphql_name 'SnippetBlobViewer'
+ description 'Represents how the blob content should be displayed'
+
+ field :type, Types::BlobViewers::TypeEnum,
+ description: 'Type of blob viewer',
+ null: false
+
+ field :load_async, GraphQL::BOOLEAN_TYPE,
+ description: 'Shows whether the blob content is loaded async',
+ null: false
+
+ field :collapsed, GraphQL::BOOLEAN_TYPE,
+ description: 'Shows whether the blob should be displayed collapsed',
+ method: :collapsed?,
+ null: false
+
+ field :too_large, GraphQL::BOOLEAN_TYPE,
+ description: 'Shows whether the blob too large to be displayed',
+ method: :too_large?,
+ null: false
+
+ field :render_error, GraphQL::STRING_TYPE,
+ description: 'Error rendering the blob content',
+ null: true
+
+ field :file_type, GraphQL::STRING_TYPE,
+ description: 'Content file type',
+ method: :partial_name,
+ null: false
+
+ field :loading_partial_name, GraphQL::STRING_TYPE,
+ description: 'Loading partial name',
+ null: false
+ end
+ end
+end
diff --git a/app/helpers/analytics_navbar_helper.rb b/app/helpers/analytics_navbar_helper.rb
new file mode 100644
index 00000000000..021b9bb10cd
--- /dev/null
+++ b/app/helpers/analytics_navbar_helper.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module AnalyticsNavbarHelper
+ class NavbarSubItem
+ attr_reader :title, :path, :link, :link_to_options
+
+ def initialize(title:, path:, link:, link_to_options: {})
+ @title = title
+ @path = path
+ @link = link
+ @link_to_options = link_to_options.merge(title: title)
+ end
+ end
+
+ def project_analytics_navbar_links(project, current_user)
+ [
+ cycle_analytics_navbar_link(project, current_user),
+ repository_analytics_navbar_link(project, current_user),
+ ci_cd_analytics_navbar_link(project, current_user)
+ ].compact
+ end
+
+ def group_analytics_navbar_links(group, current_user)
+ []
+ end
+
+ private
+
+ def navbar_sub_item(args)
+ NavbarSubItem.new(args)
+ end
+
+ def cycle_analytics_navbar_link(project, current_user)
+ return unless Feature.enabled?(:analytics_pages_under_project_analytics_sidebar, project, default_enabled: true)
+ return unless project_nav_tab?(:cycle_analytics)
+
+ navbar_sub_item(
+ title: _('Value Stream Analytics'),
+ path: 'cycle_analytics#show',
+ link: project_cycle_analytics_path(project),
+ link_to_options: { class: 'shortcuts-project-cycle-analytics' }
+ )
+ end
+
+ def repository_analytics_navbar_link(project, current_user)
+ return if Feature.disabled?(:analytics_pages_under_project_analytics_sidebar, project, default_enabled: true)
+ return if project.empty_repo?
+
+ navbar_sub_item(
+ title: _('Repository Analytics'),
+ path: 'graphs#charts',
+ link: charts_project_graph_path(project, current_ref),
+ link_to_options: { class: 'shortcuts-repository-charts' }
+ )
+ end
+
+ def ci_cd_analytics_navbar_link(project, current_user)
+ return unless Feature.enabled?(:analytics_pages_under_project_analytics_sidebar, project, default_enabled: true)
+ return unless project_nav_tab?(:pipelines)
+ return unless project.feature_available?(:builds, current_user) || !project.empty_repo?
+
+ navbar_sub_item(
+ title: _('CI / CD Analytics'),
+ path: 'pipelines#charts',
+ link: charts_project_pipelines_path(project)
+ )
+ end
+end
+
+AnalyticsNavbarHelper.prepend_if_ee('EE::AnalyticsNavbarHelper')
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 0e14db6ddbf..f96c26b428c 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -119,6 +119,17 @@ module ApplicationSettingsHelper
options_for_select(options, selected)
end
+ def repository_storages_options_json
+ options = Gitlab.config.repositories.storages.map do |name, storage|
+ {
+ label: "#{name} - #{storage['gitaly_address']}",
+ value: name
+ }
+ end
+
+ options.to_json
+ end
+
def external_authorization_description
_("If enabled, access to projects will be validated on an external service"\
" using their classification label.")
@@ -351,10 +362,10 @@ module ApplicationSettingsHelper
status_delete_self_monitoring_project_admin_application_settings_path,
'self_monitoring_project_exists' =>
- Gitlab::CurrentSettings.instance_administration_project.present?.to_s,
+ Gitlab::CurrentSettings.self_monitoring_project.present?.to_s,
'self_monitoring_project_full_path' =>
- Gitlab::CurrentSettings.instance_administration_project&.full_path
+ Gitlab::CurrentSettings.self_monitoring_project&.full_path
}
end
end
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index a9c4cfe7dcc..e8d3d5f62cb 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -89,7 +89,17 @@ module AuthHelper
def enabled_button_based_providers
disabled_providers = Gitlab::CurrentSettings.disabled_oauth_sign_in_sources || []
- button_based_providers.map(&:to_s) - disabled_providers
+ providers = button_based_providers.map(&:to_s) - disabled_providers
+ providers.sort_by do |provider|
+ case provider
+ when 'google_oauth2'
+ 0
+ when 'github'
+ 1
+ else
+ 2
+ end
+ end
end
def button_based_providers_enabled?
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index 733d21daec1..68dbc5b65d1 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -122,6 +122,13 @@ module AvatarsHelper
else
source_identicon(source, options)
end
+
+ rescue GRPC::Unavailable, GRPC::DeadlineExceeded => e
+ # Handle Gitaly connection issues gracefully
+ Gitlab::ErrorTracking
+ .track_exception(e, source_type: source.class.name, source_id: source.id)
+
+ source_identicon(source, options)
end
def source_identicon(source, options = {})
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index c9fb28d0299..77a320f8925 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -27,7 +27,7 @@ module BlobHelper
"#{current_user.namespace.full_path}/#{project.path}"
end
- segments = [ide_path, 'project', project_path, 'edit', ref]
+ segments = [ide_path, 'project', project_path, 'edit', encode_ide_path(ref)]
segments.concat(['-', encode_ide_path(path)]) if path.present?
File.join(segments)
end
@@ -47,7 +47,7 @@ module BlobHelper
def edit_blob_button(project = @project, ref = @ref, path = @path, options = {})
return unless blob = readable_blob(options, path, project, ref)
- common_classes = "btn btn-primary js-edit-blob #{options[:extra_class]}"
+ common_classes = "btn btn-primary js-edit-blob ml-2 #{options[:extra_class]}"
edit_button_tag(blob,
common_classes,
@@ -62,7 +62,7 @@ module BlobHelper
return unless blob = readable_blob(options, path, project, ref)
edit_button_tag(blob,
- 'btn btn-inverted btn-primary ide-edit-button',
+ 'btn btn-inverted btn-primary ide-edit-button ml-2',
_('Web IDE'),
ide_edit_path(project, ref, path, options),
project,
diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb
index b95fd8800c0..34e65c322c6 100644
--- a/app/helpers/broadcast_messages_helper.rb
+++ b/app/helpers/broadcast_messages_helper.rb
@@ -6,19 +6,16 @@ module BroadcastMessagesHelper
end
def current_broadcast_notification_message
- BroadcastMessage.current_notification_messages(request.path).last
+ not_hidden_messages = BroadcastMessage.current_notification_messages(request.path).select do |message|
+ cookies["hide_broadcast_notification_message_#{message.id}"].blank?
+ end
+ not_hidden_messages.last
end
def broadcast_message(message, opts = {})
return unless message.present?
- classes = "broadcast-#{message.broadcast_type}-message #{opts[:preview] && 'preview'}"
-
- content_tag :div, dir: 'auto', class: classes, style: broadcast_message_style(message) do
- concat sprite_icon('bullhorn', size: 16, css_class: 'vertical-align-text-top')
- concat ' '
- concat render_broadcast_message(message)
- end
+ render "shared/broadcast_message", { message: message, opts: opts }
end
def broadcast_message_style(broadcast_message)
diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb
index 610d823dd3c..e1aed5393ea 100644
--- a/app/helpers/button_helper.rb
+++ b/app/helpers/button_helper.rb
@@ -53,7 +53,7 @@ module ButtonHelper
}
content_tag :button, button_attributes do
- concat(sprite_icon('duplicate')) unless hide_button_icon
+ concat(sprite_icon('copy-to-clipboard')) unless hide_button_icon
concat(button_text)
end
end
diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb
index f55acad8517..80bf765f3a4 100644
--- a/app/helpers/clusters_helper.rb
+++ b/app/helpers/clusters_helper.rb
@@ -17,17 +17,6 @@ module ClustersHelper
end
end
- def new_cluster_partial(provider: nil)
- case provider
- when 'aws'
- 'clusters/clusters/aws/new'
- when 'gcp'
- 'clusters/clusters/gcp/new'
- else
- 'clusters/clusters/cloud_providers/cloud_provider_selector'
- end
- end
-
def render_gcp_signup_offer
return if Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers?
return unless show_gcp_signup_offer?
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index d58f634425b..ace8bae03ac 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -18,7 +18,7 @@ module CommitsHelper
end
def commit_to_html(commit, ref, project)
- render 'projects/commits/commit',
+ render 'projects/commits/commit.html',
commit: commit,
ref: ref,
project: project
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index 620a63fdc46..4c3c4931387 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -29,6 +29,8 @@ module DiffHelper
if action_name == 'diff_for_path'
options[:expanded] = true
options[:paths] = params.values_at(:old_path, :new_path)
+ elsif action_name == 'show'
+ options[:include_context_commits] = true unless @project.context_commits_enabled?
end
options
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index 993c18f9229..fd330d4efd9 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
module EnvironmentsHelper
+ include ActionView::Helpers::AssetUrlHelper
prepend_if_ee('::EE::EnvironmentsHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule
def environments_list_data
@@ -21,7 +22,7 @@ module EnvironmentsHelper
{
"settings-path" => edit_project_service_path(project, 'prometheus'),
"clusters-path" => project_clusters_path(project),
- "current-environment-name": environment.name,
+ "current-environment-name" => environment.name,
"documentation-path" => help_page_path('administration/monitoring/prometheus/index.md'),
"empty-getting-started-svg-path" => image_path('illustrations/monitoring/getting_started.svg'),
"empty-loading-svg-path" => image_path('illustrations/monitoring/loading.svg'),
@@ -33,7 +34,6 @@ module EnvironmentsHelper
"dashboard-endpoint" => metrics_dashboard_project_environment_path(project, environment, format: :json),
"deployments-endpoint" => project_environment_deployments_path(project, environment, format: :json),
"default-branch" => project.default_branch,
- "environments-endpoint": project_environments_path(project, format: :json),
"project-path" => project_path(project),
"tags-path" => project_tags_path(project),
"has-metrics" => "#{environment.has_metrics?}",
diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb
index 62be591ec47..1b36f60c316 100644
--- a/app/helpers/explore_helper.rb
+++ b/app/helpers/explore_helper.rb
@@ -19,6 +19,18 @@ module ExploreHelper
request_path_with_options(options)
end
+ def filter_audit_path(options = {})
+ exist_opts = {
+ entity_type: params[:entity_type],
+ entity_id: params[:entity_id],
+ created_before: params[:created_before],
+ created_after: params[:created_after],
+ sort: params[:sort]
+ }
+ options = exist_opts.merge(options).delete_if { |key, value| value.blank? }
+ request_path_with_options(options)
+ end
+
def filter_groups_path(options = {})
request_path_with_options(options)
end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 6ddcbf61090..661197e84ae 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -7,7 +7,6 @@ module GroupsHelper
groups#details
groups#activity
groups#subgroups
- analytics#show
]
end
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index d6e466d4678..a0228c6bd94 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -4,7 +4,7 @@ require 'nokogiri'
module MarkupHelper
include ActionView::Helpers::TextHelper
- include ::Gitlab::ActionViewOutput::Context
+ include ActionView::Context
def plain?(filename)
Gitlab::MarkupHelper.plain?(filename)
@@ -76,13 +76,14 @@ module MarkupHelper
# +max_chars+ limit. If the length limit falls within a tag's contents, then
# the tag contents are truncated without removing the closing tag.
def first_line_in_markdown(object, attribute, max_chars = nil, options = {})
- md = markdown_field(object, attribute, options)
+ md = markdown_field(object, attribute, options.merge(post_process: false))
return unless md.present?
tags = %w(a gl-emoji b pre code p span)
tags << 'img' if options[:allow_images]
text = truncate_visible(md, max_chars || md.length)
+ text = prepare_for_rendering(text, markdown_field_render_context(object, attribute, options))
text = sanitize(
text,
tags: tags,
@@ -107,15 +108,12 @@ module MarkupHelper
def markdown_field(object, field, context = {})
object = object.for_display if object.respond_to?(:for_display)
- redacted_field_html = object.try(:"redacted_#{field}_html")
-
return '' unless object.present?
- return redacted_field_html if redacted_field_html
- html = Banzai.render_field(object, field, context)
- context.reverse_merge!(object.banzai_render_context(field)) if object.respond_to?(:banzai_render_context)
+ redacted_field_html = object.try(:"redacted_#{field}_html")
+ return redacted_field_html if redacted_field_html
- prepare_for_rendering(html, context)
+ render_markdown_field(object, field, context)
end
def markup(file_name, text, context = {})
@@ -155,7 +153,7 @@ module MarkupHelper
other_markup_unsafe(file_name, text, context)
end
rescue StandardError => e
- Gitlab::ErrorTracking.track_exception(e, project_id: @project&.id, file_name: file_name, context: context)
+ Gitlab::ErrorTracking.track_exception(e, project_id: @project&.id, file_name: file_name)
simple_format(text)
end
@@ -277,6 +275,23 @@ module MarkupHelper
Gitlab::OtherMarkup.render(file_name, text, context)
end
+ def render_markdown_field(object, field, context = {})
+ post_process = context.delete(:post_process)
+ post_process = true if post_process.nil?
+
+ html = Banzai.render_field(object, field, context)
+
+ return html unless post_process
+
+ prepare_for_rendering(html, markdown_field_render_context(object, field, context))
+ end
+
+ def markdown_field_render_context(object, field, base_context = {})
+ return base_context unless object.respond_to?(:banzai_render_context)
+
+ base_context.reverse_merge(object.banzai_render_context(field))
+ end
+
def prepare_for_rendering(html, context = {})
return '' unless html.present?
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index 6a271e93cd9..8a79217c929 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -63,6 +63,10 @@ module PreferencesHelper
Gitlab::ColorSchemes.for_user(current_user).css_class
end
+ def user_tab_width
+ Gitlab::TabWidth.css_class_for_user(current_user)
+ end
+
def language_choices
Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] }
end
diff --git a/app/helpers/projects/error_tracking_helper.rb b/app/helpers/projects/error_tracking_helper.rb
index ed5c7640ec1..5be4f67bde8 100644
--- a/app/helpers/projects/error_tracking_helper.rb
+++ b/app/helpers/projects/error_tracking_helper.rb
@@ -22,8 +22,6 @@ module Projects::ErrorTrackingHelper
{
'issue-id' => issue_id,
'project-path' => project.full_path,
- 'list-path' => project_error_tracking_index_path(project),
- 'issue-details-path' => details_project_error_tracking_index_path(*opts),
'issue-update-path' => update_project_error_tracking_index_path(*opts),
'project-issues-path' => project_issues_path(project),
'issue-stack-trace-path' => stack_trace_project_error_tracking_index_path(*opts)
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index e2173140a08..023790f7d87 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -3,6 +3,11 @@
module ProjectsHelper
prepend_if_ee('::EE::ProjectsHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule
+ def project_incident_management_setting
+ @project_incident_management_setting ||= @project.incident_management_setting ||
+ @project.build_incident_management_setting
+ end
+
def link_to_project(project)
link_to namespace_project_path(namespace_id: project.namespace, id: project), title: h(project.name) do
title = content_tag(:span, project.name, class: 'project-name')
@@ -403,6 +408,10 @@ module ProjectsHelper
nav_tabs << :operations
end
+ if can?(current_user, :read_cycle_analytics, project)
+ nav_tabs << :cycle_analytics
+ end
+
tab_ability_map.each do |tab, ability|
if can?(current_user, ability, project)
nav_tabs << tab
@@ -425,7 +434,7 @@ module ProjectsHelper
{
environments: :read_environment,
milestones: :read_milestone,
- snippets: :read_project_snippet,
+ snippets: :read_snippet,
settings: :admin_project,
builds: :read_build,
clusters: :read_cluster,
@@ -443,7 +452,7 @@ module ProjectsHelper
blobs: :download_code,
commits: :download_code,
merge_requests: :read_merge_request,
- notes: [:read_merge_request, :download_code, :read_issue, :read_project_snippet],
+ notes: [:read_merge_request, :download_code, :read_issue, :read_snippet],
members: :read_project_member
)
end
@@ -643,7 +652,6 @@ module ProjectsHelper
projects#show
projects#activity
releases#index
- cycle_analytics#show
]
end
@@ -700,10 +708,19 @@ module ProjectsHelper
end
def vue_file_list_enabled?
- Feature.enabled?(:vue_file_list, @project)
+ Feature.enabled?(:vue_file_list, @project, default_enabled: true)
+ end
+
+ def native_code_navigation_enabled?(project)
+ Feature.enabled?(:code_navigation, project)
end
def show_visibility_confirm_modal?(project)
project.unlink_forks_upon_visibility_decrease_enabled? && project.visibility_level > Gitlab::VisibilityLevel::PRIVATE && project.forks_count > 0
end
+
+ def settings_container_registry_expiration_policy_available?(project)
+ Gitlab.config.registry.enabled &&
+ can?(current_user, :destroy_container_image, project)
+ end
end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 9a5c5f274a0..e478f76818f 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -86,19 +86,6 @@ module SearchHelper
}).html_safe
end
- def find_project_for_result_blob(projects, result)
- @project
- end
-
- # Used in EE
- def blob_projects(results)
- nil
- end
-
- def parse_search_result(result)
- result
- end
-
# Overriden in EE
def search_blob_title(project, path)
path
diff --git a/app/helpers/sidekiq_helper.rb b/app/helpers/sidekiq_helper.rb
index 6326d98461e..07d83b8d850 100644
--- a/app/helpers/sidekiq_helper.rb
+++ b/app/helpers/sidekiq_helper.rb
@@ -5,7 +5,7 @@ module SidekiqHelper
(?<pid>\d+)\s+
(?<cpu>[\d\.,]+)\s+
(?<mem>[\d\.,]+)\s+
- (?<state>[DIEKNRSTVWXZNLpsl\+<>/\d]+)\s+
+ (?<state>[DIEKNRSTVWXZLpsl\+<>/\d]+)\s+
(?<start>.+?)\s+
(?<command>(?:ruby\d+:\s+)?sidekiq.*\].*)
\z}x.freeze
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 33f3bb0b749..3e448087db0 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -207,6 +207,13 @@ module SortingHelper
}.merge(issuable_sort_option_overrides)
end
+ def audit_logs_sort_order_hash
+ {
+ sort_value_recently_created => sort_title_recently_created,
+ sort_value_oldest_created => sort_title_oldest_created
+ }
+ end
+
def issuable_sort_option_title(sort_value)
sort_value = issuable_sort_option_overrides[sort_value] || sort_value
diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb
index 4b83988e8bb..32c613ab4ad 100644
--- a/app/helpers/submodule_helper.rb
+++ b/app/helpers/submodule_helper.rb
@@ -7,9 +7,7 @@ module SubmoduleHelper
# links to files listing for submodule if submodule is a project on this server
def submodule_links(submodule_item, ref = nil, repository = @repository)
- url = repository.submodule_url_for(ref, submodule_item.path)
-
- submodule_links_for_url(submodule_item.id, url, repository)
+ repository.submodule_links.for(submodule_item, ref)
end
def submodule_links_for_url(submodule_item_id, url, repository)
@@ -41,9 +39,9 @@ module SubmoduleHelper
elsif relative_self_url?(url)
relative_self_links(url, submodule_item_id, repository.project)
elsif github_dot_com_url?(url)
- standard_links('github.com', namespace, project, submodule_item_id)
+ github_com_tree_links(namespace, project, submodule_item_id)
elsif gitlab_dot_com_url?(url)
- standard_links('gitlab.com', namespace, project, submodule_item_id)
+ gitlab_com_tree_links(namespace, project, submodule_item_id)
else
[sanitize_submodule_url(url), nil]
end
@@ -75,8 +73,13 @@ module SubmoduleHelper
url.start_with?('../', './')
end
- def standard_links(host, namespace, project, commit)
- base = ['https://', host, '/', namespace, '/', project].join('')
+ def gitlab_com_tree_links(namespace, project, commit)
+ base = ['https://gitlab.com/', namespace, '/', project].join('')
+ [base, [base, '/-/tree/', commit].join('')]
+ end
+
+ def github_com_tree_links(namespace, project, commit)
+ base = ['https://github.com/', namespace, '/', project].join('')
[base, [base, '/tree/', commit].join('')]
end
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index 51cbe93513d..05d698a6d99 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -2,6 +2,7 @@
module SystemNoteHelper
ICON_NAMES_BY_ACTION = {
+ 'cherry_pick' => 'link',
'commit' => 'commit',
'description' => 'pencil-square',
'merge' => 'git-merge',
diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb
index 58edb327be0..0f156003a01 100644
--- a/app/helpers/tab_helper.rb
+++ b/app/helpers/tab_helper.rb
@@ -12,6 +12,7 @@ module TabHelper
# :action - One or more action names to check (optional).
# :path - A shorthand path, such as 'dashboard#index', to check (optional).
# :html_options - Extra options to be passed to the list element (optional).
+ # :unless - Callable object to skip rendering the 'active' class on `li` element (optional).
# block - An optional block that will become the contents of the returned
# `li` element.
#
@@ -56,6 +57,14 @@ module TabHelper
# nav_link(path: 'admin/appearances#show') { "Hello"}
# # => '<li class="active">Hello</li>'
#
+ # # Shorthand path + unless
+ # # Add `active` class when TreeController is requested, except the `index` action.
+ # nav_link(controller: 'tree', unless: -> { action_name?('index') }) { "Hello" }
+ # # => '<li class="active">Hello</li>'
+ #
+ # # When `TreeController#index` is requested
+ # # => '<li>Hello</li>'
+ #
# Returns a list item element String
def nav_link(options = {}, &block)
klass = active_nav_link?(options) ? 'active' : ''
@@ -73,6 +82,8 @@ module TabHelper
end
def active_nav_link?(options)
+ return false if options[:unless]&.call
+
if path = options.delete(:path)
unless path.respond_to?(:each)
path = [path]
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index af1919eeb40..0b50b8b1130 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -38,13 +38,13 @@ module TreeHelper
# many paths, as with a repository tree that has thousands of items.
def fast_project_blob_path(project, blob_path)
ActionDispatch::Journey::Router::Utils.escape_path(
- File.join(relative_url_root, project.path_with_namespace, 'blob', blob_path)
+ File.join(relative_url_root, project.path_with_namespace, '-', 'blob', blob_path)
)
end
def fast_project_tree_path(project, tree_path)
ActionDispatch::Journey::Router::Utils.escape_path(
- File.join(relative_url_root, project.path_with_namespace, 'tree', tree_path)
+ File.join(relative_url_root, project.path_with_namespace, '-', 'tree', tree_path)
)
end
diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb
index b3eee25674b..ab691916706 100644
--- a/app/helpers/user_callouts_helper.rb
+++ b/app/helpers/user_callouts_helper.rb
@@ -22,6 +22,9 @@ module UserCalloutsHelper
def render_dashboard_gold_trial(user)
end
+ def render_account_recovery_regular_check
+ end
+
def show_suggest_popover?
!user_dismissed?(SUGGEST_POPOVER_DISMISSED)
end
@@ -32,8 +35,10 @@ module UserCalloutsHelper
private
- def user_dismissed?(feature_name)
- current_user&.callouts&.find_by(feature_name: UserCallout.feature_names[feature_name])
+ def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil)
+ return false unless current_user
+
+ current_user.dismissed_callout?(feature_name: feature_name, ignore_dismissal_earlier_than: ignore_dismissal_earlier_than)
end
end
diff --git a/app/mailers/abuse_report_mailer.rb b/app/mailers/abuse_report_mailer.rb
index e0aa66e6de3..0f2f63b43f5 100644
--- a/app/mailers/abuse_report_mailer.rb
+++ b/app/mailers/abuse_report_mailer.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class AbuseReportMailer < BaseMailer
+class AbuseReportMailer < ApplicationMailer
layout 'empty_mailer'
helper EmailsHelper
diff --git a/app/mailers/base_mailer.rb b/app/mailers/application_mailer.rb
index 5fd209c4761..e0c95370072 100644
--- a/app/mailers/base_mailer.rb
+++ b/app/mailers/application_mailer.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class BaseMailer < ActionMailer::Base
+class ApplicationMailer < ActionMailer::Base
around_action :render_with_default_locale
helper ApplicationHelper
diff --git a/app/mailers/email_rejection_mailer.rb b/app/mailers/email_rejection_mailer.rb
index d743533b1bc..25721658285 100644
--- a/app/mailers/email_rejection_mailer.rb
+++ b/app/mailers/email_rejection_mailer.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class EmailRejectionMailer < BaseMailer
+class EmailRejectionMailer < ApplicationMailer
layout 'empty_mailer'
helper EmailsHelper
diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb
index de70d0073b3..6dd4ccb510a 100644
--- a/app/mailers/emails/notes.rb
+++ b/app/mailers/emails/notes.rb
@@ -26,19 +26,17 @@ module Emails
mail_answer_note_thread(@merge_request, @note, note_thread_options(recipient_id, reason))
end
- def note_project_snippet_email(recipient_id, note_id, reason = nil)
+ def note_snippet_email(recipient_id, note_id, reason = nil)
setup_note_mail(note_id, recipient_id)
-
@snippet = @note.noteable
- @target_url = project_snippet_url(*note_target_url_options)
- mail_answer_note_thread(@snippet, @note, note_thread_options(recipient_id, reason))
- end
- def note_personal_snippet_email(recipient_id, note_id, reason = nil)
- setup_note_mail(note_id, recipient_id)
+ case @snippet
+ when ProjectSnippet
+ @target_url = project_snippet_url(*note_target_url_options)
+ when Snippet
+ @target_url = gitlab_snippet_url(@note.noteable)
+ end
- @snippet = @note.noteable
- @target_url = gitlab_snippet_url(@note.noteable)
mail_answer_note_thread(@snippet, @note, note_thread_options(recipient_id, reason))
end
diff --git a/app/mailers/emails/pipelines.rb b/app/mailers/emails/pipelines.rb
index 95bb52d8f97..773b9fead3a 100644
--- a/app/mailers/emails/pipelines.rb
+++ b/app/mailers/emails/pipelines.rb
@@ -15,7 +15,13 @@ module Emails
def pipeline_mail(pipeline, recipients, status)
@project = pipeline.project
@pipeline = pipeline
- @merge_request = pipeline.all_merge_requests.first
+
+ @merge_request = if pipeline.merge_request?
+ pipeline.merge_request
+ else
+ pipeline.merge_requests_as_head_pipeline.first
+ end
+
add_headers
# We use bcc here because we don't want to generate these emails for a
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index 92939136de2..49eacc44519 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Notify < BaseMailer
+class Notify < ApplicationMailer
include ActionDispatch::Routing::PolymorphicRoutes
include GitlabRoutingHelper
include EmailsHelper
diff --git a/app/mailers/repository_check_mailer.rb b/app/mailers/repository_check_mailer.rb
index aa56ba1828b..b8f990f26c8 100644
--- a/app/mailers/repository_check_mailer.rb
+++ b/app/mailers/repository_check_mailer.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class RepositoryCheckMailer < BaseMailer
+class RepositoryCheckMailer < ApplicationMailer
# rubocop: disable CodeReuse/ActiveRecord
layout 'empty_mailer'
diff --git a/app/models/ability.rb b/app/models/ability.rb
index 1466407d0d1..671a92632d5 100644
--- a/app/models/ability.rb
+++ b/app/models/ability.rb
@@ -24,7 +24,7 @@ class Ability
# read the given snippet.
def users_that_can_read_personal_snippet(users, snippet)
DeclarativePolicy.subject_scope do
- users.select { |u| allowed?(u, :read_personal_snippet, snippet) }
+ users.select { |u| allowed?(u, :read_snippet, snippet) }
end
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 10d15e84b8d..ddd43311d9b 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -10,7 +10,9 @@ class ApplicationSetting < ApplicationRecord
add_authentication_token_field :health_check_access_token
add_authentication_token_field :static_objects_external_storage_auth_token
- belongs_to :instance_administration_project, class_name: "Project"
+ belongs_to :self_monitoring_project, class_name: "Project", foreign_key: 'instance_administration_project_id'
+ alias_attribute :self_monitoring_project_id, :instance_administration_project_id
+
belongs_to :instance_administrators_group, class_name: "Group"
# Include here so it can override methods from
@@ -142,7 +144,7 @@ class ApplicationSetting < ApplicationRecord
if: :auto_devops_enabled?
validates :enabled_git_access_protocol,
- inclusion: { in: %w(ssh http), allow_blank: true, allow_nil: true }
+ inclusion: { in: %w(ssh http), allow_blank: true }
validates :domain_blacklist,
presence: { message: 'Domain blacklist cannot be empty if Blacklist is enabled.' },
diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb
index 06a607b75a4..03eb7462ece 100644
--- a/app/models/audit_event.rb
+++ b/app/models/audit_event.rb
@@ -13,6 +13,8 @@ class AuditEvent < ApplicationRecord
scope :by_entity_type, -> (entity_type) { where(entity_type: entity_type) }
scope :by_entity_id, -> (entity_id) { where(entity_id: entity_id) }
+ scope :order_by_id_desc, -> { order(id: :desc) }
+ scope :order_by_id_asc, -> { order(id: :asc) }
after_initialize :initialize_details
diff --git a/app/models/blob.rb b/app/models/blob.rb
index 42ee00bc196..d8282c918b7 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -65,7 +65,10 @@ class Blob < SimpleDelegator
BlobViewer::YarnLock
].freeze
- attr_reader :project
+ attr_reader :container
+
+ delegate :repository, to: :container, allow_nil: true
+ delegate :project, to: :repository, allow_nil: true
# Wrap a Gitlab::Git::Blob object, or return nil when given nil
#
@@ -77,22 +80,22 @@ class Blob < SimpleDelegator
#
# blob = Blob.decorate(nil)
# puts "truthy" if blob # No output
- def self.decorate(blob, project = nil)
+ def self.decorate(blob, container = nil)
return if blob.nil?
- new(blob, project)
+ new(blob, container)
end
- def self.lazy(project, commit_id, path)
- BatchLoader.for([commit_id, path]).batch(key: project.repository) do |items, loader, args|
- args[:key].blobs_at(items).each do |blob|
+ def self.lazy(container, commit_id, path, blob_size_limit: Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE)
+ BatchLoader.for([commit_id, path]).batch(key: container.repository) do |items, loader, args|
+ args[:key].blobs_at(items, blob_size_limit: blob_size_limit).each do |blob|
loader.call([blob.commit_id, blob.path], blob) if blob
end
end
end
- def initialize(blob, project = nil)
- @project = project
+ def initialize(blob, container = nil)
+ @container = container
super(blob)
end
@@ -116,7 +119,7 @@ class Blob < SimpleDelegator
def load_all_data!
# Endpoint needed: https://gitlab.com/gitlab-org/gitaly/issues/756
Gitlab::GitalyClient.allow_n_plus_1_calls do
- super(project.repository) if project
+ super(repository) if container
end
end
diff --git a/app/models/board.rb b/app/models/board.rb
index 38bbb550044..a57d101b30a 100644
--- a/app/models/board.rb
+++ b/app/models/board.rb
@@ -11,7 +11,10 @@ class Board < ApplicationRecord
validates :group, presence: true, unless: :project
scope :with_associations, -> { preload(:destroyable_lists) }
- scope :order_by_name_asc, -> { order(arel_table[:name].lower.asc) }
+
+ # Sort by case-insensitive name, then ascending ids. This ensures that we will always
+ # get the same list/first board no matter how many other boards are named the same
+ scope :order_by_name_asc, -> { order(arel_table[:name].lower.asc).order(id: :asc) }
scope :first_board, -> { where(id: self.order_by_name_asc.limit(1).select(:id)) }
def project_needed?
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index e6d41dd2779..26997d17816 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -4,19 +4,91 @@ module Ci
class Bridge < Ci::Processable
include Ci::Contextable
include Ci::PipelineDelegator
+ include Ci::Metadatable
include Importable
include AfterCommitQueue
include HasRef
- include Gitlab::Utils::StrongMemoize
+
+ InvalidBridgeTypeError = Class.new(StandardError)
belongs_to :project
belongs_to :trigger_request
+ has_many :sourced_pipelines, class_name: "::Ci::Sources::Pipeline",
+ foreign_key: :source_job_id
+
validates :ref, presence: true
+ # rubocop:disable Cop/ActiveRecordSerialize
+ serialize :options
+ serialize :yaml_variables, ::Gitlab::Serializer::Ci::Variables
+ # rubocop:enable Cop/ActiveRecordSerialize
+
+ state_machine :status do
+ after_transition created: :pending do |bridge|
+ next unless bridge.downstream_project
+
+ bridge.run_after_commit do
+ bridge.schedule_downstream_pipeline!
+ end
+ end
+
+ event :manual do
+ transition all => :manual
+ end
+
+ event :scheduled do
+ transition all => :scheduled
+ end
+ end
+
def self.retry(bridge, current_user)
raise NotImplementedError
end
+ def schedule_downstream_pipeline!
+ raise InvalidBridgeTypeError unless downstream_project
+
+ ::Ci::CreateCrossProjectPipelineWorker.perform_async(self.id)
+ end
+
+ def inherit_status_from_downstream!(pipeline)
+ case pipeline.status
+ when 'success'
+ self.success!
+ when 'failed', 'canceled', 'skipped'
+ self.drop!
+ else
+ false
+ end
+ end
+
+ def downstream_pipeline_params
+ return child_params if triggers_child_pipeline?
+ return cross_project_params if downstream_project.present?
+
+ {}
+ end
+
+ def downstream_project
+ strong_memoize(:downstream_project) do
+ if downstream_project_path
+ ::Project.find_by_full_path(downstream_project_path)
+ elsif triggers_child_pipeline?
+ project
+ end
+ end
+ end
+
+ def downstream_project_path
+ strong_memoize(:downstream_project_path) do
+ options&.dig(:trigger, :project)
+ end
+ end
+
+ def triggers_child_pipeline?
+ yaml_for_downstream.present?
+ end
+
def tags
[:bridge]
end
@@ -55,7 +127,69 @@ module Ci
end
def yaml_for_downstream
- nil
+ strong_memoize(:yaml_for_downstream) do
+ includes = options&.dig(:trigger, :include)
+ YAML.dump('include' => includes) if includes
+ end
+ end
+
+ def target_ref
+ branch = options&.dig(:trigger, :branch)
+ return unless branch
+
+ scoped_variables.to_runner_variables.yield_self do |all_variables|
+ ::ExpandVariables.expand(branch, all_variables)
+ end
+ end
+
+ def dependent?
+ strong_memoize(:dependent) do
+ options&.dig(:trigger, :strategy) == 'depend'
+ end
+ end
+
+ def downstream_variables
+ variables = scoped_variables.concat(pipeline.persisted_variables)
+
+ variables.to_runner_variables.yield_self do |all_variables|
+ yaml_variables.to_a.map do |hash|
+ { key: hash[:key], value: ::ExpandVariables.expand(hash[:value], all_variables) }
+ end
+ end
+ end
+
+ private
+
+ def cross_project_params
+ {
+ project: downstream_project,
+ source: :pipeline,
+ target_revision: {
+ ref: target_ref || downstream_project.default_branch
+ },
+ execute_params: { ignore_skip_ci: true }
+ }
+ end
+
+ def child_params
+ parent_pipeline = pipeline
+
+ {
+ project: project,
+ source: :parent_pipeline,
+ target_revision: {
+ ref: parent_pipeline.ref,
+ checkout_sha: parent_pipeline.sha,
+ before: parent_pipeline.before_sha,
+ source_sha: parent_pipeline.source_sha,
+ target_sha: parent_pipeline.target_sha
+ },
+ execute_params: {
+ ignore_skip_ci: true,
+ bridge: self,
+ merge_request: parent_pipeline.merge_request
+ }
+ }
end
end
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 369a793f3d5..e95e2c538c5 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -10,7 +10,6 @@ module Ci
include ObjectStorage::BackgroundMove
include Presentable
include Importable
- include Gitlab::Utils::StrongMemoize
include HasRef
include IgnorableColumns
@@ -23,6 +22,7 @@ module Ci
belongs_to :trigger_request
belongs_to :erased_by, class_name: 'User'
belongs_to :resource_group, class_name: 'Ci::ResourceGroup', inverse_of: :builds
+ belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id
RUNNER_FEATURES = {
upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? },
@@ -114,6 +114,7 @@ module Ci
end
scope :eager_load_job_artifacts, -> { includes(:job_artifacts) }
+ scope :eager_load_job_artifacts_archive, -> { includes(:job_artifacts_archive) }
scope :eager_load_everything, -> do
includes(
@@ -172,6 +173,9 @@ module Ci
scope :queued_before, ->(time) { where(arel_table[:queued_at].lt(time)) }
scope :order_id_desc, -> { order('ci_builds.id DESC') }
+ PROJECT_ROUTE_AND_NAMESPACE_ROUTE = { project: [:project_feature, :route, { namespace: :route }] }.freeze
+ scope :preload_project_and_pipeline_project, -> { preload(PROJECT_ROUTE_AND_NAMESPACE_ROUTE, pipeline: PROJECT_ROUTE_AND_NAMESPACE_ROUTE) }
+
acts_as_taggable
add_authentication_token_field :token, encrypted: :optional
@@ -760,8 +764,8 @@ module Ci
end
end
- def has_expiring_artifacts?
- artifacts_expire_at.present? && artifacts_expire_at > Time.now
+ def has_expiring_archive_artifacts?
+ has_expiring_artifacts? && job_artifacts_archive.present?
end
def keep_artifacts!
@@ -815,7 +819,7 @@ module Ci
depended_jobs = depends_on_builds
# find all jobs that are needed
- if Feature.enabled?(:ci_dag_support, project, default_enabled: true) && needs.exists?
+ if Feature.enabled?(:ci_dag_support, project, default_enabled: true) && scheduling_type_dag?
depended_jobs = depended_jobs.where(name: needs.artifacts.select(:name))
end
@@ -976,6 +980,10 @@ module Ci
value.with_indifferent_access
end
end
+
+ def has_expiring_artifacts?
+ artifacts_expire_at.present? && artifacts_expire_at > Time.now
+ end
end
end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index 9eca324f0fc..564853fc8a1 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -27,7 +27,8 @@ module Ci
license_management: 'gl-license-management-report.json',
license_scanning: 'gl-license-scanning-report.json',
performance: 'performance.json',
- metrics: 'metrics.txt'
+ metrics: 'metrics.txt',
+ lsif: 'lsif.json'
}.freeze
INTERNAL_TYPES = {
@@ -52,7 +53,8 @@ module Ci
dast: :raw,
license_management: :raw,
license_scanning: :raw,
- performance: :raw
+ performance: :raw,
+ lsif: :raw
}.freeze
TYPE_AND_FORMAT_PAIRS = INTERNAL_TYPES.merge(REPORT_TYPES).freeze
@@ -72,6 +74,7 @@ module Ci
scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) }
scope :with_files_stored_remotely, -> { where(file_store: ::JobArtifactUploader::Store::REMOTE) }
+ scope :for_sha, ->(sha) { joins(job: :pipeline).where(ci_pipelines: { sha: sha }) }
scope :with_file_types, -> (file_types) do
types = self.file_types.select { |file_type| file_types.include?(file_type) }.values
@@ -114,7 +117,8 @@ module Ci
performance: 11, ## EE-specific
metrics: 12, ## EE-specific
metrics_referee: 13, ## runner referees
- network_referee: 14 ## runner referees
+ network_referee: 14, ## runner referees
+ lsif: 15 # LSIF data for code navigation
}
enum file_format: {
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 7e3ba98d86c..3209e077a08 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -16,6 +16,8 @@ module Ci
include FromUnion
include UpdatedAtFilterable
+ BridgeStatusError = Class.new(StandardError)
+
sha_attribute :source_sha
sha_attribute :target_sha
@@ -64,6 +66,7 @@ module Ci
has_one :triggered_by_pipeline, through: :source_pipeline, source: :source_pipeline
has_one :parent_pipeline, -> { merge(Ci::Sources::Pipeline.same_project) }, through: :source_pipeline, source: :source_pipeline
has_one :source_job, through: :source_pipeline, source: :source_job
+ has_one :source_bridge, through: :source_pipeline, source: :source_bridge
has_one :pipeline_config, class_name: 'Ci::PipelineConfig', inverse_of: :pipeline
@@ -74,9 +77,7 @@ module Ci
validates :sha, presence: { unless: :importing? }
validates :ref, presence: { unless: :importing? }
- validates :merge_request, presence: { if: :merge_request_event? }
- validates :merge_request, absence: { unless: :merge_request_event? }
- validates :tag, inclusion: { in: [false], if: :merge_request_event? }
+ validates :tag, inclusion: { in: [false], if: :merge_request? }
validates :external_pull_request, presence: { if: :external_pull_request_event? }
validates :external_pull_request, absence: { unless: :external_pull_request_event? }
@@ -184,7 +185,7 @@ module Ci
pipeline.run_after_commit do
PipelineHooksWorker.perform_async(pipeline.id)
- ExpirePipelineCacheWorker.perform_async(pipeline.id)
+ ExpirePipelineCacheWorker.perform_async(pipeline.id) if pipeline.cacheable?
end
end
@@ -204,6 +205,22 @@ module Ci
end
end
+ after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline|
+ next unless pipeline.bridge_triggered?
+ next unless pipeline.bridge_waiting?
+
+ pipeline.run_after_commit do
+ ::Ci::PipelineBridgeStatusWorker.perform_async(pipeline.id)
+ end
+ end
+
+ after_transition created: :pending do |pipeline|
+ next unless pipeline.bridge_triggered?
+ next if pipeline.bridge_waiting?
+
+ pipeline.update_bridge_status!
+ end
+
after_transition any => [:success, :failed] do |pipeline|
pipeline.run_after_commit do
PipelineNotificationWorker.perform_async(pipeline.id)
@@ -578,7 +595,7 @@ module Ci
# Manually set the notes for a Ci::Pipeline
# There is no ActiveRecord relation between Ci::Pipeline and notes
# as they are related to a commit sha. This method helps importing
- # them using the +Gitlab::ImportExport::RelationFactory+ class.
+ # them using the +Gitlab::ImportExport::ProjectRelationFactory+ class.
def notes=(notes)
notes.each do |note|
note[:id] = nil
@@ -643,7 +660,7 @@ module Ci
variables.concat(predefined_commit_variables)
- if merge_request_event? && merge_request
+ if merge_request?
variables.append(key: 'CI_MERGE_REQUEST_EVENT_TYPE', value: merge_request_event_type.to_s)
variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', value: source_sha.to_s)
variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_SHA', value: target_sha.to_s)
@@ -701,7 +718,7 @@ module Ci
# All the merge requests for which the current pipeline runs/ran against
def all_merge_requests
@all_merge_requests ||=
- if merge_request_event?
+ if merge_request?
MergeRequest.where(id: merge_request_id)
else
MergeRequest.where(source_project_id: project_id, source_branch: ref)
@@ -722,6 +739,21 @@ module Ci
end
end
+ def update_bridge_status!
+ raise ArgumentError unless bridge_triggered?
+ raise BridgeStatusError unless source_bridge.active?
+
+ source_bridge.success!
+ end
+
+ def bridge_triggered?
+ source_bridge.present?
+ end
+
+ def bridge_waiting?
+ source_bridge&.dependent?
+ end
+
def child?
parent_pipeline.present?
end
@@ -755,6 +787,12 @@ module Ci
end
end
+ def test_reports_count
+ Rails.cache.fetch(['project', project.id, 'pipeline', id, 'test_reports_count'], force: false) do
+ test_reports.total_count
+ end
+ end
+
def has_exposed_artifacts?
complete? && builds.latest.with_exposed_artifacts.exists?
end
@@ -772,7 +810,7 @@ module Ci
# * nil: Modified path can not be evaluated
def modified_paths
strong_memoize(:modified_paths) do
- if merge_request_event?
+ if merge_request?
merge_request.modified_paths
elsif branch_updated?
push_details.modified_paths
@@ -796,12 +834,12 @@ module Ci
ref == project.default_branch
end
- def triggered_by_merge_request?
- merge_request_event? && merge_request_id.present?
+ def merge_request?
+ merge_request_id.present?
end
def detached_merge_request_pipeline?
- triggered_by_merge_request? && target_sha.nil?
+ merge_request? && target_sha.nil?
end
def legacy_detached_merge_request_pipeline?
@@ -809,7 +847,7 @@ module Ci
end
def merge_request_pipeline?
- triggered_by_merge_request? && target_sha.present?
+ merge_request? && target_sha.present?
end
def merge_request_ref?
@@ -825,7 +863,7 @@ module Ci
end
def source_ref
- if triggered_by_merge_request?
+ if merge_request?
merge_request.source_branch
else
ref
@@ -845,7 +883,7 @@ module Ci
end
def merge_request_event_type
- return unless merge_request_event?
+ return unless merge_request?
strong_memoize(:merge_request_event_type) do
if merge_request_pipeline?
@@ -864,6 +902,10 @@ module Ci
statuses.latest.success.where(name: names).pluck(:id)
end
+ def cacheable?
+ Ci::PipelineEnums.ci_config_sources.key?(config_source.to_sym)
+ end
+
private
def pipeline_data
@@ -878,7 +920,7 @@ module Ci
def git_ref
strong_memoize(:git_ref) do
- if merge_request_event?
+ if merge_request?
##
# In the future, we're going to change this ref to
# merge request's merged reference, such as "refs/merge-requests/:iid/merge".
diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb
index fde169d2f03..7e203cb67c4 100644
--- a/app/models/ci/pipeline_enums.rb
+++ b/app/models/ci/pipeline_enums.rb
@@ -46,13 +46,18 @@ module Ci
}
end
- def self.ci_config_sources_values
- config_sources.values_at(
+ def self.ci_config_sources
+ config_sources.slice(
:unknown_source,
:repository_source,
:auto_devops_source,
:remote_source,
- :external_project_source)
+ :external_project_source
+ )
+ end
+
+ def self.ci_config_sources_values
+ ci_config_sources.values
end
end
end
diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb
index 9a1445e624c..f5785000062 100644
--- a/app/models/ci/pipeline_schedule.rb
+++ b/app/models/ci/pipeline_schedule.rb
@@ -23,7 +23,7 @@ module Ci
scope :active, -> { where(active: true) }
scope :inactive, -> { where(active: false) }
- scope :preloaded, -> { preload(:owner, :project) }
+ scope :preloaded, -> { preload(:owner, project: [:route]) }
accepts_nested_attributes_for :variables, allow_destroy: true
diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb
index 6c4b271cd2c..6c080582cae 100644
--- a/app/models/ci/processable.rb
+++ b/app/models/ci/processable.rb
@@ -2,12 +2,28 @@
module Ci
class Processable < ::CommitStatus
+ include Gitlab::Utils::StrongMemoize
+
has_many :needs, class_name: 'Ci::BuildNeed', foreign_key: :build_id, inverse_of: :build
accepts_nested_attributes_for :needs
+ enum scheduling_type: { stage: 0, dag: 1 }, _prefix: true
+
scope :preload_needs, -> { preload(:needs) }
+ scope :with_needs, -> (names = nil) do
+ needs = Ci::BuildNeed.scoped_build.select(1)
+ needs = needs.where(name: names) if names
+ where('EXISTS (?)', needs).preload(:needs)
+ end
+
+ scope :without_needs, -> (names = nil) do
+ needs = Ci::BuildNeed.scoped_build.select(1)
+ needs = needs.where(name: names) if names
+ where('NOT EXISTS (?)', needs)
+ end
+
def self.select_with_aggregated_needs(project)
return all unless Feature.enabled?(:ci_dag_support, project, default_enabled: true)
@@ -22,7 +38,20 @@ module Ci
)
end
+ # Old processables may have scheduling_type as nil,
+ # so we need to ensure the data exists before using it.
+ def self.populate_scheduling_type!
+ needs = Ci::BuildNeed.scoped_build.select(1)
+ where(scheduling_type: nil).update_all(
+ "scheduling_type = CASE WHEN (EXISTS (#{needs.to_sql}))
+ THEN #{scheduling_types[:dag]}
+ ELSE #{scheduling_types[:stage]}
+ END"
+ )
+ end
+
validates :type, presence: true
+ validates :scheduling_type, presence: true, on: :create, if: :validate_scheduling_type?
def aggregated_needs_names
read_attribute(:aggregated_needs_names)
@@ -47,5 +76,24 @@ module Ci
def scoped_variables_hash
raise NotImplementedError
end
+
+ # Overriding scheduling_type enum's method for nil `scheduling_type`s
+ def scheduling_type_dag?
+ super || find_legacy_scheduling_type == :dag
+ end
+
+ # scheduling_type column of previous builds/bridges have not been populated,
+ # so we calculate this value on runtime when we need it.
+ def find_legacy_scheduling_type
+ strong_memoize(:find_legacy_scheduling_type) do
+ needs.exists? ? :dag : :stage
+ end
+ end
+
+ private
+
+ def validate_scheduling_type?
+ !importing? && Feature.enabled?(:validate_scheduling_type_of_processables, project)
+ end
end
end
diff --git a/app/models/ci/sources/pipeline.rb b/app/models/ci/sources/pipeline.rb
index d71e3b55b9a..f19aac213be 100644
--- a/app/models/ci/sources/pipeline.rb
+++ b/app/models/ci/sources/pipeline.rb
@@ -10,6 +10,7 @@ module Ci
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_bridge, class_name: "Ci::Bridge", foreign_key: :source_job_id
belongs_to :source_pipeline, class_name: "Ci::Pipeline", foreign_key: :source_pipeline_id
validates :project, presence: true
@@ -23,5 +24,3 @@ module Ci
end
end
end
-
-::Ci::Sources::Pipeline.prepend_if_ee('::EE::Ci::Sources::Pipeline')
diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb
index e86a4597ed8..ce42bc65579 100644
--- a/app/models/clusters/applications/elastic_stack.rb
+++ b/app/models/clusters/applications/elastic_stack.rb
@@ -16,7 +16,7 @@ module Clusters
include ::Gitlab::Utils::StrongMemoize
include IgnorableColumns
- ignore_column :kibana_hostname, remove_with: '12.8', remove_after: '2020-01-22'
+ ignore_column :kibana_hostname, remove_with: '12.9', remove_after: '2020-02-22'
default_value_for :version, VERSION
@@ -30,7 +30,8 @@ module Clusters
version: VERSION,
rbac: cluster.platform_kubernetes_rbac?,
chart: chart,
- files: files
+ files: files,
+ postinstall: post_install_script
)
end
@@ -43,6 +44,10 @@ module Clusters
)
end
+ def files
+ super.merge('wait-for-elasticsearch.sh': File.read("#{Rails.root}/vendor/elastic_stack/wait-for-elasticsearch.sh"))
+ end
+
def elasticsearch_client
strong_memoize(:elasticsearch_client) do
next unless kube_client
@@ -69,10 +74,16 @@ module Clusters
private
+ def post_install_script
+ [
+ "timeout -t60 sh /data/helm/elastic-stack/config/wait-for-elasticsearch.sh http://elastic-stack-elasticsearch-client:9200"
+ ]
+ end
+
def post_delete_script
[
Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", "release=elastic-stack")
- ].compact
+ ]
end
def kube_client
diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
index 63f216c7af5..bdd7ad90fba 100644
--- a/app/models/clusters/applications/ingress.rb
+++ b/app/models/clusters/applications/ingress.rb
@@ -3,7 +3,8 @@
module Clusters
module Applications
class Ingress < ApplicationRecord
- VERSION = '1.22.1'
+ VERSION = '1.29.3'
+ MODSECURITY_LOG_CONTAINER_NAME = 'modsecurity-log'
self.table_name = 'clusters_applications_ingress'
@@ -85,7 +86,7 @@ module Clusters
},
"extraContainers" => [
{
- "name" => "modsecurity-log",
+ "name" => MODSECURITY_LOG_CONTAINER_NAME,
"image" => "busybox",
"args" => [
"/bin/sh",
diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb
index 387503bee54..eebcbcba2d3 100644
--- a/app/models/clusters/applications/knative.rb
+++ b/app/models/clusters/applications/knative.rb
@@ -11,7 +11,7 @@ module Clusters
self.table_name = 'clusters_applications_knative'
- has_one :serverless_domain_cluster, class_name: 'Serverless::DomainCluster', foreign_key: 'clusters_applications_knative_id', inverse_of: :knative
+ has_one :serverless_domain_cluster, class_name: '::Serverless::DomainCluster', foreign_key: 'clusters_applications_knative_id', inverse_of: :knative
include ::Clusters::Concerns::ApplicationCore
include ::Clusters::Concerns::ApplicationStatus
@@ -74,7 +74,7 @@ module Clusters
end
def ingress_service
- cluster.kubeclient.get_service('istio-ingressgateway', 'istio-system')
+ cluster.kubeclient.get_service('istio-ingressgateway', Clusters::Kubernetes::ISTIO_SYSTEM_NAMESPACE)
end
def uninstall_command
diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb
index d24a298b0a6..adce55cb61b 100644
--- a/app/models/clusters/applications/prometheus.rb
+++ b/app/models/clusters/applications/prometheus.rb
@@ -99,6 +99,8 @@ module Clusters
def configured?
kube_client.present? && available?
+ rescue Gitlab::UrlBlocker::BlockedUrlError
+ false
end
private
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index a908ca28188..6a9cd77d356 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.12.0'
+ VERSION = '0.13.1'
self.table_name = 'clusters_applications_runners'
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index d2eee78f3df..7e76d324bdc 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -31,6 +31,7 @@ module Clusters
has_many :cluster_projects, class_name: 'Clusters::Project'
has_many :projects, through: :cluster_projects, class_name: '::Project'
has_one :cluster_project, -> { order(id: :desc) }, class_name: 'Clusters::Project'
+ has_many :deployment_clusters
has_many :cluster_groups, class_name: 'Clusters::Group'
has_many :groups, through: :cluster_groups, class_name: '::Group'
@@ -289,6 +290,12 @@ module Clusters
end
end
+ def serverless_domain
+ strong_memoize(:serverless_domain) do
+ self.application_knative&.serverless_domain_cluster
+ end
+ end
+
private
def unique_management_project_environment_scope
diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb
index ae720065387..444368d0ef3 100644
--- a/app/models/clusters/platforms/kubernetes.rb
+++ b/app/models/clusters/platforms/kubernetes.rb
@@ -92,7 +92,10 @@ module Clusters
def calculate_reactive_cache_for(environment)
return unless enabled?
- { pods: read_pods(environment.deployment_namespace) }
+ pods = read_pods(environment.deployment_namespace)
+
+ # extract_relevant_pod_data avoids uploading all the pod info into ReactiveCaching
+ { pods: extract_relevant_pod_data(pods) }
end
def terminals(environment, data)
@@ -203,6 +206,21 @@ module Clusters
def nullify_blank_namespace
self.namespace = nil if namespace.blank?
end
+
+ def extract_relevant_pod_data(pods)
+ pods.map do |pod|
+ {
+ 'metadata' => pod.fetch('metadata', {})
+ .slice('name', 'generateName', 'labels', 'annotations', 'creationTimestamp'),
+ 'status' => pod.fetch('status', {}).slice('phase'),
+ 'spec' => {
+ 'containers' => pod.fetch('spec', {})
+ .fetch('containers', [])
+ .map { |c| c.slice('name') }
+ }
+ }
+ end
+ end
end
end
end
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 460725b2016..d8a3bbfeeb2 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -21,11 +21,14 @@ class Commit
participant :committer
participant :notes_with_associations
- attr_accessor :project, :author
+ attr_accessor :author
attr_accessor :redacted_description_html
attr_accessor :redacted_title_html
attr_accessor :redacted_full_title_html
- attr_reader :gpg_commit
+ attr_reader :container
+
+ delegate :repository, to: :container
+ delegate :project, to: :repository, allow_nil: true
DIFF_SAFE_LINES = Gitlab::Git::DiffCollection::DEFAULT_LIMITS[:max_lines]
@@ -44,12 +47,12 @@ class Commit
cache_markdown_field :description, pipeline: :commit_description
class << self
- def decorate(commits, project)
+ def decorate(commits, container)
commits.map do |commit|
if commit.is_a?(Commit)
commit
else
- self.new(commit, project)
+ self.new(commit, container)
end
end
end
@@ -85,24 +88,24 @@ class Commit
}
end
- def from_hash(hash, project)
- raw_commit = Gitlab::Git::Commit.new(project.repository.raw, hash)
- new(raw_commit, project)
+ def from_hash(hash, container)
+ raw_commit = Gitlab::Git::Commit.new(container.repository.raw, hash)
+ new(raw_commit, container)
end
def valid_hash?(key)
!!(EXACT_COMMIT_SHA_PATTERN =~ key)
end
- def lazy(project, oid)
- BatchLoader.for({ project: project, oid: oid }).batch(replace_methods: false) do |items, loader|
- items_by_project = items.group_by { |i| i[:project] }
+ def lazy(container, oid)
+ BatchLoader.for({ container: container, oid: oid }).batch(replace_methods: false) do |items, loader|
+ items_by_container = items.group_by { |i| i[:container] }
- items_by_project.each do |project, commit_ids|
+ items_by_container.each do |container, commit_ids|
oids = commit_ids.map { |i| i[:oid] }
- project.repository.commits_by(oids: oids).each do |commit|
- loader.call({ project: commit.project, oid: commit.id }, commit) if commit
+ container.repository.commits_by(oids: oids).each do |commit|
+ loader.call({ container: commit.container, oid: commit.id }, commit) if commit
end
end
end
@@ -115,12 +118,11 @@ class Commit
attr_accessor :raw
- def initialize(raw_commit, project)
+ def initialize(raw_commit, container)
raise "Nil as raw commit passed" unless raw_commit
@raw = raw_commit
- @project = project
- @gpg_commit = Gitlab::Gpg::Commit.new(self) if project
+ @container = container
end
delegate \
@@ -141,7 +143,7 @@ class Commit
end
def project_id
- project.id
+ project&.id
end
def ==(other)
@@ -242,6 +244,8 @@ class Commit
# Discover issues should be closed when this commit is pushed to a project's
# default branch.
def closes_issues(current_user = self.committer)
+ return unless repository.repo_type.project?
+
Gitlab::ClosingIssueExtractor.new(project, current_user).closed_by_message(safe_message)
end
@@ -269,17 +273,17 @@ class Commit
end
def parents
- @parents ||= parent_ids.map { |oid| Commit.lazy(project, oid) }
+ @parents ||= parent_ids.map { |oid| Commit.lazy(container, oid) }
end
def parent
strong_memoize(:parent) do
- project.commit_by(oid: self.parent_id) if self.parent_id
+ container.commit_by(oid: self.parent_id) if self.parent_id
end
end
def notes
- project.notes.for_commit_id(self.id)
+ container.notes.for_commit_id(self.id)
end
def user_mentions
@@ -295,7 +299,11 @@ class Commit
end
def merge_requests
- @merge_requests ||= project.merge_requests.by_commit_sha(sha)
+ strong_memoize(:merge_requests) do
+ next MergeRequest.none unless repository.repo_type.project? && project
+
+ project.merge_requests.by_commit_sha(sha)
+ end
end
def method_missing(method, *args, &block)
@@ -317,20 +325,41 @@ class Commit
)
end
- def signature
- return @signature if defined?(@signature)
+ def has_signature?
+ signature_type && signature_type != :NONE
+ end
- @signature = gpg_commit.signature
+ def raw_signature_type
+ strong_memoize(:raw_signature_type) do
+ next unless @raw.instance_of?(Gitlab::Git::Commit)
+
+ @raw.raw_commit.signature_type if defined? @raw.raw_commit.signature_type
+ end
end
- delegate :has_signature?, to: :gpg_commit
+ def signature_type
+ @signature_type ||= raw_signature_type || :NONE
+ end
+
+ def signature
+ strong_memoize(:signature) do
+ case signature_type
+ when :PGP
+ Gitlab::Gpg::Commit.new(self).signature
+ when :X509
+ Gitlab::X509::Commit.new(self).signature
+ else
+ nil
+ end
+ end
+ end
def revert_branch_name
"revert-#{short_id}"
end
def cherry_pick_branch_name
- project.repository.next_branch("cherry-pick-#{short_id}", mild: true)
+ repository.next_branch("cherry-pick-#{short_id}", mild: true)
end
def cherry_pick_description(user)
@@ -418,7 +447,7 @@ class Commit
return unless entry
if entry[:type] == :blob
- blob = ::Blob.decorate(Gitlab::Git::Blob.new(name: entry[:name]), @project)
+ blob = ::Blob.decorate(Gitlab::Git::Blob.new(name: entry[:name]), container)
blob.image? || blob.video? || blob.audio? ? :raw : :blob
else
entry[:type]
@@ -484,10 +513,10 @@ class Commit
end
def commit_reference(from, referable_commit_id, full: false)
- reference = project.to_reference(from, full: full)
+ base = container.to_reference_base(from, full: full)
- if reference.present?
- "#{reference}#{self.class.reference_prefix}#{referable_commit_id}"
+ if base.present?
+ "#{base}#{self.class.reference_prefix}#{referable_commit_id}"
else
referable_commit_id
end
@@ -510,6 +539,6 @@ class Commit
end
def merged_merge_request_no_cache(user)
- MergeRequestsFinder.new(user, project_id: project.id).find_by(merge_commit_sha: id) if merge_commit?
+ MergeRequestsFinder.new(user, project_id: project_id).find_by(merge_commit_sha: id) if merge_commit?
end
end
diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb
index d4c29aa295b..456d32bf403 100644
--- a/app/models/commit_collection.rb
+++ b/app/models/commit_collection.rb
@@ -1,17 +1,20 @@
# frozen_string_literal: true
-# A collection of Commit instances for a specific project and Git reference.
+# A collection of Commit instances for a specific container and Git reference.
class CommitCollection
include Enumerable
include Gitlab::Utils::StrongMemoize
- attr_reader :project, :ref, :commits
+ attr_reader :container, :ref, :commits
- # project - The project the commits belong to.
+ delegate :repository, to: :container, allow_nil: true
+ delegate :project, to: :repository, allow_nil: true
+
+ # container - The object the commits belong to.
# commits - The Commit instances to store.
# ref - The name of the ref (e.g. "master").
- def initialize(project, commits, ref = nil)
- @project = project
+ def initialize(container, commits, ref = nil)
+ @container = container
@commits = commits
@ref = ref
end
@@ -39,6 +42,8 @@ class CommitCollection
# 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)
+ return self unless project
+
pipelines = project.ci_pipelines.latest_pipeline_per_commit(map(&:id), ref)
each do |commit|
@@ -59,16 +64,16 @@ class CommitCollection
# Batch load any commits that are not backed by full gitaly data, and
# replace them in the collection.
def enrich!
- # A project is needed in order to fetch data from gitaly. Projects
+ # A container is needed in order to fetch data from gitaly. Containers
# can be absent from commits in certain rare situations (like when
# viewing a MR of a deleted fork). In these cases, assume that the
# enriched data is not needed.
- return self if project.blank? || fully_enriched?
+ return self if container.blank? || fully_enriched?
# Batch load full Commits from the repository
# and map to a Hash of id => Commit
replacements = Hash[unenriched.map do |c|
- [c.id, Commit.lazy(project, c.id)]
+ [c.id, Commit.lazy(container, c.id)]
end.compact]
# Replace the commits, keeping the same order
diff --git a/app/models/commit_range.rb b/app/models/commit_range.rb
index 08ca86bc902..08f1eb3731e 100644
--- a/app/models/commit_range.rb
+++ b/app/models/commit_range.rb
@@ -92,7 +92,7 @@ class CommitRange
alias_method :id, :to_s
def to_reference(from = nil, full: false)
- project_reference = project.to_reference(from, full: full)
+ project_reference = project.to_reference_base(from, full: full)
if project_reference.present?
project_reference + self.class.reference_prefix + self.id
@@ -102,7 +102,7 @@ class CommitRange
end
def reference_link_text(from = nil)
- project_reference = project.to_reference(from)
+ project_reference = project.to_reference_base(from)
reference = ref_from + notation + ref_to
if project_reference.present?
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index f9101609f89..35b727720ba 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -62,18 +62,6 @@ class CommitStatus < ApplicationRecord
preload(project: :namespace)
end
- scope :with_needs, -> (names = nil) do
- needs = Ci::BuildNeed.scoped_build.select(1)
- needs = needs.where(name: names) if names
- where('EXISTS (?)', needs).preload(:needs)
- end
-
- scope :without_needs, -> (names = nil) do
- needs = Ci::BuildNeed.scoped_build.select(1)
- needs = needs.where(name: names) if names
- where('NOT EXISTS (?)', needs)
- end
-
scope :match_id_and_lock_version, -> (slice) do
# it expects that items are an array of attributes to match
# each hash needs to have `id` and `lock_version`
@@ -200,6 +188,10 @@ class CommitStatus < ApplicationRecord
update_all('processed=TRUE, lock_version=COALESCE(lock_version,0)+1')
end
+ def self.locking_enabled?
+ false
+ end
+
def locking_enabled?
will_save_change_to_status?
end
diff --git a/app/models/commit_status_enums.rb b/app/models/commit_status_enums.rb
index 2ca6d15e642..caebff91022 100644
--- a/app/models/commit_status_enums.rb
+++ b/app/models/commit_status_enums.rb
@@ -17,7 +17,13 @@ module CommitStatusEnums
archived_failure: 9,
unmet_prerequisites: 10,
scheduler_failure: 11,
- data_integrity_failure: 12
+ data_integrity_failure: 12,
+ forward_deployment_failure: 13,
+ insufficient_bridge_permissions: 1_001,
+ downstream_bridge_project_not_found: 1_002,
+ invalid_bridge_trigger: 1_003,
+ bridge_pipeline_is_child_pipeline: 1_006,
+ downstream_pipeline_creation_failed: 1_007
}
end
end
diff --git a/app/models/concerns/analytics/cycle_analytics/stage.rb b/app/models/concerns/analytics/cycle_analytics/stage.rb
index dde73b567db..39e8408f794 100644
--- a/app/models/concerns/analytics/cycle_analytics/stage.rb
+++ b/app/models/concerns/analytics/cycle_analytics/stage.rb
@@ -15,8 +15,8 @@ module Analytics
validates :name, exclusion: { in: Gitlab::Analytics::CycleAnalytics::DefaultStages.names }, if: :custom?
validates :start_event_identifier, presence: true
validates :end_event_identifier, presence: true
- validates :start_event_label, presence: true, if: :start_event_label_based?
- validates :end_event_label, presence: true, if: :end_event_label_based?
+ validates :start_event_label_id, presence: true, if: :start_event_label_based?
+ validates :end_event_label_id, presence: true, if: :end_event_label_based?
validate :validate_stage_event_pairs
validate :validate_labels
@@ -109,8 +109,8 @@ module Analytics
end
def validate_labels
- validate_label_within_group(:start_event_label, start_event_label_id) if start_event_label_id_changed?
- validate_label_within_group(:end_event_label, end_event_label_id) if end_event_label_id_changed?
+ validate_label_within_group(:start_event_label_id, start_event_label_id) if start_event_label_id_changed?
+ validate_label_within_group(:end_event_label_id, end_event_label_id) if end_event_label_id_changed?
end
def validate_label_within_group(association_name, label_id)
diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb
index 3e9b084e784..4a632e8cd0c 100644
--- a/app/models/concerns/atomic_internal_id.rb
+++ b/app/models/concerns/atomic_internal_id.rb
@@ -27,7 +27,7 @@ module AtomicInternalId
extend ActiveSupport::Concern
class_methods do
- def has_internal_id(column, scope:, init:, ensure_if: nil, track_if: nil, presence: true) # rubocop:disable Naming/PredicateName
+ def has_internal_id(column, scope:, init:, ensure_if: nil, track_if: nil, presence: true, backfill: false) # 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
@@ -38,6 +38,8 @@ module AtomicInternalId
validates column, presence: presence
define_method("ensure_#{scope}_#{column}!") do
+ return if backfill && self.class.where(column => nil).exists?
+
scope_value = internal_id_read_scope(scope)
value = read_attribute(column)
return value unless scope_value
diff --git a/app/models/concerns/bulk_insert_safe.rb b/app/models/concerns/bulk_insert_safe.rb
new file mode 100644
index 00000000000..6d75906b21f
--- /dev/null
+++ b/app/models/concerns/bulk_insert_safe.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module BulkInsertSafe
+ extend ActiveSupport::Concern
+
+ # These are the callbacks we think safe when used on models that are
+ # written to the database in bulk
+ CALLBACK_NAME_WHITELIST = Set[
+ :initialize,
+ :validate,
+ :validation,
+ :find,
+ :destroy
+ ].freeze
+
+ MethodNotAllowedError = Class.new(StandardError)
+
+ class_methods do
+ def set_callback(name, *args)
+ unless _bulk_insert_callback_allowed?(name, args)
+ raise MethodNotAllowedError.new(
+ "Not allowed to call `set_callback(#{name}, #{args})` when model extends `BulkInsertSafe`." \
+ "Callbacks that fire per each record being inserted do not work with bulk-inserts.")
+ end
+
+ super
+ end
+
+ private
+
+ def _bulk_insert_callback_allowed?(name, args)
+ _bulk_insert_whitelisted?(name) || _bulk_insert_saved_from_belongs_to?(name, args)
+ end
+
+ # belongs_to associations will install a before_save hook during class loading
+ def _bulk_insert_saved_from_belongs_to?(name, args)
+ args.first == :before && args.second.to_s.start_with?('autosave_associated_records_for_')
+ end
+
+ def _bulk_insert_whitelisted?(name)
+ CALLBACK_NAME_WHITELIST.include?(name)
+ end
+ end
+end
diff --git a/app/models/concerns/cached_commit.rb b/app/models/concerns/cached_commit.rb
new file mode 100644
index 00000000000..183d5728743
--- /dev/null
+++ b/app/models/concerns/cached_commit.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module CachedCommit
+ extend ActiveSupport::Concern
+
+ def to_hash
+ Gitlab::Git::Commit::SERIALIZE_KEYS.each_with_object({}) do |key, hash|
+ hash[key] = public_send(key) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+
+ # We don't save these, because they would need a table or a serialised
+ # field. They aren't used anywhere, so just pretend the commit has no parents.
+ def parent_ids
+ []
+ end
+end
diff --git a/app/models/concerns/ci/pipeline_delegator.rb b/app/models/concerns/ci/pipeline_delegator.rb
index 9f95dc38422..68ad0fcee31 100644
--- a/app/models/concerns/ci/pipeline_delegator.rb
+++ b/app/models/concerns/ci/pipeline_delegator.rb
@@ -11,7 +11,7 @@ module Ci
extend ActiveSupport::Concern
included do
- delegate :merge_request_event?,
+ delegate :merge_request?,
:merge_request_ref?,
:legacy_detached_merge_request_pipeline?,
:merge_train_pipeline?, to: :pipeline
diff --git a/app/models/concerns/delete_with_limit.rb b/app/models/concerns/delete_with_limit.rb
new file mode 100644
index 00000000000..1ea18b6149b
--- /dev/null
+++ b/app/models/concerns/delete_with_limit.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module DeleteWithLimit
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def delete_with_limit(maximum)
+ limit(maximum).delete_all
+ end
+ end
+end
diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb
index e4e5928f5cf..8542c48f366 100644
--- a/app/models/concerns/discussion_on_diff.rb
+++ b/app/models/concerns/discussion_on_diff.rb
@@ -40,7 +40,7 @@ module DiscussionOnDiff
# Returns an array of at most 16 highlighted lines above a diff note
def truncated_diff_lines(highlight: true, diff_limit: nil)
return [] unless on_text?
- return [] if diff_line.nil? && first_note.is_a?(LegacyDiffNote)
+ return [] if diff_line.nil?
diff_limit = [diff_limit, NUMBER_OF_TRUNCATED_DIFF_LINES].compact.min
lines = highlight ? highlighted_diff_lines : diff_lines
diff --git a/app/models/concerns/has_ref.rb b/app/models/concerns/has_ref.rb
index fa0cf5ddfd2..22e5955984d 100644
--- a/app/models/concerns/has_ref.rb
+++ b/app/models/concerns/has_ref.rb
@@ -7,7 +7,7 @@ module HasRef
extend ActiveSupport::Concern
def branch?
- !tag? && !merge_request_event?
+ !tag? && !merge_request?
end
def git_ref
diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb
new file mode 100644
index 00000000000..d04a6408a21
--- /dev/null
+++ b/app/models/concerns/has_repository.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+# This concern is created to handle repository actions.
+# It should be include inside any object capable
+# of directly having a repository, like project or snippet.
+#
+# It also includes `Referable`, therefore the method
+# `to_reference` should be overriden in case the object
+# needs any special behavior.
+module HasRepository
+ extend ActiveSupport::Concern
+ include Gitlab::ShellAdapter
+ include AfterCommitQueue
+ include Referable
+ include Gitlab::Utils::StrongMemoize
+
+ delegate :base_dir, :disk_path, to: :storage
+
+ def valid_repo?
+ repository.exists?
+ rescue
+ errors.add(:path, _('Invalid repository path'))
+ false
+ end
+
+ def repo_exists?
+ strong_memoize(:repo_exists) do
+ repository.exists?
+ rescue
+ false
+ end
+ end
+
+ def repository_exists?
+ !!repository.exists?
+ end
+
+ def root_ref?(branch)
+ repository.root_ref == branch
+ end
+
+ def commit(ref = 'HEAD')
+ repository.commit(ref)
+ end
+
+ def commit_by(oid:)
+ repository.commit_by(oid: oid)
+ end
+
+ def commits_by(oids:)
+ repository.commits_by(oids: oids)
+ end
+
+ def repository
+ raise NotImplementedError
+ end
+
+ def storage
+ raise NotImplementedError
+ end
+
+ def full_path
+ raise NotImplementedError
+ end
+
+ def empty_repo?
+ repository.empty?
+ end
+
+ def default_branch
+ @default_branch ||= repository.root_ref
+ end
+
+ def reload_default_branch
+ @default_branch = nil # rubocop:disable Gitlab/ModuleWithInstanceVariables
+
+ default_branch
+ end
+
+ def url_to_repo
+ gitlab_shell.url_to_repo(full_path)
+ end
+
+ def ssh_url_to_repo
+ url_to_repo
+ end
+
+ def http_url_to_repo
+ custom_root = Gitlab::CurrentSettings.custom_http_clone_url_root
+
+ url = if custom_root.present?
+ Gitlab::Utils.append_path(
+ custom_root,
+ web_url(only_path: true)
+ )
+ else
+ web_url
+ end
+
+ "#{url}.git"
+ end
+
+ def web_url(only_path: nil)
+ raise NotImplementedError
+ end
+end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index fe0fad4b9d5..78d815e5858 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -91,6 +91,7 @@ module Issuable
validate :description_max_length_for_new_records_is_valid, on: :update
before_validation :truncate_description_on_import!
+ after_save :store_mentions!, if: :any_mentionable_attributes_changed?
scope :authored, ->(user) { where(author_id: user) }
scope :recent, -> { reorder(id: :desc) }
@@ -108,7 +109,9 @@ module Issuable
where("NOT EXISTS (SELECT TRUE FROM #{to_ability_name}_assignees WHERE #{to_ability_name}_id = #{to_ability_name}s.id)")
end
scope :assigned_to, ->(u) do
- where("EXISTS (SELECT TRUE FROM #{to_ability_name}_assignees WHERE user_id = ? AND #{to_ability_name}_id = #{to_ability_name}s.id)", u.id)
+ assignees_table = Arel::Table.new("#{to_ability_name}_assignees")
+ sql = assignees_table.project('true').where(assignees_table[:user_id].in(u)).where(Arel::Nodes::SqlLiteral.new("#{to_ability_name}_id = #{to_ability_name}s.id"))
+ where("EXISTS (#{sql.to_sql})")
end
# rubocop:enable GitlabSecurity/SqlInjection
@@ -128,6 +131,10 @@ module Issuable
strip_attributes :title
+ def self.locking_enabled?
+ false
+ end
+
# We want to use optimistic lock for cases when only title or description are involved
# http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html
def locking_enabled?
@@ -243,7 +250,7 @@ module Issuable
Gitlab::Database.nulls_last_order('highest_priority', direction))
end
- def order_labels_priority(direction = 'ASC', excluded_labels: [], extra_select_columns: [])
+ def order_labels_priority(direction = 'ASC', excluded_labels: [], extra_select_columns: [], with_cte: false)
params = {
target_type: name,
target_column: "#{table_name}.id",
@@ -259,12 +266,13 @@ module Issuable
] + extra_select_columns
select(select_columns.join(', '))
- .group(arel_table[:id])
+ .group(issue_grouping_columns(use_cte: with_cte))
.reorder(Gitlab::Database.nulls_last_order('highest_priority', direction))
end
- def with_label(title, sort = nil)
- if title.is_a?(Array) && title.size > 1
+ def with_label(title, sort = nil, not_query: false)
+ multiple_labels = title.is_a?(Array) && title.size > 1
+ if multiple_labels && !not_query
joins(:labels).where(labels: { title: title }).group(*grouping_columns(sort)).having("COUNT(DISTINCT labels.title) = #{title.size}")
else
joins(:labels).where(labels: { title: title })
@@ -287,6 +295,18 @@ module Issuable
grouping_columns
end
+ # Includes all table keys in group by clause when sorting
+ # preventing errors in postgres when using CTE search optimisation
+ #
+ # Returns an array of arel columns
+ def issue_grouping_columns(use_cte: false)
+ if use_cte
+ [arel_table[:state]] + attribute_names.map { |attr| arel_table[attr.to_sym] }
+ else
+ arel_table[:id]
+ end
+ end
+
def to_ability_name
model_name.singular
end
diff --git a/app/models/concerns/loaded_in_group_list.rb b/app/models/concerns/loaded_in_group_list.rb
index fc15c6d55ed..79ff82d9f99 100644
--- a/app/models/concerns/loaded_in_group_list.rb
+++ b/app/models/concerns/loaded_in_group_list.rb
@@ -73,3 +73,5 @@ module LoadedInGroupList
@member_count ||= try(:preloaded_member_count) || users.count
end
end
+
+LoadedInGroupList::ClassMethods.prepend_if_ee('EE::LoadedInGroupList::ClassMethods')
diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb
index b43b91699ab..d157404f7bc 100644
--- a/app/models/concerns/mentionable.rb
+++ b/app/models/concerns/mentionable.rb
@@ -99,18 +99,23 @@ module Mentionable
# threw the `ActiveRecord::RecordNotUnique` exception in first place.
self.class.safe_ensure_unique(retries: 1) do
user_mention = model_user_mention
+
+ # this may happen due to notes polymorphism, so noteable_id may point to a record that no longer exists
+ # as we cannot have FK on noteable_id
+ break if user_mention.blank?
+
user_mention.mentioned_users_ids = references[:mentioned_users_ids]
user_mention.mentioned_groups_ids = references[:mentioned_groups_ids]
user_mention.mentioned_projects_ids = references[:mentioned_projects_ids]
if user_mention.has_mentions?
user_mention.save!
- elsif user_mention.persisted?
+ else
user_mention.destroy!
end
-
- true
end
+
+ true
end
def referenced_users
@@ -218,6 +223,12 @@ module Mentionable
source.select { |key, val| mentionable.include?(key) }
end
+ def any_mentionable_attributes_changed?
+ self.class.mentionable_attrs.any? do |attr|
+ saved_changes.key?(attr.first)
+ end
+ end
+
# Determine whether or not a cross-reference Note has already been created between this Mentionable and
# the specified target.
def cross_reference_exists?(target)
diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb
index 7fb3f95bf0a..7df6981a129 100644
--- a/app/models/concerns/milestoneable.rb
+++ b/app/models/concerns/milestoneable.rb
@@ -14,8 +14,6 @@ module Milestoneable
validate :milestone_is_valid
- after_save :write_to_new_milestone_relationship
-
scope :of_milestones, ->(ids) { where(milestone_id: ids) }
scope :any_milestone, -> { where('milestone_id IS NOT NULL') }
scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) }
@@ -41,10 +39,6 @@ module Milestoneable
def milestone_is_valid
errors.add(:milestone_id, message: "is invalid") if respond_to?(:milestone_id) && milestone_id.present? && !milestone_available?
end
-
- def write_to_new_milestone_relationship
- self.milestones = [milestone].compact if supports_milestone? && saved_change_to_milestone_id?
- end
end
def milestone_available?
diff --git a/app/models/concerns/mirror_authentication.rb b/app/models/concerns/mirror_authentication.rb
index 948094221e5..4dbf4dcec77 100644
--- a/app/models/concerns/mirror_authentication.rb
+++ b/app/models/concerns/mirror_authentication.rb
@@ -37,6 +37,8 @@ module MirrorAuthentication
end
define_method("#{name}=") do |value|
+ credentials_will_change!
+
self.credentials ||= {}
# Removal of the password, username, etc, generally causes an update of
diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb
index eac676f30a5..76d26500267 100644
--- a/app/models/concerns/project_features_compatibility.rb
+++ b/app/models/concerns/project_features_compatibility.rb
@@ -62,6 +62,10 @@ module ProjectFeaturesCompatibility
write_feature_attribute_string(:snippets_access_level, value)
end
+ def pages_access_level=(value)
+ write_feature_attribute_string(:pages_access_level, value)
+ end
+
private
def write_feature_attribute_boolean(field, value)
diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb
index 99da8b81398..2c171eecbd5 100644
--- a/app/models/concerns/prometheus_adapter.rb
+++ b/app/models/concerns/prometheus_adapter.rb
@@ -21,7 +21,7 @@ module PrometheusAdapter
raise NotImplemented
end
- # This is a heavy-weight check if a prometheus is properly configured and accesible from GitLab.
+ # This is a heavy-weight check if a prometheus is properly configured and accessible from GitLab.
# This actually sends a request to an external service and often it could take a long time,
# Please consider using `configured?` instead if the process is running on unicorn/puma threads.
def can_query?
@@ -58,5 +58,12 @@ module PrometheusAdapter
def build_query_args(*args)
args.map { |arg| arg.respond_to?(:id) ? arg.id : arg }
end
+
+ def clear_prometheus_reactive_cache!(query_name, *args)
+ query_class = query_klass_for(query_name)
+ query_args = build_query_args(*args)
+
+ clear_reactive_cache!(query_class.name, *query_args)
+ end
end
end
diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb
index 4b9896343c6..010e0018414 100644
--- a/app/models/concerns/reactive_caching.rb
+++ b/app/models/concerns/reactive_caching.rb
@@ -6,23 +6,24 @@ module ReactiveCaching
extend ActiveSupport::Concern
InvalidateReactiveCache = Class.new(StandardError)
+ ExceededReactiveCacheLimit = Class.new(StandardError)
included do
- class_attribute :reactive_cache_lease_timeout
+ extend ActiveModel::Naming
class_attribute :reactive_cache_key
- class_attribute :reactive_cache_lifetime
+ class_attribute :reactive_cache_lease_timeout
class_attribute :reactive_cache_refresh_interval
+ class_attribute :reactive_cache_lifetime
+ class_attribute :reactive_cache_hard_limit
class_attribute :reactive_cache_worker_finder
# defaults
self.reactive_cache_key = -> (record) { [model_name.singular, record.id] }
-
self.reactive_cache_lease_timeout = 2.minutes
-
self.reactive_cache_refresh_interval = 1.minute
self.reactive_cache_lifetime = 10.minutes
-
+ self.reactive_cache_hard_limit = 1.megabyte
self.reactive_cache_worker_finder = ->(id, *_args) do
find_by(primary_key => id)
end
@@ -71,6 +72,8 @@ module ReactiveCaching
if within_reactive_cache_lifetime?(*args)
enqueuing_update(*args) do
new_value = calculate_reactive_cache(*args)
+ check_exceeded_reactive_cache_limit!(new_value)
+
old_value = Rails.cache.read(key)
Rails.cache.write(key, new_value)
reactive_cache_updated(*args) if new_value != old_value
@@ -121,5 +124,13 @@ module ReactiveCaching
ReactiveCachingWorker.perform_in(self.class.reactive_cache_refresh_interval, self.class, id, *args)
end
+
+ def check_exceeded_reactive_cache_limit!(data)
+ return unless Feature.enabled?(:reactive_cache_limit)
+
+ data_deep_size = Gitlab::Utils::DeepSize.new(data, max_size: self.class.reactive_cache_hard_limit)
+
+ raise ExceededReactiveCacheLimit.new unless data_deep_size.valid?
+ end
end
end
diff --git a/app/models/concerns/referable.rb b/app/models/concerns/referable.rb
index 3b0606aa425..40edd3b3ead 100644
--- a/app/models/concerns/referable.rb
+++ b/app/models/concerns/referable.rb
@@ -23,6 +23,14 @@ module Referable
''
end
+ # If this referable object can serve as the base for the
+ # reference of child objects (e.g. projects are the base of
+ # issues), but it is formatted differently, then you may wish
+ # to override this method.
+ def to_reference_base(from = nil, full:)
+ to_reference(from, full: full)
+ end
+
def reference_link_text(from = nil)
to_reference(from)
end
diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb
index b645cf71443..1653ecdb305 100644
--- a/app/models/concerns/relative_positioning.rb
+++ b/app/models/concerns/relative_positioning.rb
@@ -237,8 +237,7 @@ module RelativePositioning
relation
.pluck(self.class.relative_positioning_parent_column, Arel.sql("#{calculation}(relative_position) AS position"))
- .first&.
- last
+ .first&.last
end
def scoped_items
diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb
index c0490af2453..5d78eea7fca 100644
--- a/app/models/concerns/resolvable_discussion.rb
+++ b/app/models/concerns/resolvable_discussion.rb
@@ -63,7 +63,7 @@ module ResolvableDiscussion
return unless resolved?
strong_memoize(:last_resolved_note) do
- resolved_notes.sort_by(&:resolved_at).last
+ resolved_notes.max_by(&:resolved_at)
end
end
diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb
index c4af1b1fab2..4fe2a0e1827 100644
--- a/app/models/concerns/sortable.rb
+++ b/app/models/concerns/sortable.rb
@@ -8,13 +8,13 @@ module Sortable
extend ActiveSupport::Concern
included do
- scope :with_order_id_desc, -> { order(id: :desc) }
- scope :order_id_desc, -> { reorder(id: :desc) }
- scope :order_id_asc, -> { reorder(id: :asc) }
- scope :order_created_desc, -> { reorder(created_at: :desc) }
- scope :order_created_asc, -> { reorder(created_at: :asc) }
- scope :order_updated_desc, -> { reorder(updated_at: :desc) }
- scope :order_updated_asc, -> { reorder(updated_at: :asc) }
+ scope :with_order_id_desc, -> { order(self.arel_table['id'].desc) }
+ scope :order_id_desc, -> { reorder(self.arel_table['id'].desc) }
+ scope :order_id_asc, -> { reorder(self.arel_table['id'].asc) }
+ scope :order_created_desc, -> { reorder(self.arel_table['created_at'].desc) }
+ scope :order_created_asc, -> { reorder(self.arel_table['created_at'].asc) }
+ scope :order_updated_desc, -> { reorder(self.arel_table['updated_at'].desc) }
+ scope :order_updated_asc, -> { reorder(self.arel_table['updated_at'].asc) }
scope :order_name_asc, -> { reorder(Arel::Nodes::Ascending.new(arel_table[:name].lower)) }
scope :order_name_desc, -> { reorder(Arel::Nodes::Descending.new(arel_table[:name].lower)) }
end
diff --git a/app/models/concerns/x509_serial_number_attribute.rb b/app/models/concerns/x509_serial_number_attribute.rb
new file mode 100644
index 00000000000..d2a5c736604
--- /dev/null
+++ b/app/models/concerns/x509_serial_number_attribute.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module X509SerialNumberAttribute
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def x509_serial_number_attribute(name)
+ return if ENV['STATIC_VERIFICATION']
+
+ validate_binary_column_exists!(name) unless Rails.env.production?
+
+ attribute(name, Gitlab::Database::X509SerialNumberAttribute.new)
+ end
+
+ # This only gets executed in non-production environments as an additional check to ensure
+ # the column is the correct type. In production it should behave like any other attribute.
+ # See https://gitlab.com/gitlab-org/gitlab/merge_requests/5502 for more discussion
+ def validate_binary_column_exists!(name)
+ return unless database_exists?
+
+ unless table_exists?
+ warn "WARNING: x509_serial_number_attribute #{name.inspect} is invalid since the table doesn't exist - you may need to run database migrations"
+ return
+ end
+
+ column = columns.find { |c| c.name == name.to_s }
+
+ unless column
+ warn "WARNING: x509_serial_number_attribute #{name.inspect} is invalid since the column doesn't exist - you may need to run database migrations"
+ return
+ end
+
+ unless column.type == :binary
+ raise ArgumentError.new("x509_serial_number_attribute #{name.inspect} is invalid since the column type is not :binary")
+ end
+ rescue => error
+ Gitlab::AppLogger.error "X509SerialNumberAttribute initialization: #{error.message}"
+ raise
+ end
+
+ def database_exists?
+ Gitlab::Database.exists?
+ end
+ end
+end
diff --git a/app/models/container_expiration_policy.rb b/app/models/container_expiration_policy.rb
index c929a78a7f9..ccb0a0f8acd 100644
--- a/app/models/container_expiration_policy.rb
+++ b/app/models/container_expiration_policy.rb
@@ -14,7 +14,7 @@ class ContainerExpirationPolicy < ApplicationRecord
validates :keep_n, inclusion: { in: ->(_) { self.keep_n_options.keys } }, allow_nil: true
scope :active, -> { where(enabled: true) }
- scope :preloaded, -> { preload(:project) }
+ scope :preloaded, -> { preload(project: [:route]) }
def self.keep_n_options
{
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index 152aa7b3218..fcbfda8fbc2 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -77,7 +77,11 @@ class ContainerRepository < ApplicationRecord
end
def delete_tag_by_digest(digest)
- client.delete_repository_tag(self.path, digest)
+ client.delete_repository_tag_by_digest(self.path, digest)
+ end
+
+ def delete_tag_by_name(name)
+ client.delete_repository_tag_by_name(self.path, name)
end
def self.build_from_path(path)
diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb
index 20e1d802178..31c813edb67 100644
--- a/app/models/deploy_token.rb
+++ b/app/models/deploy_token.rb
@@ -15,6 +15,11 @@ class DeployToken < ApplicationRecord
has_many :project_deploy_tokens, inverse_of: :deploy_token
has_many :projects, through: :project_deploy_tokens
+ has_many :group_deploy_tokens, inverse_of: :deploy_token
+ has_many :groups, through: :group_deploy_tokens
+
+ validate :no_groups, unless: :group_type?
+ validate :no_projects, unless: :project_type?
validate :ensure_at_least_one_scope
validates :username,
length: { maximum: 255 },
@@ -24,6 +29,12 @@ class DeployToken < ApplicationRecord
message: "can contain only letters, digits, '_', '-', '+', and '.'"
}
+ validates :deploy_token_type, presence: true
+ enum deploy_token_type: {
+ group_type: 1,
+ project_type: 2
+ }
+
before_save :ensure_token
accepts_nested_attributes_for :project_deploy_tokens
@@ -51,18 +62,31 @@ class DeployToken < ApplicationRecord
end
def has_access_to?(requested_project)
- active? && project == requested_project
+ return false unless active?
+ return false unless holder
+
+ holder.has_access_to?(requested_project)
end
# This is temporal. Currently we limit DeployToken
- # to a single project, later we're going to extend
- # that to be for multiple projects and namespaces.
+ # to a single project or group, later we're going to
+ # extend that to be for multiple projects and namespaces.
def project
strong_memoize(:project) do
projects.first
end
end
+ def holder
+ strong_memoize(:holder) do
+ if project_type?
+ project_deploy_tokens.first
+ elsif group_type?
+ group_deploy_tokens.first
+ end
+ end
+ end
+
def expires_at
expires_at = read_attribute(:expires_at)
expires_at != Forever.date ? expires_at : nil
@@ -87,4 +111,12 @@ class DeployToken < ApplicationRecord
def default_username
"gitlab+deploy-token-#{id}" if persisted?
end
+
+ def no_groups
+ errors.add(:deploy_token, 'cannot have groups assigned') if group_deploy_tokens.any?
+ end
+
+ def no_projects
+ errors.add(:deploy_token, 'cannot have projects assigned') if project_deploy_tokens.any?
+ end
end
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index e0daf692665..fe42fb93633 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -18,6 +18,8 @@ class Deployment < ApplicationRecord
has_many :merge_requests,
through: :deployment_merge_requests
+ has_one :deployment_cluster
+
has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) do
Deployment.where(project: s.project).maximum(:iid) if s&.project
end
@@ -28,6 +30,7 @@ class Deployment < ApplicationRecord
validate :valid_ref, on: :create
delegate :name, to: :environment, prefix: true
+ delegate :kubernetes_namespace, to: :deployment_cluster, allow_nil: true
scope :for_environment, -> (environment) { where(environment_id: environment) }
scope :for_environment_name, -> (name) do
@@ -37,6 +40,10 @@ class Deployment < ApplicationRecord
scope :for_status, -> (status) { where(status: status) }
scope :visible, -> { where(status: %i[running success failed canceled]) }
+ scope :stoppable, -> { where.not(on_stop: nil).where.not(deployable_id: nil).success }
+ scope :active, -> { where(status: %i[created running]) }
+ scope :older_than, -> (deployment) { where('id < ?', deployment.id) }
+ scope :with_deployable, -> { includes(:deployable).where('deployable_id IS NOT NULL') }
state_machine :status, initial: :created do
event :run do
@@ -70,6 +77,14 @@ class Deployment < ApplicationRecord
Deployments::FinishedWorker.perform_async(id)
end
end
+
+ after_transition any => :running do |deployment|
+ next unless deployment.project.forward_deployment_enabled?
+
+ deployment.run_after_commit do
+ Deployments::ForwardDeploymentWorker.perform_async(id)
+ end
+ end
end
enum status: {
diff --git a/app/models/deployment_cluster.rb b/app/models/deployment_cluster.rb
new file mode 100644
index 00000000000..3390d397bad
--- /dev/null
+++ b/app/models/deployment_cluster.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class DeploymentCluster < ApplicationRecord
+ belongs_to :deployment, optional: false
+ belongs_to :cluster, optional: false, class_name: 'Clusters::Cluster'
+end
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index 939d8bc4bef..e3df61dadae 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -161,7 +161,7 @@ class DiffNote < Note
def positions_complete
return if self.original_position.complete? && self.position.complete?
- errors.add(:position, "is invalid")
+ errors.add(:position, "is incomplete")
end
def keep_around_commits
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 2d480345b5a..bb41c4a066e 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -6,11 +6,14 @@ class Environment < ApplicationRecord
self.reactive_cache_refresh_interval = 1.minute
self.reactive_cache_lifetime = 55.seconds
+ self.reactive_cache_hard_limit = 10.megabytes
belongs_to :project, required: true
has_many :deployments, -> { visible }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :successful_deployments, -> { success }, class_name: 'Deployment'
+ has_many :active_deployments, -> { active }, class_name: 'Deployment'
+ has_many :prometheus_alerts, inverse_of: :environment
has_one :last_deployment, -> { success.order('deployments.id DESC') }, class_name: 'Deployment'
has_one :last_deployable, through: :last_deployment, source: 'deployable', source_type: 'CommitStatus'
@@ -59,6 +62,7 @@ class Environment < ApplicationRecord
scope :in_review_folder, -> { where(environment_type: "review") }
scope :for_name, -> (name) { where(name: name) }
scope :preload_cluster, -> { preload(last_deployment: :cluster) }
+ scope :auto_stoppable, -> (limit) { available.where('auto_stop_at < ?', Time.zone.now).limit(limit) }
##
# Search environments which have names like the given query.
@@ -105,6 +109,52 @@ class Environment < ApplicationRecord
find_or_create_by(name: name)
end
+ class << self
+ ##
+ # This method returns stop actions (jobs) for multiple environments within one
+ # query. It's useful to avoid N+1 problem.
+ #
+ # NOTE: The count of environments should be small~medium (e.g. < 5000)
+ def stop_actions
+ cte = cte_for_deployments_with_stop_action
+ ci_builds = Ci::Build.arel_table
+
+ inner_join_stop_actions = ci_builds.join(cte.table).on(
+ ci_builds[:project_id].eq(cte.table[:project_id])
+ .and(ci_builds[:ref].eq(cte.table[:ref]))
+ .and(ci_builds[:name].eq(cte.table[:on_stop]))
+ ).join_sources
+
+ pipeline_ids = ci_builds.join(cte.table).on(
+ ci_builds[:id].eq(cte.table[:deployable_id])
+ ).project(:commit_id)
+
+ Ci::Build.joins(inner_join_stop_actions)
+ .with(cte.to_arel)
+ .where(ci_builds[:commit_id].in(pipeline_ids))
+ .where(status: HasStatus::BLOCKED_STATUS)
+ .preload_project_and_pipeline_project
+ .preload(:user, :metadata, :deployment)
+ end
+
+ private
+
+ def cte_for_deployments_with_stop_action
+ Gitlab::SQL::CTE.new(:deployments_with_stop_action,
+ Deployment.where(environment_id: select(:id))
+ .distinct_on_environment
+ .stoppable)
+ end
+ end
+
+ def clear_prometheus_reactive_cache!(query_name)
+ cluster_prometheus_adapter&.clear_prometheus_reactive_cache!(query_name, self)
+ end
+
+ def cluster_prometheus_adapter
+ @cluster_prometheus_adapter ||= ::Gitlab::Prometheus::Adapter.new(project, deployment_platform&.cluster).cluster_prometheus_adapter
+ end
+
def predefined_variables
Gitlab::Ci::Variables::Collection.new
.append(key: 'CI_ENVIRONMENT_NAME', value: name)
diff --git a/app/models/epic.rb b/app/models/epic.rb
index 1203c6c1fc3..ea4a231931d 100644
--- a/app/models/epic.rb
+++ b/app/models/epic.rb
@@ -5,8 +5,6 @@
class Epic < ApplicationRecord
include IgnorableColumns
- ignore_column :milestone_id, remove_after: '2020-02-01', remove_with: '12.8'
-
def self.link_reference_pattern
nil
end
diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb
index a904cf4ac46..d328a609439 100644
--- a/app/models/error_tracking/project_error_tracking_setting.rb
+++ b/app/models/error_tracking/project_error_tracking_setting.rb
@@ -27,6 +27,8 @@ module ErrorTracking
validates :api_url, length: { maximum: 255 }, public_url: { enforce_sanitization: true, ascii_only: true }, allow_nil: true
+ validates :enabled, inclusion: { in: [true, false] }
+
validates :api_url, presence: { message: 'is a required field' }, if: :enabled
validate :validate_api_url_path, if: :enabled
@@ -73,7 +75,9 @@ module ErrorTracking
end
def sentry_client
- Sentry::Client.new(api_url, token)
+ strong_memoize(:sentry_client) do
+ Sentry::Client.new(api_url, token)
+ end
end
def sentry_external_url
@@ -87,7 +91,9 @@ module ErrorTracking
end
def list_sentry_projects
- { projects: sentry_client.projects }
+ handle_exceptions do
+ { projects: sentry_client.projects }
+ end
end
def issue_details(opts = {})
diff --git a/app/models/event.rb b/app/models/event.rb
index 9611019adb8..606c4d8302f 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -4,6 +4,8 @@ class Event < ApplicationRecord
include Sortable
include FromUnion
include Presentable
+ include DeleteWithLimit
+ include CreatedAtFilterable
default_scope { reorder(nil) }
@@ -76,6 +78,7 @@ class Event < ApplicationRecord
# Scopes
scope :recent, -> { reorder(id: :desc) }
scope :code_push, -> { where(action: PUSHED) }
+ scope :merged, -> { where(action: MERGED) }
scope :with_associations, -> do
# We're using preload for "push_event_payload" as otherwise the association
@@ -145,10 +148,8 @@ class Event < ApplicationRecord
Ability.allowed?(user, :read_issue, note? ? note_target : target)
elsif merge_request? || merge_request_note?
Ability.allowed?(user, :read_merge_request, note? ? note_target : target)
- elsif personal_snippet_note?
- Ability.allowed?(user, :read_personal_snippet, note_target)
- elsif project_snippet_note?
- Ability.allowed?(user, :read_project_snippet, note_target)
+ elsif personal_snippet_note? || project_snippet_note?
+ Ability.allowed?(user, :read_snippet, note_target)
elsif milestone?
Ability.allowed?(user, :read_milestone, project)
else
diff --git a/app/models/group.rb b/app/models/group.rb
index b642b177df1..ea5d46e23f4 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -467,6 +467,10 @@ class Group < Namespace
import_export_upload&.export_file
end
+ def adjourned_deletion?
+ false
+ end
+
private
def update_two_factor_requirement
diff --git a/app/models/group_deploy_token.rb b/app/models/group_deploy_token.rb
new file mode 100644
index 00000000000..221a7d768ae
--- /dev/null
+++ b/app/models/group_deploy_token.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class GroupDeployToken < ApplicationRecord
+ belongs_to :group, class_name: '::Group'
+ belongs_to :deploy_token, inverse_of: :group_deploy_tokens
+
+ validates :deploy_token, presence: true
+ validates :group, presence: true
+ validates :deploy_token_id, uniqueness: { scope: [:group_id] }
+
+ def has_access_to?(requested_project)
+ return false unless Feature.enabled?(:allow_group_deploy_token, default: true)
+
+ requested_project_group = requested_project&.group
+ return false unless requested_project_group
+ return true if requested_project_group.id == group_id
+
+ requested_project_group
+ .ancestors
+ .where(id: group_id)
+ .exists?
+ end
+end
diff --git a/app/models/group_group_link.rb b/app/models/group_group_link.rb
index 5a0d9b08cb0..58c188369da 100644
--- a/app/models/group_group_link.rb
+++ b/app/models/group_group_link.rb
@@ -10,11 +10,11 @@ class GroupGroupLink < ApplicationRecord
validates :shared_group_id, uniqueness: { scope: [:shared_with_group_id],
message: _('The group has already been shared with this group') }
validates :shared_with_group, presence: true
- validates :group_access, inclusion: { in: Gitlab::Access.values },
+ validates :group_access, inclusion: { in: Gitlab::Access.all_values },
presence: true
def self.access_options
- Gitlab::Access.options
+ Gitlab::Access.options_with_owner
end
def self.default_access
diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb
index df0e7b30f84..03f1797f4f4 100644
--- a/app/models/hooks/web_hook_log.rb
+++ b/app/models/hooks/web_hook_log.rb
@@ -3,6 +3,8 @@
class WebHookLog < ApplicationRecord
include SafeUrl
include Presentable
+ include DeleteWithLimit
+ include CreatedAtFilterable
belongs_to :web_hook
@@ -23,6 +25,10 @@ class WebHookLog < ApplicationRecord
response_status =~ /^2/
end
+ def internal_error?
+ response_status == WebHookService::InternalErrorResponse::ERROR_MESSAGE
+ end
+
private
def obfuscate_basic_auth
diff --git a/app/models/incident_management/project_incident_management_setting.rb b/app/models/incident_management/project_incident_management_setting.rb
new file mode 100644
index 00000000000..bf57c5b883f
--- /dev/null
+++ b/app/models/incident_management/project_incident_management_setting.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module IncidentManagement
+ class ProjectIncidentManagementSetting < ApplicationRecord
+ include Gitlab::Utils::StrongMemoize
+
+ belongs_to :project
+
+ validate :issue_template_exists, if: :create_issue?
+
+ def available_issue_templates
+ Gitlab::Template::IssueTemplate.all(project)
+ end
+
+ def issue_template_content
+ strong_memoize(:issue_template_content) do
+ issue_template&.content if issue_template_key.present?
+ end
+ end
+
+ private
+
+ def issue_template_exists
+ return unless issue_template_key.present?
+
+ errors.add(:issue_template_key, 'not found') unless issue_template
+ end
+
+ def issue_template
+ Gitlab::Template::IssueTemplate.find(issue_template_key, project)
+ rescue Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError
+ end
+ end
+end
diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb
index 8d3eeaf2461..3e8d0c6a778 100644
--- a/app/models/internal_id.rb
+++ b/app/models/internal_id.rb
@@ -21,7 +21,7 @@ class InternalId < ApplicationRecord
belongs_to :project
belongs_to :namespace
- enum usage: { issues: 0, merge_requests: 1, deployments: 2, milestones: 3, epics: 4, ci_pipelines: 5 }
+ enum usage: { issues: 0, merge_requests: 1, deployments: 2, milestones: 3, epics: 4, ci_pipelines: 5, operations_feature_flags: 6 }
validates :usage, presence: true
diff --git a/app/models/issue.rb b/app/models/issue.rb
index bf600278162..be702134ced 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -33,9 +33,6 @@ class Issue < ApplicationRecord
has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) { s&.project&.issues&.maximum(:iid) }
- has_many :issue_milestones
- has_many :milestones, through: :issue_milestones
-
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :merge_requests_closing_issues,
@@ -45,7 +42,7 @@ class Issue < ApplicationRecord
has_many :issue_assignees
has_many :assignees, class_name: "User", through: :issue_assignees
has_many :zoom_meetings
- has_many :user_mentions, class_name: "IssueUserMention"
+ has_many :user_mentions, class_name: "IssueUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_one :sentry_issue
accepts_nested_attributes_for :sentry_issue
@@ -147,6 +144,20 @@ class Issue < ApplicationRecord
'project_id'
end
+ def self.simple_sorts
+ super.merge(
+ {
+ 'closest_future_date' => -> { order_closest_future_date },
+ 'closest_future_date_asc' => -> { order_closest_future_date },
+ 'due_date' => -> { order_due_date_asc.with_order_id_desc },
+ 'due_date_asc' => -> { order_due_date_asc.with_order_id_desc },
+ 'due_date_desc' => -> { order_due_date_desc.with_order_id_desc },
+ 'relative_position' => -> { order_relative_position_asc.with_order_id_desc },
+ 'relative_position_asc' => -> { order_relative_position_asc.with_order_id_desc }
+ }
+ )
+ end
+
def self.sort_by_attribute(method, excluded_labels: [])
case method.to_s
when 'closest_future_date', 'closest_future_date_asc' then order_closest_future_date
@@ -158,8 +169,10 @@ class Issue < ApplicationRecord
end
end
- def self.order_by_position_and_priority
- order_labels_priority
+ # `with_cte` argument allows sorting when using CTE queries and prevents
+ # errors in postgres when using CTE search optimisation
+ def self.order_by_position_and_priority(with_cte: false)
+ order_labels_priority(with_cte: with_cte)
.reorder(Gitlab::Database.nulls_last_order('relative_position', 'ASC'),
Gitlab::Database.nulls_last_order('highest_priority', 'ASC'),
"id DESC")
@@ -173,7 +186,7 @@ class Issue < ApplicationRecord
def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{iid}"
- "#{project.to_reference(from, full: full)}#{reference}"
+ "#{project.to_reference_base(from, full: full)}#{reference}"
end
def suggested_branch_name
diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb
index 748f73373e3..8128b8a538e 100644
--- a/app/models/issue_assignee.rb
+++ b/app/models/issue_assignee.rb
@@ -3,6 +3,8 @@
class IssueAssignee < ApplicationRecord
belongs_to :issue
belongs_to :assignee, class_name: "User", foreign_key: :user_id
+
+ validates :assignee, uniqueness: { scope: :issue_id }
end
IssueAssignee.prepend_if_ee('EE::IssueAssignee')
diff --git a/app/models/issue_milestone.rb b/app/models/issue_milestone.rb
deleted file mode 100644
index da030077d87..00000000000
--- a/app/models/issue_milestone.rb
+++ /dev/null
@@ -1,6 +0,0 @@
-# frozen_string_literal: true
-
-class IssueMilestone < ApplicationRecord
- belongs_to :milestone
- belongs_to :issue
-end
diff --git a/app/models/key.rb b/app/models/key.rb
index 71188f210bb..e729ef67346 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -96,8 +96,7 @@ class Key < ApplicationRecord
def remove_from_shell
GitlabShellWorker.perform_async(
:remove_key,
- shell_id,
- key
+ shell_id
)
end
diff --git a/app/models/label.rb b/app/models/label.rb
index dbb96a2b9da..632207701d8 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -42,6 +42,22 @@ class Label < ApplicationRecord
scope :order_name_desc, -> { reorder(title: :desc) }
scope :subscribed_by, ->(user_id) { joins(:subscriptions).where(subscriptions: { user_id: user_id, subscribed: true }) }
+ scope :top_labels_by_target, -> (target_relation) {
+ label_id_column = arel_table[:id]
+
+ # Window aggregation to count labels
+ count_by_id = Arel::Nodes::Over.new(
+ Arel::Nodes::NamedFunction.new('count', [label_id_column]),
+ Arel::Nodes::Window.new.partition(label_id_column)
+ ).as('count_by_id')
+
+ select(arel_table[Arel.star], count_by_id)
+ .joins(:label_links)
+ .merge(LabelLink.where(target: target_relation))
+ .reorder(count_by_id: :desc)
+ .distinct
+ }
+
def self.prioritized(project)
joins(:priorities)
.where(label_priorities: { project_id: project })
@@ -225,7 +241,7 @@ class Label < ApplicationRecord
reference = "#{self.class.reference_prefix}#{format_reference}"
if from
- "#{from.to_reference(target_project, full: full)}#{reference}"
+ "#{from.to_reference_base(target_project, full: full)}#{reference}"
else
reference
end
diff --git a/app/models/label_link.rb b/app/models/label_link.rb
index ffc0afd8e85..5ae1e88e14e 100644
--- a/app/models/label_link.rb
+++ b/app/models/label_link.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class LabelLink < ApplicationRecord
+ include BulkInsertSafe
include Importable
belongs_to :target, polymorphic: true, inverse_of: :label_links # rubocop:disable Cop/PolymorphicAssociations
diff --git a/app/models/lfs_objects_project.rb b/app/models/lfs_objects_project.rb
index e45c56b6394..68ef84223c5 100644
--- a/app/models/lfs_objects_project.rb
+++ b/app/models/lfs_objects_project.rb
@@ -16,6 +16,8 @@ class LfsObjectsProject < ApplicationRecord
design: 2 ## EE-specific
}
+ scope :project_id_in, ->(ids) { where(project_id: ids) }
+
private
def update_project_statistics
diff --git a/app/models/member.rb b/app/models/member.rb
index 2654453cf3f..a26a0615a6e 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -75,6 +75,7 @@ class Member < ApplicationRecord
scope :reporters, -> { active.where(access_level: REPORTER) }
scope :developers, -> { active.where(access_level: DEVELOPER) }
scope :maintainers, -> { active.where(access_level: MAINTAINER) }
+ scope :non_guests, -> { where('members.access_level > ?', GUEST) }
scope :masters, -> { maintainers } # @deprecated
scope :owners, -> { active.where(access_level: OWNER) }
scope :owners_and_maintainers, -> { active.where(access_level: [OWNER, MAINTAINER]) }
@@ -82,6 +83,7 @@ class Member < ApplicationRecord
scope :with_user, -> (user) { where(user: user) }
scope :with_source_id, ->(source_id) { where(source_id: source_id) }
+ scope :including_source, -> { includes(:source) }
scope :order_name_asc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'ASC')) }
scope :order_name_desc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'DESC')) }
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 7162ba08a76..6c32bdadfa8 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -24,6 +24,7 @@ class MergeRequest < ApplicationRecord
self.reactive_cache_key = ->(model) { [model.project.id, model.iid] }
self.reactive_cache_refresh_interval = 10.minutes
self.reactive_cache_lifetime = 10.minutes
+ self.reactive_cache_hard_limit = 20.megabytes
SORTING_PREFERENCE_FIELD = :merge_requests_sort
@@ -34,9 +35,8 @@ class MergeRequest < ApplicationRecord
has_internal_id :iid, scope: :target_project, track_if: -> { !importing? }, init: ->(s) { s&.target_project&.merge_requests&.maximum(:iid) }
has_many :merge_request_diffs
-
- has_many :merge_request_milestones
- has_many :milestones, through: :merge_request_milestones
+ has_many :merge_request_context_commits
+ has_many :merge_request_context_commit_diff_files, through: :merge_request_context_commits, source: :diff_files
has_one :merge_request_diff,
-> { order('merge_request_diffs.id DESC') }, inverse_of: :merge_request
@@ -74,7 +74,7 @@ class MergeRequest < ApplicationRecord
has_many :merge_request_assignees
has_many :assignees, class_name: "User", through: :merge_request_assignees
- has_many :user_mentions, class_name: "MergeRequestUserMention"
+ has_many :user_mentions, class_name: "MergeRequestUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :deployment_merge_requests
@@ -160,20 +160,25 @@ class MergeRequest < ApplicationRecord
state_machine :merge_status, initial: :unchecked do
event :mark_as_unchecked do
- transition [:can_be_merged, :unchecked] => :unchecked
+ transition [:can_be_merged, :checking, :unchecked] => :unchecked
transition [:cannot_be_merged, :cannot_be_merged_recheck] => :cannot_be_merged_recheck
end
+ event :mark_as_checking do
+ transition [:unchecked, :cannot_be_merged_recheck] => :checking
+ end
+
event :mark_as_mergeable do
- transition [:unchecked, :cannot_be_merged_recheck] => :can_be_merged
+ transition [:unchecked, :cannot_be_merged_recheck, :checking] => :can_be_merged
end
event :mark_as_unmergeable do
- transition [:unchecked, :cannot_be_merged_recheck] => :cannot_be_merged
+ transition [:unchecked, :cannot_be_merged_recheck, :checking] => :cannot_be_merged
end
state :unchecked
state :cannot_be_merged_recheck
+ state :checking
state :can_be_merged
state :cannot_be_merged
@@ -191,7 +196,7 @@ class MergeRequest < ApplicationRecord
# rubocop: enable CodeReuse/ServiceClass
def check_state?(merge_status)
- [:unchecked, :cannot_be_merged_recheck].include?(merge_status.to_sym)
+ [:unchecked, :cannot_be_merged_recheck, :checking].include?(merge_status.to_sym)
end
end
@@ -223,6 +228,9 @@ class MergeRequest < ApplicationRecord
scope :by_merge_commit_sha, -> (sha) do
where(merge_commit_sha: sha)
end
+ scope :by_cherry_pick_sha, -> (sha) do
+ joins(:notes).where(notes: { commit_id: sha })
+ end
scope :join_project, -> { joins(:target_project) }
scope :references_project, -> { references(:target_project) }
scope :with_api_entity_associations, -> {
@@ -388,7 +396,11 @@ class MergeRequest < ApplicationRecord
def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{iid}"
- "#{project.to_reference(from, full: full)}#{reference}"
+ "#{project.to_reference_base(from, full: full)}#{reference}"
+ end
+
+ def context_commits
+ @context_commits ||= merge_request_context_commits.map(&:to_commit)
end
def commits(limit: nil)
@@ -698,7 +710,7 @@ class MergeRequest < ApplicationRecord
end
def validate_branch_name(attr)
- return unless changes_include?(attr)
+ return unless will_save_change_to_attribute?(attr)
branch = read_attribute(attr)
@@ -812,13 +824,23 @@ class MergeRequest < ApplicationRecord
MergeRequests::ReloadDiffsService.new(self, current_user).execute
end
- def check_mergeability
- return if Feature.enabled?(:merge_requests_conditional_mergeability_check, default_enabled: true) && !recheck_merge_status?
+ def check_mergeability(async: false)
+ return unless recheck_merge_status?
- MergeRequests::MergeabilityCheckService.new(self).execute(retry_lease: false)
+ check_service = MergeRequests::MergeabilityCheckService.new(self)
+
+ if async && Feature.enabled?(:async_merge_request_check_mergeability, project)
+ check_service.async_execute
+ else
+ check_service.execute(retry_lease: false)
+ end
end
# rubocop: enable CodeReuse/ServiceClass
+ def diffable_merge_ref?
+ Feature.enabled?(:diff_compare_with_head, target_project) && can_be_merged? && merge_ref_head.present?
+ end
+
# Returns boolean indicating the merge_status should be rechecked in order to
# switch to either can_be_merged or cannot_be_merged.
def recheck_merge_status?
@@ -1142,7 +1164,7 @@ class MergeRequest < ApplicationRecord
# Since deployments run on a merge request ref (e.g. `refs/merge-requests/:iid/head`),
# we cannot look up environments with source branch name.
def environments
- return Environment.none unless actual_head_pipeline&.triggered_by_merge_request?
+ return Environment.none unless actual_head_pipeline&.merge_request?
actual_head_pipeline.environments
end
@@ -1187,12 +1209,10 @@ class MergeRequest < ApplicationRecord
end
def in_locked_state
- begin
- lock_mr
- yield
- ensure
- unlock_mr
- end
+ lock_mr
+ yield
+ ensure
+ unlock_mr
end
def diverged_commits_count
diff --git a/app/models/merge_request/pipelines.rb b/app/models/merge_request/pipelines.rb
index c32f29a9304..72756e8e9d0 100644
--- a/app/models/merge_request/pipelines.rb
+++ b/app/models/merge_request/pipelines.rb
@@ -61,6 +61,8 @@ class MergeRequest::Pipelines
pipelines.joins(shas_table)
end
+ # NOTE: this method returns only parent merge request pipelines.
+ # Child merge request pipelines have a different source.
def triggered_by_merge_request
source_project.ci_pipelines
.where(source: :merge_request_event, merge_request: merge_request)
diff --git a/app/models/merge_request_assignee.rb b/app/models/merge_request_assignee.rb
index f0e6be51b7f..fe642bee8e2 100644
--- a/app/models/merge_request_assignee.rb
+++ b/app/models/merge_request_assignee.rb
@@ -3,4 +3,6 @@
class MergeRequestAssignee < ApplicationRecord
belongs_to :merge_request
belongs_to :assignee, class_name: "User", foreign_key: :user_id
+
+ validates :assignee, uniqueness: { scope: :merge_request_id }
end
diff --git a/app/models/merge_request_context_commit.rb b/app/models/merge_request_context_commit.rb
new file mode 100644
index 00000000000..eecb10e6dbc
--- /dev/null
+++ b/app/models/merge_request_context_commit.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class MergeRequestContextCommit < ApplicationRecord
+ include CachedCommit
+ include ShaAttribute
+
+ belongs_to :merge_request
+ has_many :diff_files, class_name: 'MergeRequestContextCommitDiffFile'
+
+ sha_attribute :sha
+
+ validates :sha, presence: true
+ validates :sha, uniqueness: { message: 'has already been added' }
+
+ # delete all MergeRequestContextCommit & MergeRequestContextCommitDiffFile for given merge_request & commit SHAs
+ def self.delete_bulk(merge_request, commits)
+ commit_ids = commits.map(&:sha)
+ merge_request.merge_request_context_commits.where(sha: commit_ids).delete_all
+ end
+
+ # create MergeRequestContextCommit by given commit sha and it's diff file record
+ def self.bulk_insert(*args)
+ Gitlab::Database.bulk_insert('merge_request_context_commits', *args)
+ end
+
+ def to_commit
+ # Here we are storing the commit sha because to_hash removes the sha parameter and we lose
+ # the reference, this happens because we are storing the ID in db and the Commit class replaces
+ # id with sha and removes it, so in our case it will be some incremented integer which is not
+ # what we want
+ commit_hash = attributes.except('id').to_hash
+ commit_hash['id'] = sha
+ Commit.from_hash(commit_hash, merge_request.target_project)
+ end
+end
diff --git a/app/models/merge_request_context_commit_diff_file.rb b/app/models/merge_request_context_commit_diff_file.rb
new file mode 100644
index 00000000000..9dce7c53ab6
--- /dev/null
+++ b/app/models/merge_request_context_commit_diff_file.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class MergeRequestContextCommitDiffFile < ApplicationRecord
+ include Gitlab::EncodingHelper
+ include ShaAttribute
+ include DiffFile
+
+ belongs_to :merge_request_context_commit, inverse_of: :diff_files
+
+ sha_attribute :sha
+ alias_attribute :id, :sha
+
+ # create MergeRequestContextCommitDiffFile by given diff file record(s)
+ def self.bulk_insert(*args)
+ Gitlab::Database.bulk_insert('merge_request_context_commit_diff_files', *args)
+ end
+end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index fa633a1a725..ffe95e8f034 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -560,6 +560,10 @@ class MergeRequestDiff < ApplicationRecord
opening_external_diff do
collection = merge_request_diff_files
+ if options[:include_context_commits]
+ collection += merge_request.merge_request_context_commit_diff_files
+ end
+
if paths = options[:paths]
collection = collection.where('old_path IN (?) OR new_path IN (?)', paths, paths)
end
diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb
index b897bbc8cf5..460b394f067 100644
--- a/app/models/merge_request_diff_commit.rb
+++ b/app/models/merge_request_diff_commit.rb
@@ -1,7 +1,9 @@
# frozen_string_literal: true
class MergeRequestDiffCommit < ApplicationRecord
+ include BulkInsertSafe
include ShaAttribute
+ include CachedCommit
belongs_to :merge_request_diff
@@ -9,8 +11,6 @@ class MergeRequestDiffCommit < ApplicationRecord
alias_attribute :id, :sha
def self.create_bulk(merge_request_diff_id, commits)
- sha_attribute = Gitlab::Database::ShaAttribute.new
-
rows = commits.map.with_index do |commit, index|
# See #parent_ids.
commit_hash = commit.to_hash.except(:parent_ids)
@@ -19,7 +19,7 @@ class MergeRequestDiffCommit < ApplicationRecord
commit_hash.merge(
merge_request_diff_id: merge_request_diff_id,
relative_order: index,
- sha: sha_attribute.serialize(sha), # rubocop:disable Cop/ActiveRecordSerialize
+ sha: Gitlab::Database::ShaAttribute.serialize(sha), # rubocop:disable Cop/ActiveRecordSerialize
authored_date: Gitlab::Database.sanitize_timestamp(commit_hash[:authored_date]),
committed_date: Gitlab::Database.sanitize_timestamp(commit_hash[:committed_date])
)
@@ -27,16 +27,4 @@ class MergeRequestDiffCommit < ApplicationRecord
Gitlab::Database.bulk_insert(self.table_name, rows)
end
-
- def to_hash
- Gitlab::Git::Commit::SERIALIZE_KEYS.each_with_object({}) do |key, hash|
- hash[key] = public_send(key) # rubocop:disable GitlabSecurity/PublicSend
- end
- end
-
- # We don't save these, because they would need a table or a serialised
- # field. They aren't used anywhere, so just pretend the commit has no parents.
- def parent_ids
- []
- end
end
diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb
index 14c86ec69da..23319445a38 100644
--- a/app/models/merge_request_diff_file.rb
+++ b/app/models/merge_request_diff_file.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class MergeRequestDiffFile < ApplicationRecord
+ include BulkInsertSafe
include Gitlab::EncodingHelper
include DiffFile
diff --git a/app/models/merge_request_milestone.rb b/app/models/merge_request_milestone.rb
deleted file mode 100644
index 4fa1d1dcb33..00000000000
--- a/app/models/merge_request_milestone.rb
+++ /dev/null
@@ -1,6 +0,0 @@
-# frozen_string_literal: true
-
-class MergeRequestMilestone < ApplicationRecord
- belongs_to :milestone
- belongs_to :merge_request
-end
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index 5da92fc4bc5..29c621c54d0 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -39,9 +39,6 @@ class Milestone < ApplicationRecord
has_many :merge_requests
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
- has_many :issue_milestones
- has_many :merge_request_milestones
-
scope :of_projects, ->(ids) { where(project_id: ids) }
scope :of_groups, ->(ids) { where(group_id: ids) }
scope :active, -> { with_state(:active) }
@@ -59,6 +56,12 @@ class Milestone < ApplicationRecord
where(project_id: projects).or(where(group_id: groups))
end
+ scope :within_timeframe, -> (start_date, end_date) do
+ where('start_date is not NULL or due_date is not NULL')
+ .where('start_date is NULL or start_date <= ?', end_date)
+ .where('due_date is NULL or due_date >= ?', start_date)
+ end
+
scope :order_by_name_asc, -> { order(Arel::Nodes::Ascending.new(arel_table[:title].lower)) }
scope :reorder_by_due_date_asc, -> { reorder(Gitlab::Database.nulls_last_order('due_date', 'ASC')) }
@@ -228,7 +231,7 @@ class Milestone < ApplicationRecord
reference = "#{self.class.reference_prefix}#{format_reference}"
if project
- "#{project.to_reference(from, full: full)}#{reference}"
+ "#{project.to_reference_base(from, full: full)}#{reference}"
else
reference
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 621a98e9ab6..efe14a3e614 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -131,6 +131,11 @@ class Namespace < ApplicationRecord
name = host.delete_suffix(gitlab_host)
Namespace.find_by_full_path(name)
end
+
+ # overridden in ee
+ def reset_ci_minutes!(namespace_id)
+ false
+ end
end
def visibility_level_field
diff --git a/app/models/note.rb b/app/models/note.rb
index 11237a5049d..97e84bb79f6 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -124,7 +124,7 @@ class Note < ApplicationRecord
scope :inc_author, -> { includes(:author) }
scope :inc_relations_for_view, -> do
includes(:project, { author: :status }, :updated_by, :resolved_by, :award_emoji,
- :system_note_metadata, :note_diff_file, :suggestions)
+ { system_note_metadata: :description_version }, :note_diff_file, :suggestions)
end
scope :with_notes_filter, -> (notes_filter) do
@@ -157,6 +157,7 @@ class Note < ApplicationRecord
after_save :expire_etag_cache, unless: :importing?
after_save :touch_noteable, unless: :importing?
after_destroy :expire_etag_cache
+ after_save :store_mentions!, if: :any_mentionable_attributes_changed?
class << self
def model_name
@@ -367,7 +368,7 @@ class Note < ApplicationRecord
end
def noteable_ability_name
- for_snippet? ? noteable.class.name.underscore : noteable_type.demodulize.underscore
+ for_snippet? ? 'snippet' : noteable_type.demodulize.underscore
end
def can_be_discussion_note?
@@ -498,6 +499,8 @@ class Note < ApplicationRecord
end
def user_mentions
+ return Note.none unless noteable.present?
+
noteable.user_mentions.where(note: self)
end
@@ -506,6 +509,8 @@ class Note < ApplicationRecord
# Using this method followed by a call to `save` may result in ActiveRecord::RecordNotUnique exception
# in a multithreaded environment. Make sure to use it within a `safe_ensure_unique` block.
def model_user_mention
+ return if user_mentions.is_a?(ActiveRecord::NullRelation)
+
user_mentions.first_or_initialize
end
diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb
index 2b3443f24d7..e2c362538eb 100644
--- a/app/models/notification_setting.rb
+++ b/app/models/notification_setting.rb
@@ -21,9 +21,14 @@ class NotificationSetting < ApplicationRecord
# pending delete).
#
scope :for_projects, -> do
- includes(:project).references(:projects).where(source_type: 'Project').where.not(projects: { id: nil, pending_delete: true })
+ includes(:project).references(:projects)
+ .where(source_type: 'Project')
+ .where.not(projects: { id: nil })
+ .where.not(projects: { pending_delete: true })
end
+ scope :preload_source_route, -> { preload(source: [:route]) }
+
EMAIL_EVENTS = [
:new_release,
:new_note,
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index dd2cafd9a35..05cf427184c 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -6,13 +6,15 @@ class PagesDomain < ApplicationRecord
SSL_RENEWAL_THRESHOLD = 30.days.freeze
enum certificate_source: { user_provided: 0, gitlab_provided: 1 }, _prefix: :certificate
- enum domain_type: { instance: 0, group: 1, project: 2 }, _prefix: :domain_type
+ enum scope: { instance: 0, group: 1, project: 2 }, _prefix: :scope
+ enum usage: { pages: 0, serverless: 1 }, _prefix: :usage
belongs_to :project
has_many :acme_orders, class_name: "PagesDomainAcmeOrder"
validates :domain, hostname: { allow_numeric_hostname: true }
validates :domain, uniqueness: { case_sensitive: false }
+ validates :certificate, :key, presence: true, if: :usage_serverless?
validates :certificate, presence: { message: 'must be present if HTTPS-only is enabled' },
if: :certificate_should_be_present?
validates :certificate, certificate: true, if: ->(domain) { domain.certificate.present? }
@@ -26,8 +28,9 @@ class PagesDomain < ApplicationRecord
validate :validate_intermediates, if: ->(domain) { domain.certificate.present? && domain.certificate_changed? }
default_value_for(:auto_ssl_enabled, allow_nil: false) { ::Gitlab::LetsEncrypt.enabled? }
- default_value_for :domain_type, allow_nil: false, value: :project
+ default_value_for :scope, allow_nil: false, value: :project
default_value_for :wildcard, allow_nil: false, value: false
+ default_value_for :usage, allow_nil: false, value: :pages
attr_encrypted :key,
mode: :per_attribute_iv_and_salt,
@@ -60,6 +63,10 @@ class PagesDomain < ApplicationRecord
scope :for_removal, -> { where("remove_at < ?", Time.now) }
+ scope :with_logging_info, -> { includes(project: [:namespace, :route]) }
+
+ scope :instance_serverless, -> { where(wildcard: true, scope: :instance, usage: :serverless) }
+
def verified?
!!verified_at
end
@@ -220,7 +227,7 @@ class PagesDomain < ApplicationRecord
# rubocop: disable CodeReuse/ServiceClass
def update_daemon
- return if domain_type_instance?
+ return if usage_serverless?
::Projects::UpdatePagesConfigurationService.new(project).execute
end
@@ -283,3 +290,5 @@ class PagesDomain < ApplicationRecord
!auto_ssl_enabled? && project&.pages_https_only?
end
end
+
+PagesDomain.prepend_if_ee('::EE::PagesDomain')
diff --git a/app/models/performance_monitoring/prometheus_dashboard.rb b/app/models/performance_monitoring/prometheus_dashboard.rb
new file mode 100644
index 00000000000..5f2df444fd0
--- /dev/null
+++ b/app/models/performance_monitoring/prometheus_dashboard.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module PerformanceMonitoring
+ class PrometheusDashboard
+ include ActiveModel::Model
+
+ attr_accessor :dashboard, :panel_groups
+
+ validates :dashboard, presence: true
+ validates :panel_groups, presence: true
+
+ def self.from_json(json_content)
+ dashboard = new(
+ dashboard: json_content['dashboard'],
+ panel_groups: json_content['panel_groups'].map { |group| PrometheusPanelGroup.from_json(group) }
+ )
+
+ dashboard.tap(&:validate!)
+ end
+
+ def to_yaml
+ self.as_json(only: valid_attributes).to_yaml
+ end
+
+ private
+
+ def valid_attributes
+ %w(panel_groups panels metrics group priority type title y_label weight id unit label query query_range dashboard)
+ end
+ end
+end
diff --git a/app/models/performance_monitoring/prometheus_metric.rb b/app/models/performance_monitoring/prometheus_metric.rb
new file mode 100644
index 00000000000..7b8bef906fa
--- /dev/null
+++ b/app/models/performance_monitoring/prometheus_metric.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module PerformanceMonitoring
+ class PrometheusMetric
+ include ActiveModel::Model
+
+ attr_accessor :id, :unit, :label, :query, :query_range
+
+ validates :unit, presence: true
+ validates :query, presence: true, unless: :query_range
+ validates :query_range, presence: true, unless: :query
+
+ def self.from_json(json_content)
+ metric = PrometheusMetric.new(
+ id: json_content['id'],
+ unit: json_content['unit'],
+ label: json_content['label'],
+ query: json_content['query'],
+ query_range: json_content['query_range']
+ )
+
+ metric.tap(&:validate!)
+ end
+ end
+end
diff --git a/app/models/performance_monitoring/prometheus_panel.rb b/app/models/performance_monitoring/prometheus_panel.rb
new file mode 100644
index 00000000000..c03218b4219
--- /dev/null
+++ b/app/models/performance_monitoring/prometheus_panel.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module PerformanceMonitoring
+ class PrometheusPanel
+ include ActiveModel::Model
+
+ attr_accessor :type, :title, :y_label, :weight, :metrics
+
+ validates :title, presence: true
+ validates :metrics, presence: true
+
+ def self.from_json(json_content)
+ panel = new(
+ type: json_content['type'],
+ title: json_content['title'],
+ y_label: json_content['y_label'],
+ weight: json_content['weight'],
+ metrics: json_content['metrics'].map { |metric| PrometheusMetric.from_json(metric) }
+ )
+
+ panel.tap(&:validate!)
+ end
+ end
+end
diff --git a/app/models/performance_monitoring/prometheus_panel_group.rb b/app/models/performance_monitoring/prometheus_panel_group.rb
new file mode 100644
index 00000000000..e672545fce3
--- /dev/null
+++ b/app/models/performance_monitoring/prometheus_panel_group.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module PerformanceMonitoring
+ class PrometheusPanelGroup
+ include ActiveModel::Model
+
+ attr_accessor :group, :priority, :panels
+
+ validates :group, presence: true
+ validates :panels, presence: true
+
+ def self.from_json(json_content)
+ panel_group = new(
+ group: json_content['group'],
+ priority: json_content['priority'],
+ panels: json_content['panels'].map { |panel| PrometheusPanel.from_json(panel) }
+ )
+
+ panel_group.tap(&:validate!)
+ end
+ end
+end
diff --git a/app/models/personal_snippet.rb b/app/models/personal_snippet.rb
index 1b5be8698b1..5940265b17a 100644
--- a/app/models/personal_snippet.rb
+++ b/app/models/personal_snippet.rb
@@ -2,4 +2,8 @@
class PersonalSnippet < Snippet
include WithUploads
+
+ def web_url(only_path: nil)
+ Gitlab::Routing.url_helpers.snippet_url(self, only_path: only_path)
+ end
end
diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb
index 25eab6e4e03..94992adfd1e 100644
--- a/app/models/pool_repository.rb
+++ b/app/models/pool_repository.rb
@@ -110,8 +110,8 @@ class PoolRepository < ApplicationRecord
end
def storage
- Storage::HashedProject
- .new(self, prefix: Storage::HashedProject::POOL_PATH_PREFIX)
+ Storage::Hashed
+ .new(self, prefix: Storage::Hashed::POOL_PATH_PREFIX)
end
end
diff --git a/app/models/project.rb b/app/models/project.rb
index b2f20731c65..e16bd568153 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -9,7 +9,6 @@ class Project < ApplicationRecord
include AccessRequestable
include Avatarable
include CacheMarkdownField
- include Referable
include Sortable
include AfterCommitQueue
include CaseSensitivity
@@ -19,6 +18,7 @@ class Project < ApplicationRecord
include ProjectFeaturesCompatibility
include SelectForProjectAuthorization
include Presentable
+ include HasRepository
include Routable
include GroupDescendant
include Gitlab::SQL::Pattern
@@ -137,6 +137,7 @@ class Project < ApplicationRecord
has_many :boards
# Project services
+ has_one :alerts_service
has_one :campfire_service
has_one :discord_service
has_one :drone_ci_service
@@ -186,9 +187,11 @@ class Project < ApplicationRecord
has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project
has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :project_repository, inverse_of: :project
+ has_one :incident_management_setting, inverse_of: :project, class_name: 'IncidentManagement::ProjectIncidentManagementSetting'
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
+ has_one :project_setting, ->(project) { where_or_create_by(project: project) }, 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
@@ -243,6 +246,7 @@ class Project < ApplicationRecord
has_many :management_clusters, class_name: 'Clusters::Cluster', foreign_key: :management_project_id, inverse_of: :management_project
has_many :prometheus_metrics
+ has_many :prometheus_alerts, inverse_of: :project
# Container repositories need to remove data from the container registry,
# which is not managed by the DB. Hence we're still using dependent: :destroy
@@ -313,20 +317,21 @@ class Project < ApplicationRecord
allow_destroy: true,
reject_if: ->(attrs) { attrs[:id].blank? && attrs[:url].blank? }
+ accepts_nested_attributes_for :incident_management_setting, update_only: true
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
+ accepts_nested_attributes_for :prometheus_service, update_only: true
delegate :feature_available?, :builds_enabled?, :wiki_enabled?,
:merge_requests_enabled?, :forking_enabled?, :issues_enabled?,
:pages_enabled?, :public_pages?, :private_pages?,
:merge_requests_access_level, :forking_access_level, :issues_access_level,
:wiki_access_level, :snippets_access_level, :builds_access_level,
- :repository_access_level,
+ :repository_access_level, :pages_access_level,
to: :project_feature, allow_nil: true
delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?,
prefix: :import, to: :import_state, allow_nil: true
- delegate :base_dir, :disk_path, to: :storage
delegate :no_import?, to: :import_state, allow_nil: true
delegate :name, to: :owner, allow_nil: true, prefix: true
delegate :members, to: :team, prefix: true
@@ -338,6 +343,7 @@ class Project < ApplicationRecord
delegate :last_pipeline, to: :commit, allow_nil: true
delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true
delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings, prefix: :ci
+ delegate :forward_deployment_enabled, :forward_deployment_enabled=, :forward_deployment_enabled?, to: :ci_cd_settings
# Validations
validates :creator, presence: true, on: :create
@@ -357,6 +363,8 @@ class Project < ApplicationRecord
project_path: true,
length: { maximum: 255 }
+ validates :project_feature, presence: true
+
validates :namespace, presence: true
validates :name, uniqueness: { scope: :namespace_id }
validates :import_url, public_url: { schemes: ->(project) { project.persisted? ? VALID_MIRROR_PROTOCOLS : VALID_IMPORT_PROTOCOLS },
@@ -394,9 +402,11 @@ class Project < ApplicationRecord
# last_activity_at is throttled every minute, but last_repository_updated_at is updated with every push
scope :sorted_by_activity, -> { reorder(Arel.sql("GREATEST(COALESCE(last_activity_at, '1970-01-01'), COALESCE(last_repository_updated_at, '1970-01-01')) DESC")) }
- scope :sorted_by_stars_desc, -> { reorder(star_count: :desc) }
- scope :sorted_by_stars_asc, -> { reorder(star_count: :asc) }
+ scope :sorted_by_stars_desc, -> { reorder(self.arel_table['star_count'].desc) }
+ scope :sorted_by_stars_asc, -> { reorder(self.arel_table['star_count'].asc) }
scope :sorted_by_name_asc_limited, ->(limit) { reorder(name: :asc).limit(limit) }
+ # Sometimes queries (e.g. using CTEs) require explicit disambiguation with table name
+ scope :projects_order_id_desc, -> { reorder(self.arel_table['id'].desc) }
scope :in_namespace, ->(namespace_ids) { where(namespace_id: namespace_ids) }
scope :personal, ->(user) { where(namespace_id: user.namespace_id) }
@@ -411,6 +421,8 @@ class Project < ApplicationRecord
scope :with_project_feature, -> { joins('LEFT JOIN project_features ON projects.id = project_features.project_id') }
scope :inc_routes, -> { includes(:route, namespace: :route) }
scope :with_statistics, -> { includes(:statistics) }
+ scope :with_namespace, -> { includes(:namespace) }
+ scope :with_import_state, -> { includes(:import_state) }
scope :with_service, ->(service) { joins(service).eager_load(service) }
scope :with_shared_runners, -> { where(shared_runners_enabled: true) }
scope :with_container_registry, -> { where(container_registry_enabled: true) }
@@ -449,6 +461,9 @@ class Project < ApplicationRecord
scope :with_issues_enabled, -> { with_feature_enabled(:issues) }
scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) }
scope :with_merge_requests_available_for_user, ->(current_user) { with_feature_available_for_user(:merge_requests, current_user) }
+ scope :with_issues_or_mrs_available_for_user, -> (user) do
+ with_issues_available_for_user(user).or(with_merge_requests_available_for_user(user))
+ end
scope :with_merge_requests_enabled, -> { with_feature_enabled(:merge_requests) }
scope :with_remote_mirrors, -> { joins(:remote_mirrors).where(remote_mirrors: { enabled: true }).distinct }
scope :with_limit, -> (maximum) { limit(maximum) }
@@ -541,6 +556,11 @@ class Project < ApplicationRecord
)
end
+ def self.wrap_with_cte(collection)
+ cte = Gitlab::SQL::CTE.new(:projects_cte, collection)
+ Project.with(cte.to_arel).from(cte.alias_to(Project.arel_table))
+ end
+
scope :active, -> { joins(:issues, :notes, :merge_requests).order('issues.created_at, notes.created_at, merge_requests.created_at DESC') }
scope :abandoned, -> { where('projects.last_activity_at < ?', 6.months.ago) }
@@ -582,9 +602,9 @@ class Project < ApplicationRecord
# pass a string to avoid AR adding the table name
reorder('project_statistics.storage_size DESC, projects.id DESC')
when 'latest_activity_desc'
- reorder(last_activity_at: :desc)
+ reorder(self.arel_table['last_activity_at'].desc)
when 'latest_activity_asc'
- reorder(last_activity_at: :asc)
+ reorder(self.arel_table['last_activity_at'].asc)
when 'stars_desc'
sorted_by_stars_desc
when 'stars_asc'
@@ -751,8 +771,8 @@ class Project < ApplicationRecord
Feature.enabled?(:unlink_fork_network_upon_visibility_decrease, self, default_enabled: true)
end
- def empty_repo?
- repository.empty?
+ def context_commits_enabled?
+ Feature.enabled?(:context_commits, default_enabled: true)
end
def team
@@ -782,18 +802,6 @@ class Project < ApplicationRecord
has_root_container_repository_tags?
end
- def commit(ref = 'HEAD')
- repository.commit(ref)
- end
-
- def commit_by(oid:)
- repository.commit_by(oid: oid)
- end
-
- def commits_by(oids:)
- repository.commits_by(oids: oids)
- end
-
# ref can't be HEAD, can only be branch/tag name
def latest_successful_build_for_ref(job_name, ref = default_branch)
return unless ref
@@ -894,7 +902,9 @@ class Project < ApplicationRecord
if Gitlab::UrlSanitizer.valid?(value)
import_url = Gitlab::UrlSanitizer.new(value)
super(import_url.sanitized_url)
- create_or_update_import_data(credentials: import_url.credentials)
+
+ credentials = import_url.credentials.to_h.transform_values { |value| CGI.unescape(value.to_s) }
+ create_or_update_import_data(credentials: credentials)
else
super(value)
end
@@ -1056,12 +1066,19 @@ class Project < ApplicationRecord
end
end
- def to_reference_with_postfix
- "#{to_reference(full: true)}#{self.class.reference_postfix}"
+ # Produce a valid reference (see Referable#to_reference)
+ #
+ # NB: For projects, all references are 'full' - i.e. they all include the
+ # full_path, rather than just the project name. For this reason, we ignore
+ # the value of `full:` passed to this method, which is part of the Referable
+ # interface.
+ def to_reference(from = nil, full: false)
+ base = to_reference_base(from, full: true)
+ "#{base}#{self.class.reference_postfix}"
end
# `from` argument can be a Namespace or Project.
- def to_reference(from = nil, full: false)
+ def to_reference_base(from = nil, full: false)
if full || cross_namespace_reference?(from)
full_path
elsif cross_project_reference?(from)
@@ -1334,48 +1351,6 @@ class Project < ApplicationRecord
services.public_send(hooks_scope).any? # rubocop:disable GitlabSecurity/PublicSend
end
- def valid_repo?
- repository.exists?
- rescue
- errors.add(:path, _('Invalid repository path'))
- false
- end
-
- def url_to_repo
- gitlab_shell.url_to_repo(full_path)
- end
-
- def repo_exists?
- strong_memoize(:repo_exists) do
- repository.exists?
- rescue
- false
- end
- end
-
- def root_ref?(branch)
- repository.root_ref == branch
- end
-
- def ssh_url_to_repo
- url_to_repo
- end
-
- def http_url_to_repo
- 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
def lfs_http_url_to_repo(_)
http_url_to_repo
@@ -1391,6 +1366,10 @@ class Project < ApplicationRecord
forked_from_project || fork_network&.root_project
end
+ # TODO: Remove this method once all LfsObjectsProject records are backfilled
+ # for forks.
+ #
+ # See https://gitlab.com/gitlab-org/gitlab/issues/122002 for more info.
def lfs_storage_project
@lfs_storage_project ||= begin
result = self
@@ -1403,14 +1382,27 @@ class Project < ApplicationRecord
end
end
- # This will return all `lfs_objects` that are accessible to the project.
- # So this might be `self.lfs_objects` if the project is not part of a fork
- # network, or it is the base of the fork network.
+ # This will return all `lfs_objects` that are accessible to the project and
+ # the fork source. This is needed since older forks won't have access to some
+ # LFS objects directly and have to get it from the fork source.
#
- # TODO: refactor this to get the correct lfs objects when implementing
- # https://gitlab.com/gitlab-org/gitlab-foss/issues/39769
+ # TODO: Remove this method once all LfsObjectsProject records are backfilled
+ # for forks. At that point, projects can look at their own `lfs_objects`.
+ #
+ # See https://gitlab.com/gitlab-org/gitlab/issues/122002 for more info.
def all_lfs_objects
- lfs_storage_project.lfs_objects
+ LfsObject
+ .distinct
+ .joins(:lfs_objects_projects)
+ .where(lfs_objects_projects: { project_id: [self, lfs_storage_project] })
+ end
+
+ # TODO: Call `#lfs_objects` instead once all LfsObjectsProject records are
+ # backfilled. At that point, projects can look at their own `lfs_objects`.
+ #
+ # See https://gitlab.com/gitlab-org/gitlab/issues/122002 for more info.
+ def lfs_objects_oids
+ all_lfs_objects.pluck(:oid)
end
def personal?
@@ -1515,15 +1507,6 @@ class Project < ApplicationRecord
end
end
- def default_branch
- @default_branch ||= repository.root_ref
- end
-
- def reload_default_branch
- @default_branch = nil
- default_branch
- end
-
def visibility_level_field
:visibility_level
end
@@ -1560,10 +1543,6 @@ class Project < ApplicationRecord
create_repository(force: true) unless repository_exists?
end
- def repository_exists?
- !!repository.exists?
- end
-
def wiki_repository_exists?
wiki.repository_exists?
end
@@ -1936,6 +1915,8 @@ class Project < ApplicationRecord
.append(key: 'GITLAB_CI', value: 'true')
.append(key: 'CI_SERVER_URL', value: Gitlab.config.gitlab.url)
.append(key: 'CI_SERVER_HOST', value: Gitlab.config.gitlab.host)
+ .append(key: 'CI_SERVER_PORT', value: Gitlab.config.gitlab.port.to_s)
+ .append(key: 'CI_SERVER_PROTOCOL', value: Gitlab.config.gitlab.protocol)
.append(key: 'CI_SERVER_NAME', value: 'GitLab')
.append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION)
.append(key: 'CI_SERVER_VERSION_MAJOR', value: Gitlab.version_info.major.to_s)
@@ -2203,7 +2184,7 @@ class Project < ApplicationRecord
end
def reference_counter(type: Gitlab::GlRepository::PROJECT)
- Gitlab::ReferenceCounter.new(type.identifier_for_subject(self))
+ Gitlab::ReferenceCounter.new(type.identifier_for_container(self))
end
def badges
@@ -2263,7 +2244,7 @@ class Project < ApplicationRecord
def storage
@storage ||=
if hashed_storage?(:repository)
- Storage::HashedProject.new(self)
+ Storage::Hashed.new(self)
else
Storage::LegacyProject.new(self)
end
@@ -2274,7 +2255,7 @@ class Project < ApplicationRecord
end
def snippets_visible?(user = nil)
- Ability.allowed?(user, :read_project_snippet, self)
+ Ability.allowed?(user, :read_snippet, self)
end
def max_attachment_size
@@ -2345,6 +2326,22 @@ class Project < ApplicationRecord
false
end
+ def uses_default_ci_config?
+ ci_config_path.blank? || ci_config_path == Gitlab::FileDetector::PATTERNS[:gitlab_ci]
+ end
+
+ def limited_protected_branches(limit)
+ protected_branches.limit(limit)
+ end
+
+ def alerts_service_activated?
+ alerts_service&.active?
+ end
+
+ def self_monitoring?
+ Gitlab::CurrentSettings.self_monitoring_project_id == id
+ end
+
private
def closest_namespace_setting(name)
diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb
index 1dd65c76258..b26a3025b61 100644
--- a/app/models/project_ci_cd_setting.rb
+++ b/app/models/project_ci_cd_setting.rb
@@ -1,9 +1,6 @@
# frozen_string_literal: true
class ProjectCiCdSetting < ApplicationRecord
- include IgnorableColumns
- # https://gitlab.com/gitlab-org/gitlab/issues/36651
- ignore_column :merge_trains_enabled, remove_with: '12.7', remove_after: '2019-12-22'
belongs_to :project, inverse_of: :ci_cd_settings
# The version of the schema that first introduced this model/table.
@@ -21,6 +18,8 @@ class ProjectCiCdSetting < ApplicationRecord
},
allow_nil: true
+ default_value_for :forward_deployment_enabled, true
+
def self.available?
@available ||=
ActiveRecord::Migrator.current_version >= MINIMUM_SCHEMA_VERSION
@@ -31,6 +30,10 @@ class ProjectCiCdSetting < ApplicationRecord
super
end
+ def forward_deployment_enabled?
+ super && ::Feature.enabled?(:forward_deployment_enabled, project)
+ end
+
private
def set_default_git_depth
diff --git a/app/models/project_deploy_token.rb b/app/models/project_deploy_token.rb
index a55667496fb..0bce1c745f7 100644
--- a/app/models/project_deploy_token.rb
+++ b/app/models/project_deploy_token.rb
@@ -7,4 +7,8 @@ class ProjectDeployToken < ApplicationRecord
validates :deploy_token, presence: true
validates :project, presence: true
validates :deploy_token_id, uniqueness: { scope: [:project_id] }
+
+ def has_access_to?(requested_project)
+ requested_project == project
+ end
end
diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb
index b70c07a8386..bc16a34612a 100644
--- a/app/models/project_group_link.rb
+++ b/app/models/project_group_link.rb
@@ -31,6 +31,10 @@ class ProjectGroupLink < ApplicationRecord
DEVELOPER
end
+ def self.search(query)
+ joins(:group).merge(Group.search(query))
+ end
+
def human_access
self.class.access_options.key(self.group_access)
end
diff --git a/app/models/project_services/alerts_service.rb b/app/models/project_services/alerts_service.rb
new file mode 100644
index 00000000000..2f7902d9617
--- /dev/null
+++ b/app/models/project_services/alerts_service.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require 'securerandom'
+
+class AlertsService < Service
+ has_one :data, class_name: 'AlertsServiceData', autosave: true,
+ inverse_of: :service, foreign_key: :service_id
+
+ attribute :token, :string
+ delegate :token, :token=, :token_changed?, :token_was, to: :data
+
+ validates :token, presence: true, if: :activated?
+
+ before_validation :prevent_token_assignment
+ before_validation :ensure_token, if: :activated?
+
+ def url
+ url_helpers.project_alerts_notify_url(project, format: :json)
+ end
+
+ def json_fields
+ super + %w(token)
+ end
+
+ def editable?
+ false
+ end
+
+ def show_active_box?
+ false
+ end
+
+ def can_test?
+ false
+ end
+
+ def title
+ _('Alerts endpoint')
+ end
+
+ def description
+ _('Receive alerts on GitLab from any source')
+ end
+
+ def detailed_description
+ description
+ end
+
+ def self.to_param
+ 'alerts'
+ end
+
+ def self.supported_events
+ %w()
+ end
+
+ def data
+ super || build_data
+ end
+
+ private
+
+ def prevent_token_assignment
+ self.token = token_was if token.present? && token_changed?
+ end
+
+ def ensure_token
+ self.token = generate_token if token.blank?
+ end
+
+ def generate_token
+ SecureRandom.hex
+ end
+
+ def url_helpers
+ Gitlab::Routing.url_helpers
+ end
+end
diff --git a/app/models/project_services/alerts_service_data.rb b/app/models/project_services/alerts_service_data.rb
new file mode 100644
index 00000000000..5a52ed83455
--- /dev/null
+++ b/app/models/project_services/alerts_service_data.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require 'securerandom'
+
+class AlertsServiceData < ApplicationRecord
+ belongs_to :service, class_name: 'AlertsService'
+
+ validates :service, presence: true
+
+ attr_encrypted :token,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ algorithm: 'aes-256-gcm'
+end
diff --git a/app/models/project_services/chat_message/base_message.rb b/app/models/project_services/chat_message/base_message.rb
index 529af1277b0..5c39a80b32d 100644
--- a/app/models/project_services/chat_message/base_message.rb
+++ b/app/models/project_services/chat_message/base_message.rb
@@ -16,7 +16,7 @@ module ChatMessage
def initialize(params)
@markdown = params[:markdown] || false
- @project_name = params.dig(:project, :path_with_namespace) || params[:project_name]
+ @project_name = params[:project_name] || params.dig(:project, :path_with_namespace)
@project_url = params.dig(:project, :web_url) || params[:project_url]
@user_full_name = params.dig(:user, :name) || params[:user_full_name]
@user_name = params.dig(:user, :username) || params[:user_name]
diff --git a/app/models/project_services/chat_message/merge_message.rb b/app/models/project_services/chat_message/merge_message.rb
index 46313ba7bec..dc62a4c8908 100644
--- a/app/models/project_services/chat_message/merge_message.rb
+++ b/app/models/project_services/chat_message/merge_message.rb
@@ -62,7 +62,7 @@ module ChatMessage
end
def merge_request_url
- "#{project_url}/merge_requests/#{merge_request_iid}"
+ "#{project_url}/-/merge_requests/#{merge_request_iid}"
end
# overridden in EE
diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb
index b84a79453c1..46c8260ab48 100644
--- a/app/models/project_services/chat_notification_service.rb
+++ b/app/models/project_services/chat_notification_service.rb
@@ -157,7 +157,7 @@ class ChatNotificationService < Service
end
def project_name
- project.full_name.gsub(/\s/, '')
+ project.full_name
end
def project_url
diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb
index eb78938324d..dd2f1359e76 100644
--- a/app/models/project_services/emails_on_push_service.rb
+++ b/app/models/project_services/emails_on_push_service.rb
@@ -25,10 +25,9 @@ class EmailsOnPushService < Service
end
def initialize_properties
- if properties.nil?
- self.properties = {}
- self.branches_to_be_notified ||= "all"
- end
+ super
+
+ self.branches_to_be_notified = 'all' if branches_to_be_notified.nil?
end
def execute(push_data)
diff --git a/app/models/project_services/flowdock_service.rb b/app/models/project_services/flowdock_service.rb
index 094488cb431..e721fded1d9 100644
--- a/app/models/project_services/flowdock_service.rb
+++ b/app/models/project_services/flowdock_service.rb
@@ -36,7 +36,7 @@ class FlowdockService < Service
token: token,
repo: project.repository,
repo_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}",
- commit_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/commit/%s",
+ commit_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/-/commit/%s",
diff_url: "#{Gitlab.config.gitlab.url}/#{project.full_path}/compare/%s...%s"
)
end
diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb
index 019bd54f48c..c92e8ecb31c 100644
--- a/app/models/project_services/hipchat_service.rb
+++ b/app/models/project_services/hipchat_service.rb
@@ -184,7 +184,7 @@ class HipchatService < Service
description = obj_attr[:description]
title = render_line(obj_attr[:title])
- merge_request_url = "#{project_url}/merge_requests/#{merge_request_id}"
+ merge_request_url = "#{project_url}/-/merge_requests/#{merge_request_id}"
merge_request_link = "<a href=\"#{merge_request_url}\">merge request !#{merge_request_id}</a>"
message = ["#{user_name} #{state} #{merge_request_link} in " \
"#{project_link}: <b>#{title}</b>"]
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index 128cbc6fa82..9875e0b9b88 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -194,7 +194,7 @@ class JiraService < IssueTrackerService
def test(_)
result = test_settings
success = result.present?
- result = @error if @error && !success
+ result = @error&.message unless success
{ success: success, result: result }
end
@@ -205,6 +205,8 @@ class JiraService < IssueTrackerService
nil
end
+ private
+
def test_settings
return unless client_url.present?
@@ -212,8 +214,6 @@ class JiraService < IssueTrackerService
jira_request { client.ServerInfo.all.attrs }
end
- private
-
def can_cross_reference?(noteable)
case noteable
when Commit then commit_events
@@ -346,9 +346,17 @@ 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)
+ rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => error
+ @error = error
+ log_error(
+ "Error sending message",
+ client_url: client_url,
+ error: {
+ exception_class: error.class.name,
+ exception_message: error.message,
+ exception_backtrace: error.backtrace.join("\n")
+ }
+ )
nil
end
diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb
index 8452239129d..65bf8535d2a 100644
--- a/app/models/project_services/pipelines_email_service.rb
+++ b/app/models/project_services/pipelines_email_service.rb
@@ -100,6 +100,6 @@ class PipelinesEmailService < Service
end
def retrieve_recipients(data)
- recipients.to_s.split(/[,(?:\r?\n) ]+/).reject(&:empty?)
+ recipients.to_s.split(/[,\r\n ]+/).reject(&:empty?)
end
end
diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb
index 3d5967de41e..00b06ae2595 100644
--- a/app/models/project_services/prometheus_service.rb
+++ b/app/models/project_services/prometheus_service.rb
@@ -102,7 +102,7 @@ class PrometheusService < MonitoringService
private
def self_monitoring_project?
- project && project.id == current_settings.instance_administration_project_id
+ project && project.id == current_settings.self_monitoring_project_id
end
def internal_prometheus_url?
diff --git a/app/models/project_services/youtrack_service.rb b/app/models/project_services/youtrack_service.rb
index 0416eaa5be0..02d06eeb405 100644
--- a/app/models/project_services/youtrack_service.rb
+++ b/app/models/project_services/youtrack_service.rb
@@ -6,9 +6,9 @@ class YoutrackService < IssueTrackerService
# {PROJECT-KEY}-{NUMBER} Examples: YT-1, PRJ-1, gl-030
def self.reference_pattern(only_long: false)
if only_long
- /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+)/
+ /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)/
else
- /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+)|(#{Issue.reference_prefix}(?<issue>\d+))/
+ /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)|(#{Issue.reference_prefix}(?<issue>\d+))/
end
end
diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb
new file mode 100644
index 00000000000..37e4a7be770
--- /dev/null
+++ b/app/models/project_setting.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class ProjectSetting < ApplicationRecord
+ belongs_to :project, inverse_of: :project_setting
+
+ self.primary_key = :project_id
+
+ def self.where_or_create_by(attrs)
+ where(primary_key => safe_find_or_create_by(attrs))
+ end
+end
diff --git a/app/models/project_snippet.rb b/app/models/project_snippet.rb
index ffb08e10f1f..6045ec71c6e 100644
--- a/app/models/project_snippet.rb
+++ b/app/models/project_snippet.rb
@@ -5,4 +5,8 @@ class ProjectSnippet < Snippet
validates :project, presence: true
validates :secret, inclusion: { in: [false] }
+
+ def web_url(only_path: nil)
+ Gitlab::Routing.url_helpers.project_snippet_url(project, self, only_path: only_path)
+ end
end
diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb
index f4666197def..1abde5196de 100644
--- a/app/models/project_wiki.rb
+++ b/app/models/project_wiki.rb
@@ -65,7 +65,7 @@ class ProjectWiki
# Returns the Gitlab::Git::Wiki object.
def wiki
@wiki ||= begin
- gl_repository = Gitlab::GlRepository::WIKI.identifier_for_subject(project)
+ gl_repository = Gitlab::GlRepository::WIKI.identifier_for_container(project)
raw_repository = Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', gl_repository, full_path)
create_repo!(raw_repository) unless raw_repository.exists?
diff --git a/app/models/prometheus_alert.rb b/app/models/prometheus_alert.rb
new file mode 100644
index 00000000000..1014231102f
--- /dev/null
+++ b/app/models/prometheus_alert.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+class PrometheusAlert < ApplicationRecord
+ include Sortable
+
+ OPERATORS_MAP = {
+ lt: "<",
+ eq: "==",
+ gt: ">"
+ }.freeze
+
+ belongs_to :environment, validate: true, inverse_of: :prometheus_alerts
+ belongs_to :project, validate: true, inverse_of: :prometheus_alerts
+ belongs_to :prometheus_metric, validate: true, inverse_of: :prometheus_alerts
+
+ has_many :prometheus_alert_events, inverse_of: :prometheus_alert
+ has_many :related_issues, through: :prometheus_alert_events
+
+ after_save :clear_prometheus_adapter_cache!
+ after_destroy :clear_prometheus_adapter_cache!
+
+ validates :environment, :project, :prometheus_metric, presence: true
+ validate :require_valid_environment_project!
+ validate :require_valid_metric_project!
+
+ enum operator: { lt: 0, eq: 1, gt: 2 }
+
+ delegate :title, :query, to: :prometheus_metric
+
+ scope :for_metric, -> (metric) { where(prometheus_metric: metric) }
+ scope :for_project, -> (project) { where(project_id: project) }
+ scope :for_environment, -> (environment) { where(environment_id: environment) }
+
+ def self.distinct_projects
+ sub_query = self.group(:project_id).select(1)
+ self.from(sub_query)
+ end
+
+ def self.operator_to_enum(op)
+ OPERATORS_MAP.invert.fetch(op)
+ end
+
+ def full_query
+ "#{query} #{computed_operator} #{threshold}"
+ end
+
+ def computed_operator
+ OPERATORS_MAP.fetch(operator.to_sym)
+ end
+
+ def to_param
+ {
+ "alert" => title,
+ "expr" => full_query,
+ "for" => "5m",
+ "labels" => {
+ "gitlab" => "hook",
+ "gitlab_alert_id" => prometheus_metric_id
+ }
+ }
+ end
+
+ private
+
+ def clear_prometheus_adapter_cache!
+ environment.clear_prometheus_reactive_cache!(:additional_metrics_environment)
+ end
+
+ def require_valid_environment_project!
+ return if project == environment&.project
+
+ errors.add(:environment, "invalid project")
+ end
+
+ def require_valid_metric_project!
+ return if prometheus_metric&.common?
+ return if project == prometheus_metric&.project
+
+ errors.add(:prometheus_metric, "invalid project")
+ end
+end
diff --git a/app/models/prometheus_metric.rb b/app/models/prometheus_metric.rb
index d0dc31476ff..571b586056b 100644
--- a/app/models/prometheus_metric.rb
+++ b/app/models/prometheus_metric.rb
@@ -2,6 +2,7 @@
class PrometheusMetric < ApplicationRecord
belongs_to :project, validate: true, inverse_of: :prometheus_metrics
+ has_many :prometheus_alerts, inverse_of: :prometheus_metric
enum group: PrometheusMetricEnums.groups
@@ -73,5 +74,3 @@ class PrometheusMetric < ApplicationRecord
PrometheusMetricEnums.group_details.fetch(group.to_sym)
end
end
-
-PrometheusMetric.prepend_if_ee('EE::PrometheusMetric')
diff --git a/app/models/prometheus_metric_enums.rb b/app/models/prometheus_metric_enums.rb
index cdd5e2acfce..75a34618e2c 100644
--- a/app/models/prometheus_metric_enums.rb
+++ b/app/models/prometheus_metric_enums.rb
@@ -9,7 +9,8 @@ module PrometheusMetricEnums
aws_elb: -3,
nginx: -4,
kubernetes: -5,
- nginx_ingress: -6
+ nginx_ingress: -6,
+ cluster_health: -100
}.merge(custom_groups).freeze
end
@@ -54,6 +55,11 @@ module PrometheusMetricEnums
group_title: _('System metrics (Kubernetes)'),
required_metrics: %w(container_memory_usage_bytes container_cpu_usage_seconds_total),
priority: 5
+ }.freeze,
+ cluster_health: {
+ group_title: _('Cluster Health'),
+ required_metrics: %w(container_memory_usage_bytes container_cpu_usage_seconds_total),
+ priority: 10
}.freeze
}.merge(custom_group_details).freeze
end
@@ -76,5 +82,3 @@ module PrometheusMetricEnums
}.freeze
end
end
-
-PrometheusMetricEnums.prepend_if_ee('EE::PrometheusMetricEnums')
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 735e2bdea81..94c3b83564f 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -2,6 +2,7 @@
class ProtectedBranch < ApplicationRecord
include ProtectedRef
+ include Gitlab::SQL::Pattern
scope :requiring_code_owner_approval,
-> { where(code_owner_approval_required: true) }
@@ -45,6 +46,12 @@ class ProtectedBranch < ApplicationRecord
# NOOP
#
end
+
+ def self.by_name(query)
+ return none if query.blank?
+
+ where(fuzzy_arel_match(:name, query.downcase))
+ end
end
ProtectedBranch.prepend_if_ee('EE::ProtectedBranch')
diff --git a/app/models/release.rb b/app/models/release.rb
index ecfae554fe0..2543717895f 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -34,7 +34,6 @@ class Release < ApplicationRecord
delegate :repository, to: :project
- after_commit :create_evidence!, on: :create, unless: :importing?
after_commit :notify_new_release, on: :create, unless: :importing?
MAX_NUMBER_TO_DISPLAY = 3
@@ -70,6 +69,10 @@ class Release < ApplicationRecord
released_at.present? && released_at > Time.zone.now
end
+ def historical_release?
+ released_at.present? && released_at < created_at
+ end
+
def name
self.read_attribute(:name) || tag
end
@@ -98,10 +101,6 @@ class Release < ApplicationRecord
end
end
- def create_evidence!
- CreateEvidenceWorker.perform_async(self.id)
- end
-
def notify_new_release
NewReleaseWorker.perform_async(id)
end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index c53b2fc5340..cddffa9bb1d 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -22,7 +22,7 @@ class Repository
include Gitlab::RepositoryCacheAdapter
- attr_accessor :full_path, :disk_path, :project, :repo_type
+ attr_accessor :full_path, :disk_path, :container, :repo_type
delegate :ref_name_for_sha, to: :raw_repository
delegate :bundle_to_disk, to: :raw_repository
@@ -41,8 +41,8 @@ class Repository
CACHED_METHODS = %i(size commit_count rendered_readme readme_path contribution_guide
changelog license_blob license_key gitignore
gitlab_ci_yml branch_names tag_names branch_count
- tag_count avatar exists? root_ref has_visible_content?
- issue_template_names merge_request_template_names
+ tag_count avatar exists? root_ref merged_branch_names
+ has_visible_content? issue_template_names merge_request_template_names
metrics_dashboard_paths xcode_project?).freeze
# Methods that use cache_method but only memoize the value
@@ -65,10 +65,10 @@ class Repository
xcode_config: :xcode_project?
}.freeze
- def initialize(full_path, project, disk_path: nil, repo_type: Gitlab::GlRepository::PROJECT)
+ def initialize(full_path, container, disk_path: nil, repo_type: Gitlab::GlRepository::PROJECT)
@full_path = full_path
@disk_path = disk_path || full_path
- @project = project
+ @container = container
@commit_cache = {}
@repo_type = repo_type
end
@@ -95,7 +95,7 @@ class Repository
def path_to_repo
@path_to_repo ||=
begin
- storage = Gitlab.config.repositories.storages[project.repository_storage]
+ storage = Gitlab.config.repositories.storages[container.repository_storage]
File.expand_path(
File.join(storage.legacy_disk_path, disk_path + '.git')
@@ -128,21 +128,12 @@ class Repository
commits = Gitlab::Git::Commit.batch_by_oid(raw_repository, oids)
if commits.present?
- Commit.decorate(commits, project)
+ Commit.decorate(commits, container)
else
[]
end
end
- # the opts are:
- # - :path
- # - :limit
- # - :offset
- # - :skip_merges
- # - :after
- # - :before
- # - :all
- # - :first_parent
def commits(ref = nil, opts = {})
options = {
repo: raw_repository,
@@ -155,18 +146,19 @@ class Repository
after: opts[:after],
before: opts[:before],
all: !!opts[:all],
- first_parent: !!opts[:first_parent]
+ first_parent: !!opts[:first_parent],
+ order: opts[:order]
}
commits = Gitlab::Git::Commit.where(options)
- commits = Commit.decorate(commits, project) if commits.present?
+ commits = Commit.decorate(commits, container) if commits.present?
- CommitCollection.new(project, commits, ref)
+ CommitCollection.new(container, commits, ref)
end
def commits_between(from, to)
commits = Gitlab::Git::Commit.between(raw_repository, from, to)
- commits = Commit.decorate(commits, project) if commits.present?
+ commits = Commit.decorate(commits, container) if commits.present?
commits
end
@@ -174,7 +166,7 @@ class Repository
def new_commits(newrev)
commits = raw.new_commits(newrev)
- ::Commit.decorate(commits, project)
+ ::Commit.decorate(commits, container)
end
# Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/384
@@ -186,7 +178,7 @@ class Repository
commits = raw_repository.find_commits_by_message(query, ref, path, limit, offset).map do |c|
commit(c)
end
- CommitCollection.new(project, commits, ref)
+ CommitCollection.new(container, commits, ref)
end
def find_branch(name)
@@ -279,7 +271,7 @@ class Repository
raw_repository.archive_metadata(
ref,
storage_path,
- project.path,
+ project&.path,
format,
append_sha: append_sha,
path: path
@@ -296,7 +288,7 @@ class Repository
end
def expire_branches_cache
- expire_method_caches(%i(branch_names branch_count has_visible_content?))
+ expire_method_caches(%i(branch_names merged_branch_names branch_count has_visible_content?))
@local_branches = nil
@branch_exists_memo = nil
end
@@ -447,10 +439,8 @@ class Repository
def after_import
expire_content_cache
- # This call is stubbed in tests due to being an expensive operation
- # It can be reenabled for specific tests via:
- #
- # allow(DetectRepositoryLanguagesWorker).to receive(:perform_async).and_call_original
+ return unless repo_type.project?
+
DetectRepositoryLanguagesWorker.perform_async(project.id)
end
@@ -495,7 +485,7 @@ class Repository
end
def blob_at(sha, path)
- blob = Blob.decorate(raw_repository.blob_at(sha, path), project)
+ blob = Blob.decorate(raw_repository.blob_at(sha, path), container)
# Don't attempt to return a special result if there is no blob at all
return unless blob
@@ -514,10 +504,12 @@ class Repository
end
# items is an Array like: [[oid, path], [oid1, path1]]
- def blobs_at(items)
+ def blobs_at(items, blob_size_limit: Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE)
return [] unless exists?
- raw_repository.batch_blobs(items).map { |blob| Blob.decorate(blob, project) }
+ raw_repository.batch_blobs(items, blob_size_limit: blob_size_limit).map do |blob|
+ Blob.decorate(blob, container)
+ end
end
def root_ref
@@ -695,13 +687,13 @@ class Repository
commits = raw_repository.list_last_commits_for_tree(sha, path, offset: offset, limit: limit)
commits.each do |path, commit|
- commits[path] = ::Commit.new(commit, project)
+ commits[path] = ::Commit.new(commit, container)
end
end
def last_commit_for_path(sha, path)
commit = raw_repository.last_commit_for_path(sha, path)
- ::Commit.new(commit, project) if commit
+ ::Commit.new(commit, container) if commit
end
def last_commit_id_for_path(sha, path)
@@ -912,7 +904,29 @@ class Repository
@root_ref_sha ||= commit(root_ref).sha
end
- delegate :merged_branch_names, to: :raw_repository
+ # If this method is not provided a set of branch names to check merge status,
+ # it fetches all branches.
+ def merged_branch_names(branch_names = [])
+ # Currently we should skip caching if requesting all branch names
+ # This is only used in a few places, notably app/services/branches/delete_merged_service.rb,
+ # and it could potentially result in a very large cache/performance issues with the current
+ # implementation.
+ skip_cache = branch_names.empty? || Feature.disabled?(:merged_branch_names_redis_caching, default_enabled: true)
+ return raw_repository.merged_branch_names(branch_names) if skip_cache
+
+ cache = redis_hash_cache
+
+ merged_branch_names_hash = cache.fetch_and_add_missing(:merged_branch_names, branch_names) do |missing_branch_names, hash|
+ merged = raw_repository.merged_branch_names(missing_branch_names)
+
+ missing_branch_names.each do |bn|
+ # Redis only stores strings in hset keys, use a fancy encoder
+ hash[bn] = Gitlab::Redis::Boolean.new(merged.include?(bn))
+ end
+ end
+
+ Set.new(merged_branch_names_hash.select { |_, v| Gitlab::Redis::Boolean.true?(v) }.keys)
+ end
def merge_base(*commits_or_ids)
commit_ids = commits_or_ids.map do |commit_or_id|
@@ -925,22 +939,12 @@ class Repository
def ancestor?(ancestor_id, descendant_id)
return false if ancestor_id.nil? || descendant_id.nil?
- counter = Gitlab::Metrics.counter(
- :repository_ancestor_calls_total,
- 'The number of times we call Repository#ancestor with valid arguments')
- cache_hit = true
-
cache_key = "ancestor:#{ancestor_id}:#{descendant_id}"
- result = request_store_cache.fetch(cache_key) do
+ request_store_cache.fetch(cache_key) do
cache.fetch(cache_key) do
- cache_hit = false
raw_repository.ancestor?(ancestor_id, descendant_id)
end
end
-
- counter.increment(cache_hit: cache_hit.to_s)
-
- result
end
def fetch_as_mirror(url, forced: false, refmap: :all_refs, remote_name: nil, prune: true)
@@ -958,6 +962,7 @@ class Repository
# rubocop:disable Gitlab/RailsLogger
def async_remove_remote(remote_name)
return unless remote_name
+ return unless project
job_id = RepositoryRemoveRemoteWorker.perform_async(project.id, remote_name)
@@ -988,10 +993,10 @@ class Repository
raw_repository.ls_files(actual_ref)
end
- def search_files_by_content(query, ref)
+ def search_files_by_content(query, ref, options = {})
return [] if empty? || query.blank?
- raw_repository.search_files_by_content(query, ref)
+ raw_repository.search_files_by_content(query, ref, options)
end
def search_files_by_name(query, ref)
@@ -1044,29 +1049,7 @@ class Repository
raw_repository.fetch_ref(source_repository.raw_repository, source_ref: source_ref, target_ref: target_ref)
end
- # DEPRECATED: https://gitlab.com/gitlab-org/gitaly/issues/1628
- def rebase_deprecated(user, merge_request)
- rebase_sha = raw.rebase_deprecated(
- user,
- merge_request.id,
- branch: merge_request.source_branch,
- branch_sha: merge_request.source_branch_sha,
- remote_repository: merge_request.target_project.repository.raw,
- remote_branch: merge_request.target_branch
- )
-
- # To support the full deprecated behaviour, set the
- # `rebase_commit_sha` for the merge_request here and return the value
- merge_request.update(rebase_commit_sha: rebase_sha, merge_error: nil)
-
- rebase_sha
- end
-
def rebase(user, merge_request, skip_ci: false)
- if Feature.disabled?(:two_step_rebase, default_enabled: true)
- return rebase_deprecated(user, merge_request)
- end
-
push_options = []
push_options << Gitlab::PushOptions::CI_SKIP if skip_ci
@@ -1094,6 +1077,10 @@ class Repository
message: message)
end
+ def submodule_links
+ @submodule_links ||= ::Gitlab::SubmoduleLinks.new(self)
+ end
+
def update_submodule(user, submodule, commit_sha, message:, branch:)
with_cache_hooks do
raw.update_submodule(
@@ -1123,12 +1110,26 @@ class Repository
true
end
+ def create_from_bundle(bundle_path)
+ raw.create_from_bundle(bundle_path).tap do |result|
+ after_create if result
+ end
+ end
+
def blobs_metadata(paths, ref = 'HEAD')
references = Array.wrap(paths).map { |path| [ref, path] }
Gitlab::Git::Blob.batch_metadata(raw, references).map { |raw_blob| Blob.decorate(raw_blob) }
end
+ def project
+ if repo_type.snippet?
+ container.project
+ else
+ container
+ end
+ end
+
private
# TODO Genericize finder, later split this on finders by Ref or Oid
@@ -1140,7 +1141,7 @@ class Repository
Gitlab::Git::Commit.find(raw_repository, oid_or_ref)
end
- ::Commit.new(commit, project) if commit
+ ::Commit.new(commit, container) if commit
end
def cache
@@ -1151,6 +1152,10 @@ class Repository
@redis_set_cache ||= Gitlab::RepositorySetCache.new(self)
end
+ def redis_hash_cache
+ @redis_hash_cache ||= Gitlab::RepositoryHashCache.new(self)
+ end
+
def request_store_cache
@request_store_cache ||= Gitlab::RepositoryCache.new(self, backend: Gitlab::SafeRequestStore)
end
@@ -1175,10 +1180,10 @@ class Repository
end
def initialize_raw_repository
- Gitlab::Git::Repository.new(project.repository_storage,
+ Gitlab::Git::Repository.new(container.repository_storage,
disk_path + '.git',
- repo_type.identifier_for_subject(project),
- project.full_path)
+ repo_type.identifier_for_container(container),
+ container.full_path)
end
end
diff --git a/app/models/sentry_issue.rb b/app/models/sentry_issue.rb
index e60ad6015a5..30f4026e633 100644
--- a/app/models/sentry_issue.rb
+++ b/app/models/sentry_issue.rb
@@ -5,10 +5,25 @@ class SentryIssue < ApplicationRecord
validates :issue, uniqueness: true, presence: true
validates :sentry_issue_identifier, presence: true
+ validate :ensure_sentry_issue_identifier_is_unique_per_project
+
+ after_create_commit :enqueue_sentry_sync_job
def self.for_project_and_identifier(project, identifier)
joins(:issue)
.where(issues: { project_id: project.id })
- .find_by_sentry_issue_identifier(identifier)
+ .where(sentry_issue_identifier: identifier)
+ .order('issues.created_at').last
+ end
+
+ def ensure_sentry_issue_identifier_is_unique_per_project
+ if issue && self.class.for_project_and_identifier(issue.project, sentry_issue_identifier).present?
+ # Custom message because field is hidden
+ errors.add(:_, _('is already associated to a GitLab Issue. New issue will not be associated.'))
+ end
+ end
+
+ def enqueue_sentry_sync_job
+ ErrorTrackingIssueLinkWorker.perform_async(issue.id)
end
end
diff --git a/app/models/serverless/domain_cluster.rb b/app/models/serverless/domain_cluster.rb
index a8365649dd1..94d90d3e305 100644
--- a/app/models/serverless/domain_cluster.rb
+++ b/app/models/serverless/domain_cluster.rb
@@ -4,11 +4,23 @@ module Serverless
class DomainCluster < ApplicationRecord
self.table_name = 'serverless_domain_cluster'
+ HEX_REGEXP = %r{\A\h+\z}.freeze
+
belongs_to :pages_domain
belongs_to :knative, class_name: 'Clusters::Applications::Knative', foreign_key: 'clusters_applications_knative_id'
belongs_to :creator, class_name: 'User', optional: true
- validates :pages_domain, :knative, :uuid, presence: true
- validates :uuid, uniqueness: true, length: { is: 14 }
+ attr_encrypted :key,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ algorithm: 'aes-256-gcm'
+
+ validates :pages_domain, :knative, presence: true
+ validates :uuid, presence: true, uniqueness: true, length: { is: Gitlab::Serverless::Domain::UUID_LENGTH },
+ format: { with: HEX_REGEXP, message: 'only allows hex characters' }
+
+ default_value_for(:uuid, allows_nil: false) { Gitlab::Serverless::Domain.generate_uuid }
+
+ delegate :domain, to: :pages_domain
end
end
diff --git a/app/models/service.rb b/app/models/service.rb
index 95b7c6927cf..e60dda59176 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -260,6 +260,7 @@ class Service < ApplicationRecord
def self.available_services_names
service_names = %w[
+ alerts
asana
assembla
bamboo
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index b3b3de21dee..4ba8e6a94e6 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -6,7 +6,6 @@ class Snippet < ApplicationRecord
include CacheMarkdownField
include Noteable
include Participable
- include Referable
include Sortable
include Awardable
include Mentionable
@@ -15,10 +14,11 @@ class Snippet < ApplicationRecord
include Gitlab::SQL::Pattern
include FromUnion
include IgnorableColumns
-
+ include HasRepository
extend ::Gitlab::Utils::Override
ignore_column :storage_version, remove_with: '12.9', remove_after: '2020-03-22'
+ ignore_column :repository_storage, remove_with: '12.10', remove_after: '2020-04-22'
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description
@@ -41,7 +41,8 @@ class Snippet < ApplicationRecord
belongs_to :project
has_many :notes, as: :noteable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- has_many :user_mentions, class_name: "SnippetUserMention"
+ has_many :user_mentions, class_name: "SnippetUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+ has_one :snippet_repository, inverse_of: :snippet
delegate :name, :email, to: :author, prefix: true, allow_nil: true
@@ -65,6 +66,8 @@ class Snippet < ApplicationRecord
validates :visibility_level, inclusion: { in: Gitlab::VisibilityLevel.values }
+ after_save :store_mentions!, if: :any_mentionable_attributes_changed?
+
# Scopes
scope :are_internal, -> { where(visibility_level: Snippet::INTERNAL) }
scope :are_private, -> { where(visibility_level: Snippet::PRIVATE) }
@@ -180,7 +183,7 @@ class Snippet < ApplicationRecord
reference = "#{self.class.reference_prefix}#{id}"
if project.present?
- "#{project.to_reference(from, full: full)}#{reference}"
+ "#{project.to_reference_base(from, full: full)}#{reference}"
else
reference
end
@@ -215,9 +218,7 @@ class Snippet < ApplicationRecord
end
def embeddable?
- ability = project_id? ? :read_project_snippet : :read_personal_snippet
-
- Ability.allowed?(nil, ability, self)
+ Ability.allowed?(nil, :read_snippet, self)
end
def notes_with_associations
@@ -229,7 +230,7 @@ class Snippet < ApplicationRecord
(public? && (title_changed? || content_changed?))
end
- # snippers are the biggest sources of spam
+ # snippets are the biggest sources of spam
override :allow_possible_spam?
def allow_possible_spam?
false
@@ -240,7 +241,7 @@ class Snippet < ApplicationRecord
end
def to_ability_name
- model_name.singular
+ 'snippet'
end
def valid_secret_token?(token)
@@ -256,6 +257,47 @@ class Snippet < ApplicationRecord
super
end
+ def repository
+ @repository ||= Repository.new(full_path, self, disk_path: disk_path, repo_type: Gitlab::GlRepository::SNIPPET)
+ end
+
+ def storage
+ @storage ||= Storage::Hashed.new(self, prefix: Storage::Hashed::SNIPPET_REPOSITORY_PATH_PREFIX)
+ end
+
+ # This is the full_path used to identify the
+ # the snippet repository. It will be used mostly
+ # for logging purposes.
+ def full_path
+ return unless persisted?
+
+ @full_path ||= begin
+ components = []
+ components << project.full_path if project_id?
+ components << '@snippets'
+ components << self.id
+ components.join('/')
+ end
+ end
+
+ def repository_storage
+ snippet_repository&.shard_name ||
+ Gitlab::CurrentSettings.pick_repository_storage
+ end
+
+ def create_repository
+ return if repository_exists?
+
+ repository.create_if_not_exists
+
+ track_snippet_repository if repository_exists?
+ end
+
+ def track_snippet_repository
+ repository = snippet_repository || build_snippet_repository
+ repository.update!(shard_name: repository_storage, disk_path: disk_path)
+ end
+
class << self
# Searches for snippets with a matching title or file name.
#
diff --git a/app/models/snippet_repository.rb b/app/models/snippet_repository.rb
new file mode 100644
index 00000000000..ba2a061a5f4
--- /dev/null
+++ b/app/models/snippet_repository.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class SnippetRepository < ApplicationRecord
+ include Shardable
+
+ belongs_to :snippet, inverse_of: :snippet_repository
+
+ class << self
+ def find_snippet(disk_path)
+ find_by(disk_path: disk_path)&.snippet
+ end
+ end
+end
diff --git a/app/models/spam_log.rb b/app/models/spam_log.rb
index 5b9ece8373f..2ec53f58e5f 100644
--- a/app/models/spam_log.rb
+++ b/app/models/spam_log.rb
@@ -12,4 +12,8 @@ class SpamLog < ApplicationRecord
def text
[title, description].join("\n")
end
+
+ def self.verify_recaptcha!(id:, user_id:)
+ find_by(id: id, user_id: user_id)&.update!(recaptcha_verified: true)
+ end
end
diff --git a/app/models/storage/hashed_project.rb b/app/models/storage/hashed.rb
index 9a38b06b2f9..3dea50ab98b 100644
--- a/app/models/storage/hashed_project.rb
+++ b/app/models/storage/hashed.rb
@@ -1,15 +1,16 @@
# frozen_string_literal: true
module Storage
- class HashedProject
- attr_accessor :project
- delegate :gitlab_shell, :repository_storage, to: :project
+ class Hashed
+ attr_accessor :container
+ delegate :gitlab_shell, :repository_storage, to: :container
REPOSITORY_PATH_PREFIX = '@hashed'
+ SNIPPET_REPOSITORY_PATH_PREFIX = '@snippets'
POOL_PATH_PREFIX = '@pools'
- def initialize(project, prefix: REPOSITORY_PATH_PREFIX)
- @project = project
+ def initialize(container, prefix: REPOSITORY_PATH_PREFIX)
+ @container = container
@prefix = prefix
end
@@ -20,9 +21,10 @@ module Storage
"#{@prefix}/#{disk_hash[0..1]}/#{disk_hash[2..3]}" if disk_hash
end
- # Disk path is used to build repository and project's wiki path on disk
+ # Disk path is used to build repository path on disk
#
- # @return [String] combination of base_dir and the repository own name without `.git` or `.wiki.git` extensions
+ # @return [String] combination of base_dir and the repository own name
+ # without `.git`, `.wiki.git`, or any other extension
def disk_path
"#{base_dir}/#{disk_hash}" if disk_hash
end
@@ -33,10 +35,10 @@ module Storage
private
- # Generates the hash for the project path and name on disk
+ # Generates the hash for the repository path and name on disk
# If you need to refer to the repository on disk, use the `#disk_path`
def disk_hash
- @disk_hash ||= Digest::SHA2.hexdigest(project.id.to_s) if project.id
+ @disk_hash ||= Digest::SHA2.hexdigest(container.id.to_s) if container.id
end
end
end
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index 5a44ee7211b..6324636db1e 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -17,7 +17,7 @@ class SystemNoteMetadata < ApplicationRecord
commit description merge confidential visible label assignee cross_reference
title time_tracking branch milestone discussion task moved
opened closed merged duplicate locked unlocked
- outdated tag due_date pinned_embed
+ outdated tag due_date pinned_embed cherry_pick
].freeze
validates :note, presence: true
diff --git a/app/models/todo.rb b/app/models/todo.rb
index f217c942e8e..d337ef33051 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -51,10 +51,12 @@ class Todo < ApplicationRecord
validates :project, presence: true, unless: :group_id
validates :group, presence: true, unless: :project_id
+ scope :for_ids, -> (ids) { where(id: ids) }
scope :pending, -> { with_state(:pending) }
scope :done, -> { with_state(:done) }
scope :for_action, -> (action) { where(action: action) }
scope :for_author, -> (author) { where(author: author) }
+ scope :for_user, -> (user) { where(user: user) }
scope :for_project, -> (projects) { where(project: projects) }
scope :for_undeleted_projects, -> { joins(:project).merge(Project.without_deleted) }
scope :for_group, -> (group) { where(group: group) }
diff --git a/app/models/uploads/base.rb b/app/models/uploads/base.rb
index 29f376670da..442ed733566 100644
--- a/app/models/uploads/base.rb
+++ b/app/models/uploads/base.rb
@@ -7,7 +7,7 @@ module Uploads
attr_reader :logger
def initialize(logger: nil)
- @logger ||= Rails.logger # rubocop:disable Gitlab/RailsLogger
+ @logger = Rails.logger # rubocop:disable Gitlab/RailsLogger
end
def delete_keys_async(keys_to_delete)
diff --git a/app/models/user.rb b/app/models/user.rb
index df6c28f5076..ec9bc7ae01e 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -59,6 +59,8 @@ class User < ApplicationRecord
MINIMUM_INACTIVE_DAYS = 180
+ enum bot_type: ::UserBotTypeEnums.bots
+
# Override Devise::Models::Trackable#update_tracked_fields!
# to limit database writes to at most once every hour
# rubocop: disable CodeReuse/ServiceClass
@@ -101,6 +103,7 @@ class User < ApplicationRecord
# Groups
has_many :members
+ has_one :max_access_level_membership, -> { select(:id, :user_id, :access_level).order(access_level: :desc).readonly }, class_name: 'Member'
has_many :group_members, -> { where(requested_at: nil) }, source: 'GroupMember'
has_many :groups, through: :group_members
has_many :owned_groups, -> { where(members: { access_level: Gitlab::Access::OWNER }) }, through: :group_members, source: :group
@@ -187,6 +190,12 @@ class User < ApplicationRecord
validate :owns_commit_email, if: :commit_email_changed?
validate :signup_domain_valid?, on: :create, if: ->(user) { !user.created_by_id }
+ validates :theme_id, allow_nil: true, inclusion: { in: Gitlab::Themes.valid_ids,
+ message: _("%{placeholder} is not a valid theme") % { placeholder: '%{value}' } }
+
+ validates :color_scheme_id, allow_nil: true, inclusion: { in: Gitlab::ColorSchemes.valid_ids,
+ message: _("%{placeholder} is not a valid color scheme") % { placeholder: '%{value}' } }
+
before_validation :sanitize_attrs
before_validation :set_notification_email, if: :new_record?
before_validation :set_public_email, if: :public_email_changed?
@@ -223,19 +232,19 @@ class User < ApplicationRecord
after_initialize :set_projects_limit
# User's Layout preference
- enum layout: [:fixed, :fluid]
+ enum layout: { fixed: 0, fluid: 1 }
# User's Dashboard preference
# Note: When adding an option, it MUST go on the end of the array.
- enum dashboard: [:projects, :stars, :project_activity, :starred_project_activity, :groups, :todos, :issues, :merge_requests, :operations]
+ enum dashboard: { projects: 0, stars: 1, project_activity: 2, starred_project_activity: 3, groups: 4, todos: 5, issues: 6, merge_requests: 7, operations: 8 }
# User's Project preference
# Note: When adding an option, it MUST go on the end of the array.
- enum project_view: [:readme, :activity, :files]
+ enum project_view: { readme: 0, activity: 1, files: 2 }
# 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
+ enum role: { software_developer: 0, development_team_lead: 1, devops_engineer: 2, systems_administrator: 3, security_analyst: 4, data_analyst: 5, product_manager: 6, product_designer: 7, other: 8 }, _suffix: true
delegate :path, to: :namespace, allow_nil: true, prefix: true
delegate :notes_filter_for, to: :user_preference
@@ -245,6 +254,7 @@ class User < ApplicationRecord
delegate :time_display_relative, :time_display_relative=, to: :user_preference
delegate :time_format_in_24h, :time_format_in_24h=, to: :user_preference
delegate :show_whitespace_in_diffs, :show_whitespace_in_diffs=, to: :user_preference
+ delegate :tab_width, :tab_width=, to: :user_preference
delegate :sourcegraph_enabled, :sourcegraph_enabled=, to: :user_preference
delegate :setup_for_company, :setup_for_company=, to: :user_preference
delegate :render_whitespace_in_code, :render_whitespace_in_code=, to: :user_preference
@@ -321,6 +331,8 @@ class User < ApplicationRecord
scope :with_emails, -> { preload(:emails) }
scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) }
scope :with_public_profile, -> { where(private_profile: false) }
+ scope :bots, -> { where.not(bot_type: nil) }
+ scope :humans, -> { where(bot_type: nil) }
scope :with_expiring_and_not_notified_personal_access_tokens, ->(at) do
where('EXISTS (?)',
@@ -597,6 +609,15 @@ class User < ApplicationRecord
end
end
+ def alert_bot
+ email_pattern = "alert%s@#{Settings.gitlab.host}"
+
+ unique_internal(where(bot_type: :alert_bot), 'alert-bot', email_pattern) do |u|
+ u.bio = 'The GitLab alert bot'
+ u.name = 'GitLab Alert Bot'
+ end
+ end
+
# Return true if there is only single non-internal user in the deployment,
# ghost user is ignored.
def single_user?
@@ -612,16 +633,20 @@ class User < ApplicationRecord
username
end
+ def bot?
+ bot_type.present?
+ end
+
def internal?
- ghost?
+ ghost? || bot?
end
def self.internal
- where(ghost: true)
+ where(ghost: true).or(bots)
end
def self.non_internal
- without_ghosts
+ without_ghosts.humans
end
#
@@ -1027,7 +1052,7 @@ class User < ApplicationRecord
end
def highest_role
- members.maximum(:access_level) || Gitlab::Access::NO_ACCESS
+ max_access_level_membership&.access_level || Gitlab::Access::NO_ACCESS
end
def accessible_deploy_keys
@@ -1201,7 +1226,8 @@ class User < ApplicationRecord
{
name: name,
username: username,
- avatar_url: avatar_url(only_path: false)
+ avatar_url: avatar_url(only_path: false),
+ email: email
}
end
@@ -1526,6 +1552,13 @@ class User < ApplicationRecord
end
def read_only_attribute?(attribute)
+ if Feature.enabled?(:ldap_readonly_attributes, default_enabled: true)
+ enabled = Gitlab::Auth::LDAP::Config.enabled?
+ read_only = attribute.to_sym.in?(UserSyncedAttributesMetadata::SYNCABLE_ATTRIBUTES)
+
+ return true if enabled && read_only
+ end
+
user_synced_attributes_metadata&.read_only?(attribute)
end
@@ -1618,6 +1651,13 @@ class User < ApplicationRecord
end
# End of signup_flow experiment methods
+ def dismissed_callout?(feature_name:, ignore_dismissal_earlier_than: nil)
+ callouts = self.callouts.with_feature_name(feature_name)
+ callouts = callouts.with_dismissed_after(ignore_dismissal_earlier_than) if ignore_dismissal_earlier_than
+
+ callouts.any?
+ end
+
# @deprecated
alias_method :owned_or_masters_groups, :owned_or_maintainers_groups
@@ -1630,13 +1670,6 @@ class User < ApplicationRecord
super
end
- # override from Devise::Confirmable
- def confirmation_period_valid?
- return false if Feature.disabled?(:soft_email_confirmation)
-
- super
- end
-
private
def default_private_profile_to_false
diff --git a/app/models/user_bot_type_enums.rb b/app/models/user_bot_type_enums.rb
new file mode 100644
index 00000000000..b6b08ce650b
--- /dev/null
+++ b/app/models/user_bot_type_enums.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module UserBotTypeEnums
+ def self.bots
+ # When adding a new key, please ensure you are not conflicting with EE-only keys in app/models/user_bot_types_enums.rb
+ {
+ alert_bot: 2
+ }
+ end
+end
+
+UserBotTypeEnums.prepend_if_ee('EE::UserBotTypeEnums')
diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb
index 027ee44c6a9..82f82356cb4 100644
--- a/app/models/user_callout.rb
+++ b/app/models/user_callout.rb
@@ -12,4 +12,7 @@ class UserCallout < ApplicationRecord
presence: true,
uniqueness: { scope: :user_id },
inclusion: { in: UserCallout.feature_names.keys }
+
+ scope :with_feature_name, -> (feature_name) { where(feature_name: UserCallout.feature_names[feature_name]) }
+ scope :with_dismissed_after, -> (dismissed_after) { where('dismissed_at > ?', dismissed_after) }
end
diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb
index 713b0598029..48a56cded0e 100644
--- a/app/models/user_preference.rb
+++ b/app/models/user_preference.rb
@@ -9,7 +9,13 @@ class UserPreference < ApplicationRecord
belongs_to :user
validates :issue_notes_filter, :merge_request_notes_filter, inclusion: { in: NOTES_FILTERS.values }, presence: true
+ validates :tab_width, numericality: {
+ only_integer: true,
+ greater_than_or_equal_to: Gitlab::TabWidth::MIN,
+ less_than_or_equal_to: Gitlab::TabWidth::MAX
+ }
+ default_value_for :tab_width, value: Gitlab::TabWidth::DEFAULT, allows_nil: false
default_value_for :timezone, value: Time.zone.tzinfo.name, allows_nil: false
default_value_for :time_display_relative, value: true, allows_nil: false
default_value_for :time_format_in_24h, value: false, allows_nil: false
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index c6867e48cbf..26beb77a025 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -5,6 +5,9 @@ class WikiPage
PageChangedError = Class.new(StandardError)
PageRenameError = Class.new(StandardError)
+ MAX_TITLE_BYTES = 245
+ MAX_DIRECTORY_BYTES = 255
+
include ActiveModel::Validations
include ActiveModel::Conversion
include StaticModel
@@ -51,6 +54,7 @@ class WikiPage
validates :title, presence: true
validates :content, presence: true
+ validate :validate_path_limits, if: :title_changed?
# The GitLab ProjectWiki instance.
attr_reader :wiki
@@ -262,7 +266,7 @@ class WikiPage
end
def title_changed?
- title.present? && self.class.unhyphenize(@page.url_path) != title
+ title.present? && (@page.nil? || self.class.unhyphenize(@page.url_path) != title)
end
# Updates the current @attributes hash by merging a hash of params
@@ -324,4 +328,16 @@ class WikiPage
set_attributes
@persisted = errors.blank?
end
+
+ def validate_path_limits
+ *dirnames, title = @attributes[:title].split('/')
+
+ if title.bytesize > MAX_TITLE_BYTES
+ errors.add(:title, _("exceeds the limit of %{bytes} bytes for page titles") % { bytes: MAX_TITLE_BYTES })
+ end
+
+ if dirnames.any? { |d| d.bytesize > MAX_DIRECTORY_BYTES }
+ errors.add(:title, _("exceeds the limit of %{bytes} bytes for directory names") % { bytes: MAX_DIRECTORY_BYTES })
+ end
+ end
end
diff --git a/app/models/x509_certificate.rb b/app/models/x509_certificate.rb
new file mode 100644
index 00000000000..43927e65db1
--- /dev/null
+++ b/app/models/x509_certificate.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class X509Certificate < ApplicationRecord
+ include X509SerialNumberAttribute
+
+ x509_serial_number_attribute :serial_number
+
+ enum certificate_status: {
+ good: 0,
+ revoked: 1
+ }
+
+ belongs_to :x509_issuer, class_name: 'X509Issuer', foreign_key: 'x509_issuer_id', optional: false
+
+ has_many :x509_commit_signatures, inverse_of: 'x509_certificate'
+
+ # rfc 5280 - 4.2.1.2 Subject Key Identifier
+ validates :subject_key_identifier, presence: true, format: { with: /\A(\h{2}:){19}\h{2}\z/ }
+ # rfc 5280 - 4.1.2.6 Subject
+ validates :subject, presence: true
+ # rfc 5280 - 4.1.2.6 Subject (subjectAltName contains the email address)
+ validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
+ # rfc 5280 - 4.1.2.2 Serial number
+ validates :serial_number, presence: true, numericality: { only_integer: true }
+
+ validates :x509_issuer_id, presence: true
+
+ def self.safe_create!(attributes)
+ create_with(attributes)
+ .safe_find_or_create_by!(subject_key_identifier: attributes[:subject_key_identifier])
+ end
+end
diff --git a/app/models/x509_commit_signature.rb b/app/models/x509_commit_signature.rb
new file mode 100644
index 00000000000..ed7c638cecc
--- /dev/null
+++ b/app/models/x509_commit_signature.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+class X509CommitSignature < ApplicationRecord
+ include ShaAttribute
+
+ sha_attribute :commit_sha
+
+ enum verification_status: {
+ unverified: 0,
+ verified: 1
+ }
+
+ belongs_to :project, class_name: 'Project', foreign_key: 'project_id', optional: false
+ belongs_to :x509_certificate, class_name: 'X509Certificate', foreign_key: 'x509_certificate_id', optional: false
+
+ validates :commit_sha, presence: true
+ validates :project_id, presence: true
+ validates :x509_certificate_id, presence: true
+
+ scope :by_commit_sha, ->(shas) { where(commit_sha: shas) }
+
+ def self.safe_create!(attributes)
+ create_with(attributes)
+ .safe_find_or_create_by!(commit_sha: attributes[:commit_sha])
+ end
+
+ # Find commits that are lacking a signature in the database at present
+ def self.unsigned_commit_shas(commit_shas)
+ return [] if commit_shas.empty?
+
+ signed = by_commit_sha(commit_shas).pluck(:commit_sha)
+ commit_shas - signed
+ end
+
+ def commit
+ project.commit(commit_sha)
+ end
+
+ def x509_commit
+ return unless commit
+
+ Gitlab::X509::Commit.new(commit)
+ end
+end
diff --git a/app/models/x509_issuer.rb b/app/models/x509_issuer.rb
new file mode 100644
index 00000000000..514b38808ef
--- /dev/null
+++ b/app/models/x509_issuer.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class X509Issuer < ApplicationRecord
+ has_many :x509_certificates, inverse_of: 'x509_issuer'
+
+ # rfc 5280 - 4.2.1.1 Authority Key Identifier
+ validates :subject_key_identifier, presence: true, format: { with: /\A(\h{2}:){19}\h{2}\z/ }
+ # rfc 5280 - 4.1.2.4 Issuer
+ validates :subject, presence: true
+ # rfc 5280 - 4.2.1.14 CRL Distribution Points
+ # cRLDistributionPoints extension using URI:http
+ validates :crl_url, presence: true, public_url: true
+
+ def self.safe_create!(attributes)
+ create_with(attributes)
+ .safe_find_or_create_by!(subject_key_identifier: attributes[:subject_key_identifier])
+ end
+end
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index c93a19bdc3d..ce3e5b0195c 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -44,6 +44,9 @@ class BasePolicy < DeclarativePolicy::Base
::Gitlab::ExternalAuthorization.perform_check?
end
+ with_options scope: :user, score: 0
+ condition(:alert_bot) { @user&.alert_bot? }
+
rule { external_authorization_enabled & ~can?(:read_all_resources) }.policy do
prevent :read_cross_project
end
diff --git a/app/policies/concerns/policy_actor.rb b/app/policies/concerns/policy_actor.rb
index b963a64b429..406677d7b56 100644
--- a/app/policies/concerns/policy_actor.rb
+++ b/app/policies/concerns/policy_actor.rb
@@ -33,6 +33,10 @@ module PolicyActor
def can_create_group
false
end
+
+ def alert_bot?
+ false
+ end
end
PolicyActor.prepend_if_ee('EE::PolicyActor')
diff --git a/app/policies/error_tracking/detailed_error_policy.rb b/app/policies/error_tracking/base_policy.rb
index cb74242d46a..ea56106ed89 100644
--- a/app/policies/error_tracking/detailed_error_policy.rb
+++ b/app/policies/error_tracking/base_policy.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module ErrorTracking
- class DetailedErrorPolicy < BasePolicy
+ class BasePolicy < ::BasePolicy
delegate { @subject.gitlab_project }
end
end
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index 2187c703760..2bde7bcca08 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -82,7 +82,7 @@ class GlobalPolicy < BasePolicy
rule { ~anonymous }.policy do
enable :read_instance_metadata
- enable :create_personal_snippet
+ enable :create_snippet
end
rule { admin }.policy do
@@ -90,7 +90,7 @@ class GlobalPolicy < BasePolicy
enable :update_custom_attribute
end
- rule { external_user }.prevent :create_personal_snippet
+ rule { external_user }.prevent :create_snippet
end
GlobalPolicy.prepend_if_ee('EE::GlobalPolicy')
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 1cd400e4dfa..3bb7ab05be2 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -67,6 +67,7 @@ class GroupPolicy < BasePolicy
enable :read_milestone
enable :read_list
enable :read_label
+ enable :read_board
end
rule { has_access }.enable :read_namespace
diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb
index c2fcf1a1010..bc60913563c 100644
--- a/app/policies/personal_snippet_policy.rb
+++ b/app/policies/personal_snippet_policy.rb
@@ -6,19 +6,19 @@ class PersonalSnippetPolicy < BasePolicy
condition(:internal_snippet, scope: :subject) { @subject.internal? }
rule { public_snippet }.policy do
- enable :read_personal_snippet
+ enable :read_snippet
enable :create_note
end
rule { is_author | admin }.policy do
- enable :read_personal_snippet
- enable :update_personal_snippet
- enable :admin_personal_snippet
+ enable :read_snippet
+ enable :update_snippet
+ enable :admin_snippet
enable :create_note
end
rule { internal_snippet & ~external_user }.policy do
- enable :read_personal_snippet
+ enable :read_snippet
enable :create_note
end
@@ -26,8 +26,5 @@ class PersonalSnippetPolicy < BasePolicy
rule { can?(:create_note) }.enable :award_emoji
- rule { can?(:read_all_resources) }.enable :read_personal_snippet
-
- # Aliasing the ability to ease GraphQL permissions check
- rule { can?(:read_personal_snippet) }.enable :read_snippet
+ rule { can?(:read_all_resources) }.enable :read_snippet
end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index e38eef527be..507e227c952 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -9,7 +9,7 @@ class ProjectPolicy < BasePolicy
merge_request
label
milestone
- project_snippet
+ snippet
wiki
note
pipeline
@@ -185,7 +185,7 @@ class ProjectPolicy < BasePolicy
enable :read_issue
enable :read_label
enable :read_milestone
- enable :read_project_snippet
+ enable :read_snippet
enable :read_project_member
enable :read_note
enable :create_project
@@ -208,7 +208,7 @@ class ProjectPolicy < BasePolicy
enable :download_code
enable :read_statistics
enable :download_wiki_code
- enable :create_project_snippet
+ enable :create_snippet
enable :update_issue
enable :reopen_issue
enable :admin_issue
@@ -222,6 +222,7 @@ class ProjectPolicy < BasePolicy
enable :read_deployment
enable :read_merge_request
enable :read_sentry_issue
+ enable :update_sentry_issue
enable :read_prometheus
end
@@ -285,8 +286,8 @@ class ProjectPolicy < BasePolicy
rule { can?(:maintainer_access) }.policy do
enable :admin_board
enable :push_to_delete_protected_branch
- enable :update_project_snippet
- enable :admin_project_snippet
+ enable :update_snippet
+ enable :admin_snippet
enable :admin_project_member
enable :admin_note
enable :admin_wiki
@@ -351,7 +352,7 @@ class ProjectPolicy < BasePolicy
end
rule { snippets_disabled }.policy do
- prevent(*create_read_update_admin_destroy(:project_snippet))
+ prevent(*create_read_update_admin_destroy(:snippet))
end
rule { wiki_disabled }.policy do
@@ -369,7 +370,7 @@ class ProjectPolicy < BasePolicy
# There's two separate cases when builds_disabled is true:
# 1. When internal CI is disabled - builds_disabled && internal_builds_disabled
- # - We do not prevent the user from accessing Pipelines to allow him to access external CI
+ # - We do not prevent the user from accessing Pipelines to allow them to access external CI
# 2. When the user is not allowed to access CI - builds_disabled && ~internal_builds_disabled
# - We prevent the user from accessing Pipelines
rule { (builds_disabled & ~internal_builds_disabled) | repository_disabled }.policy do
@@ -404,7 +405,7 @@ class ProjectPolicy < BasePolicy
enable :read_wiki
enable :read_label
enable :read_milestone
- enable :read_project_snippet
+ enable :read_snippet
enable :read_project_member
enable :read_merge_request
enable :read_note
@@ -514,6 +515,8 @@ class ProjectPolicy < BasePolicy
end
def lookup_access_level!
+ return ::Gitlab::Access::REPORTER if alert_bot?
+
# NOTE: max_member_access has its own cache
project.team.max_member_access(@user.id)
end
diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb
index a9094fbd958..a38d9154102 100644
--- a/app/policies/project_snippet_policy.rb
+++ b/app/policies/project_snippet_policy.rb
@@ -14,44 +14,41 @@ class ProjectSnippetPolicy < BasePolicy
# We have to check both project feature visibility and a snippet visibility and take the stricter one
# This will be simplified - check https://gitlab.com/gitlab-org/gitlab-foss/issues/27573
rule { ~can?(:read_project) }.policy do
- prevent :read_project_snippet
- prevent :update_project_snippet
- prevent :admin_project_snippet
+ prevent :read_snippet
+ prevent :update_snippet
+ prevent :admin_snippet
end
- # we have to use this complicated prevent because the delegated project policy
- # is overly greedy in allowing :read_project_snippet, since it doesn't have any
- # information about the snippet. However, :read_project_snippet on the *project*
- # is used to hide/show various snippet-related controls, so we can't just move
- # all of the handling here.
+ # we have to use this complicated prevent because the delegated project
+ # policy is overly greedy in allowing :read_snippet, since it doesn't have
+ # any information about the snippet. However, :read_snippet on the *project*
+ # is used to hide/show various snippet-related controls, so we can't just
+ # move all of the handling here.
rule do
all?(private_snippet | (internal_snippet & external_user),
~project.guest,
~is_author,
~can?(:read_all_resources))
- end.prevent :read_project_snippet
+ end.prevent :read_snippet
rule { internal_snippet & ~is_author & ~admin }.policy do
- prevent :update_project_snippet
- prevent :admin_project_snippet
+ prevent :update_snippet
+ prevent :admin_snippet
end
- rule { public_snippet }.enable :read_project_snippet
+ rule { public_snippet }.enable :read_snippet
rule { is_author & ~project.reporter & ~admin }.policy do
- prevent :admin_project_snippet
+ prevent :admin_snippet
end
rule { is_author | admin }.policy do
- enable :read_project_snippet
- enable :update_project_snippet
- enable :admin_project_snippet
+ enable :read_snippet
+ enable :update_snippet
+ enable :admin_snippet
end
- rule { ~can?(:read_project_snippet) }.prevent :create_note
-
- # Aliasing the ability to ease GraphQL permissions check
- rule { can?(:read_project_snippet) }.enable :read_snippet
+ rule { ~can?(:read_snippet) }.prevent :create_note
end
ProjectSnippetPolicy.prepend_if_ee('EE::ProjectSnippetPolicy')
diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb
index 3a71d2b87f3..e0077db8d5c 100644
--- a/app/presenters/blob_presenter.rb
+++ b/app/presenters/blob_presenter.rb
@@ -9,7 +9,7 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
Gitlab::Highlight.highlight(
blob.path,
limited_blob_data(to: to),
- language: blob.language_from_gitattributes,
+ language: language,
plain: plain
)
end
@@ -37,4 +37,8 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
def all_lines
@all_lines ||= blob.data.lines
end
+
+ def language
+ blob.language_from_gitattributes
+ end
end
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
index f01ff56540a..37abefb5664 100644
--- a/app/presenters/ci/pipeline_presenter.rb
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -70,18 +70,22 @@ module Ci
end
end
- def all_related_merge_request_text
+ def all_related_merge_request_text(limit: nil)
if all_related_merge_requests.none?
- 'No related merge requests found.'
+ _("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(', ')
+ pluralized_subject: n_('merge request', 'merge requests', all_related_merge_requests.count),
+ links: all_related_merge_request_links(limit: limit).join(', ')
}).html_safe
end
end
+ def has_many_merge_requests?
+ all_related_merge_requests.count > 1
+ end
+
def link_to_pipeline_ref
link_to(pipeline.ref,
project_commits_path(pipeline.project, pipeline.ref),
@@ -112,14 +116,16 @@ module Ci
def merge_request_presenter
strong_memoize(:merge_request_presenter) do
- if pipeline.triggered_by_merge_request?
+ if pipeline.merge_request?
pipeline.merge_request.present(current_user: current_user)
end
end
end
- def all_related_merge_request_links
- all_related_merge_requests.map do |merge_request|
+ def all_related_merge_request_links(limit: nil)
+ limit ||= all_related_merge_requests.count
+
+ all_related_merge_requests.first(limit).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'
diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb
index 66ae840a619..258852c77c6 100644
--- a/app/presenters/commit_status_presenter.rb
+++ b/app/presenters/commit_status_presenter.rb
@@ -13,7 +13,13 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated
archived_failure: 'The job is archived and cannot be run',
unmet_prerequisites: 'The job failed to complete prerequisite tasks',
scheduler_failure: 'The scheduler failed to assign job to the runner, please try again or contact system administrator',
- data_integrity_failure: 'There has been a structural integrity problem detected, please contact system administrator'
+ data_integrity_failure: 'There has been a structural integrity problem detected, please contact system administrator',
+ forward_deployment_failure: 'The deployment job is older than the previously succeeded deployment job, and therefore cannot be run',
+ invalid_bridge_trigger: 'This job could not be executed because downstream pipeline trigger definition is invalid',
+ downstream_bridge_project_not_found: 'This job could not be executed because downstream bridge project could not be found',
+ insufficient_bridge_permissions: 'This job could not be executed because of insufficient permissions to create a downstream pipeline',
+ bridge_pipeline_is_child_pipeline: 'This job belongs to a child pipeline and cannot create further child pipelines',
+ downstream_pipeline_creation_failed: 'The downstream pipeline could not be created'
}.freeze
private_constant :CALLOUT_FAILURE_MESSAGES
diff --git a/app/presenters/milestone_presenter.rb b/app/presenters/milestone_presenter.rb
new file mode 100644
index 00000000000..7d9045ddebe
--- /dev/null
+++ b/app/presenters/milestone_presenter.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class MilestonePresenter < Gitlab::View::Presenter::Delegated
+ presents :milestone
+
+ def milestone_path
+ url_builder.milestone_path(milestone)
+ end
+
+ private
+
+ def url_builder
+ @url_builder ||= Gitlab::UrlBuilder.new(milestone)
+ end
+end
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index 8c24d07675a..3af6be26843 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -208,7 +208,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
AnchorData.new(false,
statistic_icon + _('New file'),
project_new_blob_path(project, default_branch || 'master'),
- 'success')
+ 'missing')
end
end
@@ -302,7 +302,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
cluster_link = clusters.count == 1 ? project_cluster_path(project, clusters.first) : project_clusters_path(project)
AnchorData.new(false,
- _('Kubernetes configured'),
+ _('Kubernetes'),
cluster_link,
'default')
end
diff --git a/app/presenters/projects/prometheus/alert_presenter.rb b/app/presenters/projects/prometheus/alert_presenter.rb
new file mode 100644
index 00000000000..8988c567c5c
--- /dev/null
+++ b/app/presenters/projects/prometheus/alert_presenter.rb
@@ -0,0 +1,110 @@
+# frozen_string_literal: true
+
+module Projects
+ module Prometheus
+ class AlertPresenter < Gitlab::View::Presenter::Delegated
+ RESERVED_ANNOTATIONS = %w(gitlab_incident_markdown title).freeze
+ GENERIC_ALERT_SUMMARY_ANNOTATIONS = %w(monitoring_tool service hosts).freeze
+ MARKDOWN_LINE_BREAK = " \n".freeze
+
+ def full_title
+ [environment_name, alert_title].compact.join(': ')
+ end
+
+ def project_full_path
+ project.full_path
+ end
+
+ def metric_query
+ gitlab_alert&.full_query
+ end
+
+ def environment_name
+ environment&.name
+ end
+
+ def performance_dashboard_link
+ if environment
+ metrics_project_environment_url(project, environment)
+ else
+ metrics_project_environments_url(project)
+ end
+ end
+
+ def starts_at
+ super&.rfc3339
+ end
+
+ def issue_summary_markdown
+ <<~MARKDOWN.chomp
+ #### Summary
+
+ #{metadata_list}
+ #{alert_details}
+ MARKDOWN
+ end
+
+ private
+
+ def alert_title
+ query_title || title
+ end
+
+ def query_title
+ return unless gitlab_alert
+
+ "#{gitlab_alert.title} #{gitlab_alert.computed_operator} #{gitlab_alert.threshold} for 5 minutes"
+ end
+
+ def metadata_list
+ metadata = []
+
+ metadata << list_item('Start time', starts_at) if starts_at
+ metadata << list_item('full_query', backtick(full_query)) if full_query
+ metadata << list_item(service.label.humanize, service.value) if service
+ metadata << list_item(monitoring_tool.label.humanize, monitoring_tool.value) if monitoring_tool
+ metadata << list_item(hosts.label.humanize, host_links) if hosts
+
+ metadata.join(MARKDOWN_LINE_BREAK)
+ end
+
+ def alert_details
+ if annotation_list.present?
+ <<~MARKDOWN.chomp
+
+ #### Alert Details
+
+ #{annotation_list}
+ MARKDOWN
+ end
+ end
+
+ def annotation_list
+ strong_memoize(:annotation_list) do
+ annotations
+ .reject { |annotation| annotation.label.in?(RESERVED_ANNOTATIONS | GENERIC_ALERT_SUMMARY_ANNOTATIONS) }
+ .map { |annotation| list_item(annotation.label, annotation.value) }
+ .join(MARKDOWN_LINE_BREAK)
+ end
+ end
+
+ def list_item(key, value)
+ "**#{key}:** #{value}".strip
+ end
+
+ def backtick(value)
+ "`#{value}`"
+ end
+
+ GENERIC_ALERT_SUMMARY_ANNOTATIONS.each do |annotation_name|
+ define_method(annotation_name) do
+ annotations.find { |a| a.label == annotation_name }
+ end
+ end
+
+ def host_links
+ Array(hosts.value).join(' ')
+ end
+ end
+ end
+end
diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb
index 099ac9b09cd..2f91495c34c 100644
--- a/app/presenters/release_presenter.rb
+++ b/app/presenters/release_presenter.rb
@@ -19,6 +19,12 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated
project_tag_path(project, release.tag)
end
+ def self_url
+ return unless ::Feature.enabled?(:release_show_page, project)
+
+ project_release_url(project, release)
+ end
+
def merge_requests_url
return unless release_mr_issue_urls_available?
diff --git a/app/presenters/sentry_detailed_error_presenter.rb b/app/presenters/sentry_error_presenter.rb
index 9329f987879..ba724b0f8be 100644
--- a/app/presenters/sentry_detailed_error_presenter.rb
+++ b/app/presenters/sentry_error_presenter.rb
@@ -1,10 +1,22 @@
# frozen_string_literal: true
-class SentryDetailedErrorPresenter < Gitlab::View::Presenter::Delegated
+class SentryErrorPresenter < Gitlab::View::Presenter::Delegated
presents :error
FrequencyStruct = Struct.new(:time, :count, keyword_init: true)
+ def first_seen
+ DateTime.parse(error.first_seen)
+ end
+
+ def last_seen
+ DateTime.parse(error.last_seen)
+ end
+
+ def project_id
+ Gitlab::GlobalId.build(model_name: 'Project', id: error.project_id).to_s
+ end
+
def frequency
utc_offset = Time.zone_offset('UTC')
diff --git a/app/presenters/snippet_blob_presenter.rb b/app/presenters/snippet_blob_presenter.rb
new file mode 100644
index 00000000000..70a373619d6
--- /dev/null
+++ b/app/presenters/snippet_blob_presenter.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+class SnippetBlobPresenter < BlobPresenter
+ def rich_data
+ return if blob.binary?
+
+ if markup?
+ blob.rendered_markup
+ else
+ highlight(plain: false)
+ end
+ end
+
+ def plain_data
+ return if blob.binary?
+
+ highlight(plain: !markup?)
+ end
+
+ def raw_path
+ if snippet.is_a?(ProjectSnippet)
+ raw_project_snippet_path(snippet.project, snippet)
+ else
+ raw_snippet_path(snippet)
+ end
+ end
+
+ private
+
+ def markup?
+ blob.rich_viewer&.partial_name == 'markup'
+ end
+
+ def snippet
+ blob.snippet
+ end
+
+ def language
+ nil
+ end
+end
diff --git a/app/serializers/README.md b/app/serializers/README.md
index 93b21786015..2cbe6f9d263 100644
--- a/app/serializers/README.md
+++ b/app/serializers/README.md
@@ -64,7 +64,7 @@ A new serializer should inherit from a `BaseSerializer` class. It is necessary
to specify which serialization entity will be used to serialize a resource.
```ruby
-class MyResourceSerializer < BaseSerialize
+class MyResourceSerializer < BaseSerializer
entity MyResourceEntity
end
```
diff --git a/app/serializers/build_artifact_entity.rb b/app/serializers/build_artifact_entity.rb
index d1750695523..fac0fbd14b9 100644
--- a/app/serializers/build_artifact_entity.rb
+++ b/app/serializers/build_artifact_entity.rb
@@ -15,7 +15,7 @@ class BuildArtifactEntity < Grape::Entity
fast_download_project_job_artifacts_path(project, job)
end
- expose :keep_path, if: -> (*) { job.has_expiring_artifacts? } do |job|
+ expose :keep_path, if: -> (*) { job.has_expiring_archive_artifacts? } do |job|
fast_keep_project_job_artifacts_path(project, job)
end
diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb
index 480a8cab6ff..fe6afa4ff6f 100644
--- a/app/serializers/build_details_entity.rb
+++ b/app/serializers/build_details_entity.rb
@@ -22,6 +22,12 @@ class BuildDetailsEntity < JobEntity
end
end
+ expose :deployment_cluster, if: -> (build) { build&.deployment&.cluster } do |build, options|
+ # Until data is copied over from deployments.cluster_id, this entity must represent Deployment instead of DeploymentCluster
+ # https://gitlab.com/gitlab-org/gitlab/issues/202628
+ DeploymentClusterEntity.represent(build.deployment, options)
+ end
+
expose :artifact, if: -> (*) { can?(current_user, :read_build, build) } do
expose :download_path, if: -> (*) { build.artifacts? } do |build|
download_project_job_artifacts_path(project, build)
@@ -31,7 +37,7 @@ class BuildDetailsEntity < JobEntity
browse_project_job_artifacts_path(project, build)
end
- expose :keep_path, if: -> (*) { build.has_expiring_artifacts? && can?(current_user, :update_build, build) } do |build|
+ expose :keep_path, if: -> (*) { build.has_expiring_archive_artifacts? && can?(current_user, :update_build, build) } do |build|
keep_project_job_artifacts_path(project, build)
end
diff --git a/app/serializers/cluster_basic_entity.rb b/app/serializers/cluster_basic_entity.rb
deleted file mode 100644
index d104f2c8bbd..00000000000
--- a/app/serializers/cluster_basic_entity.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-# frozen_string_literal: true
-
-class ClusterBasicEntity < Grape::Entity
- include RequestAwareEntity
-
- expose :name
- expose :path, if: -> (cluster) { can?(request.current_user, :read_cluster, cluster) } do |cluster|
- cluster.present(current_user: request.current_user).show_path
- end
-end
diff --git a/app/serializers/concerns/user_status_tooltip.rb b/app/serializers/concerns/user_status_tooltip.rb
index a81e377691e..633b117d392 100644
--- a/app/serializers/concerns/user_status_tooltip.rb
+++ b/app/serializers/concerns/user_status_tooltip.rb
@@ -3,7 +3,7 @@
module UserStatusTooltip
extend ActiveSupport::Concern
include ActionView::Helpers::TagHelper
- include ::Gitlab::ActionViewOutput::Context
+ include ActionView::Context
include EmojiHelper
include UsersHelper
diff --git a/app/serializers/container_repositories_serializer.rb b/app/serializers/container_repositories_serializer.rb
index bc35a67ff24..0e9bdee187b 100644
--- a/app/serializers/container_repositories_serializer.rb
+++ b/app/serializers/container_repositories_serializer.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class ContainerRepositoriesSerializer < BaseSerializer
+ include WithPagination
entity ContainerRepositoryEntity
def represent_read_only(resource)
diff --git a/app/serializers/deployment_cluster_entity.rb b/app/serializers/deployment_cluster_entity.rb
new file mode 100644
index 00000000000..98736472b62
--- /dev/null
+++ b/app/serializers/deployment_cluster_entity.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class DeploymentClusterEntity < Grape::Entity
+ include RequestAwareEntity
+
+ # Until data is copied over from deployments.cluster_id, this entity must represent Deployment instead of DeploymentCluster
+ # https://gitlab.com/gitlab-org/gitlab/issues/202628
+
+ expose :name do |deployment|
+ deployment.cluster.name
+ end
+
+ expose :path, if: -> (deployment) { can?(request.current_user, :read_cluster, deployment.cluster) } do |deployment|
+ deployment.cluster.present(current_user: request.current_user).show_path
+ end
+
+ expose :kubernetes_namespace, if: -> (deployment) { can?(request.current_user, :read_cluster, deployment.cluster) } do |deployment|
+ deployment.kubernetes_namespace
+ end
+end
diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb
index 94773eeebd0..dc7c4654208 100644
--- a/app/serializers/deployment_entity.rb
+++ b/app/serializers/deployment_entity.rb
@@ -41,7 +41,11 @@ class DeploymentEntity < Grape::Entity
JobEntity.represent(deployment.playable_build, options.merge(only: [:play_path, :retry_path]))
end
- expose :cluster, using: ClusterBasicEntity
+ expose :cluster do |deployment, options|
+ # Until data is copied over from deployments.cluster_id, this entity must represent Deployment instead of DeploymentCluster
+ # https://gitlab.com/gitlab-org/gitlab/issues/202628
+ DeploymentClusterEntity.represent(deployment, options) unless deployment.cluster.nil?
+ end
private
diff --git a/app/serializers/diffs_entity.rb b/app/serializers/diffs_entity.rb
index 88e09ae8c0b..02f78180fb0 100644
--- a/app/serializers/diffs_entity.rb
+++ b/app/serializers/diffs_entity.rb
@@ -24,6 +24,10 @@ class DiffsEntity < Grape::Entity
)
end
+ expose :context_commits, using: API::Entities::Commit, if: -> (diffs, options) { merge_request&.project&.context_commits_enabled? } do |diffs|
+ options[:context_commits]
+ end
+
expose :merge_request_diff, using: MergeRequestDiffEntity do |diffs|
options[:merge_request_diff]
end
diff --git a/app/serializers/merge_request_diff_entity.rb b/app/serializers/merge_request_diff_entity.rb
index 5c79b165ee9..aa0ac7d2a7e 100644
--- a/app/serializers/merge_request_diff_entity.rb
+++ b/app/serializers/merge_request_diff_entity.rb
@@ -34,6 +34,14 @@ class MergeRequestDiffEntity < Grape::Entity
merge_request_version_path(project, merge_request, merge_request_diff)
end
+ expose :head_version_path do |merge_request_diff|
+ project = merge_request.target_project
+
+ next unless project && merge_request.diffable_merge_ref?
+
+ diffs_project_merge_request_path(project, merge_request, diff_head: true)
+ end
+
expose :version_path do |merge_request_diff|
start_sha = options[:start_sha]
project = merge_request.target_project
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index 2a81931c49f..7d67a35c94c 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -50,6 +50,19 @@ class MergeRequestWidgetEntity < Grape::Entity
ci_environments_status_project_merge_request_path(merge_request.project, merge_request)
end
+ expose :merge_request_add_ci_config_path, if: ->(mr, _) { can_add_ci_config_path?(mr) } do |merge_request|
+ project_new_blob_path(
+ merge_request.source_project,
+ merge_request.source_branch,
+ file_name: '.gitlab-ci.yml',
+ commit_message: s_("CommitMessage|Add %{file_name}") % { file_name: Gitlab::FileDetector::PATTERNS[:gitlab_ci] }
+ )
+ end
+
+ expose :human_access do |merge_request|
+ merge_request.project.team.human_max_access(current_user&.id)
+ end
+
# Rendering and redacting Markdown can be expensive. These links are
# just nice to have in the merge request widget, so only
# include them if they are explicitly requested on first load.
@@ -67,14 +80,6 @@ class MergeRequestWidgetEntity < Grape::Entity
end
end
- def as_json(options = {})
- return super(options) if Feature.enabled?(:async_mr_widget)
-
- super(options)
- .merge(MergeRequestPollCachedWidgetEntity.new(object, **@options.opts_hash).as_json(options))
- .merge(MergeRequestPollWidgetEntity.new(object, **@options.opts_hash).as_json(options))
- end
-
private
delegate :current_user, to: :request
@@ -83,6 +88,13 @@ class MergeRequestWidgetEntity < Grape::Entity
@presenters ||= {}
@presenters[merge_request] ||= MergeRequestPresenter.new(merge_request, current_user: current_user) # rubocop: disable CodeReuse/Presenter
end
+
+ def can_add_ci_config_path?(merge_request)
+ merge_request.source_project&.uses_default_ci_config? &&
+ merge_request.all_pipelines.none? &&
+ merge_request.commits_count.positive? &&
+ can?(current_user, :push_code, merge_request.source_project)
+ end
end
MergeRequestWidgetEntity.prepend_if_ee('EE::MergeRequestWidgetEntity')
diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb
index a4ab1d399bc..a58278cf4ef 100644
--- a/app/serializers/pipeline_details_entity.rb
+++ b/app/serializers/pipeline_details_entity.rb
@@ -8,7 +8,12 @@ class PipelineDetailsEntity < PipelineEntity
end
expose :details do
- expose :artifacts, using: BuildArtifactEntity
+ expose :artifacts do |pipeline, options|
+ rel = pipeline.artifacts
+ rel = rel.eager_load_job_artifacts_archive if options.fetch(:preload_job_artifacts_archive, true)
+
+ BuildArtifactEntity.represent(rel, options)
+ end
expose :manual_actions, using: BuildActionEntity
expose :scheduled_actions, using: BuildActionEntity
end
diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb
index ba8f4fffe02..c3ddbb88c9c 100644
--- a/app/serializers/pipeline_entity.rb
+++ b/app/serializers/pipeline_entity.rb
@@ -25,7 +25,7 @@ class PipelineEntity < Grape::Entity
expose :flags do
expose :stuck?, as: :stuck
expose :auto_devops_source?, as: :auto_devops
- expose :merge_request_event?, as: :merge_request
+ expose :merge_request?, as: :merge_request
expose :has_yaml_errors?, as: :yaml_errors
expose :can_retry?, as: :retryable
expose :can_cancel?, as: :cancelable
@@ -59,11 +59,11 @@ class PipelineEntity < Grape::Entity
expose :tag?, as: :tag
expose :branch?, as: :branch
- expose :merge_request_event?, as: :merge_request
+ expose :merge_request?, as: :merge_request
end
expose :commit, using: CommitEntity
- expose :merge_request_event_type, if: -> (pipeline, _) { pipeline.merge_request_event? }
+ expose :merge_request_event_type, if: -> (pipeline, _) { pipeline.merge_request? }
expose :source_sha, if: -> (pipeline, _) { pipeline.merge_request_pipeline? }
expose :target_sha, if: -> (pipeline, _) { pipeline.merge_request_pipeline? }
expose :yaml_errors, if: -> (pipeline, _) { pipeline.has_yaml_errors? }
@@ -104,7 +104,7 @@ class PipelineEntity < Grape::Entity
end
def has_presentable_merge_request?
- pipeline.triggered_by_merge_request? &&
+ pipeline.merge_request? &&
can?(request.current_user, :read_merge_request, pipeline.merge_request)
end
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index be535a5d414..3ad9f2bc0bf 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -7,6 +7,10 @@ class PipelineSerializer < BaseSerializer
# rubocop: disable CodeReuse/ActiveRecord
def represent(resource, opts = {})
if resource.is_a?(ActiveRecord::Relation)
+ # We don't want PipelineDetailsEntity to preload the job_artifacts_archive
+ # because we do it with preloaded_relations in a more optimal way
+ # if the given resource is a collection of multiple pipelines.
+ opts[:preload_job_artifacts_archive] = false
resource = resource.preload(preloaded_relations)
end
@@ -58,7 +62,8 @@ class PipelineSerializer < BaseSerializer
pending_builds: :project,
project: [:route, { namespace: :route }],
artifacts: {
- project: [:route, { namespace: :route }]
+ project: [:route, { namespace: :route }],
+ job_artifacts_archive: []
}
},
{ triggered_by_pipeline: [:project, :user] },
diff --git a/app/serializers/projects/serverless/service_entity.rb b/app/serializers/projects/serverless/service_entity.rb
index 10360e575bb..05beb562e40 100644
--- a/app/serializers/projects/serverless/service_entity.rb
+++ b/app/serializers/projects/serverless/service_entity.rb
@@ -5,91 +5,31 @@ module Projects
class ServiceEntity < Grape::Entity
include RequestAwareEntity
- expose :name do |service|
- service.dig('metadata', 'name')
- end
-
- expose :namespace do |service|
- service.dig('metadata', 'namespace')
- end
-
- expose :environment_scope do |service|
- service.dig('environment_scope')
- end
-
- expose :cluster_id do |service|
- service.dig('cluster_id')
- end
+ expose :name
+ expose :namespace
+ expose :environment_scope
+ expose :podcount
+ expose :created_at
+ expose :image
+ expose :description
+ expose :url
expose :detail_url do |service|
project_serverless_path(
request.project,
- service.dig('environment_scope'),
- service.dig('metadata', 'name'))
- end
-
- expose :podcount do |service|
- service.dig('podcount')
+ service.environment_scope,
+ service.name)
end
expose :metrics_url do |service|
project_serverless_metrics_path(
request.project,
- service.dig('environment_scope'),
- service.dig('metadata', 'name')) + ".json"
- end
-
- expose :created_at do |service|
- service.dig('metadata', 'creationTimestamp')
- end
-
- expose :url do |service|
- knative_06_07_url(service) || knative_05_url(service)
- end
-
- expose :description do |service|
- knative_07_description(service) || knative_05_06_description(service)
+ service.environment_scope,
+ service.name, format: :json)
end
- expose :image do |service|
- service.dig(
- 'spec',
- 'runLatest',
- 'configuration',
- 'build',
- 'template',
- 'name')
- end
-
- private
-
- def knative_07_description(service)
- service.dig(
- 'spec',
- 'template',
- 'metadata',
- 'annotations',
- 'Description'
- )
- end
-
- def knative_05_url(service)
- "http://#{service.dig('status', 'domain')}"
- end
-
- def knative_06_07_url(service)
- service.dig('status', 'url')
- end
-
- def knative_05_06_description(service)
- service.dig(
- 'spec',
- 'runLatest',
- 'configuration',
- 'revisionTemplate',
- 'metadata',
- 'annotations',
- 'Description')
+ expose :cluster_id do |service|
+ service.cluster&.id
end
end
end
diff --git a/app/serializers/test_reports_comparer_entity.rb b/app/serializers/test_reports_comparer_entity.rb
index d7a3dd34fdc..5f8a68338cc 100644
--- a/app/serializers/test_reports_comparer_entity.rb
+++ b/app/serializers/test_reports_comparer_entity.rb
@@ -7,6 +7,7 @@ class TestReportsComparerEntity < Grape::Entity
expose :total_count, as: :total
expose :resolved_count, as: :resolved
expose :failed_count, as: :failed
+ expose :error_count, as: :errored
end
expose :suite_comparers, as: :suites, using: TestSuiteComparerEntity
diff --git a/app/serializers/test_suite_comparer_entity.rb b/app/serializers/test_suite_comparer_entity.rb
index d402a4d5718..78c243f75b8 100644
--- a/app/serializers/test_suite_comparer_entity.rb
+++ b/app/serializers/test_suite_comparer_entity.rb
@@ -11,6 +11,7 @@ class TestSuiteComparerEntity < Grape::Entity
expose :total_count, as: :total
expose :resolved_count, as: :resolved
expose :failed_count, as: :failed
+ expose :error_count, as: :errored
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -28,6 +29,20 @@ class TestSuiteComparerEntity < Grape::Entity
max_tests(suite.new_failures, suite.existing_failures))
end
+ expose :new_errors, using: TestCaseEntity do |suite|
+ suite.new_errors.take(max_tests)
+ end
+
+ expose :existing_errors, using: TestCaseEntity do |suite|
+ suite.existing_errors.take(
+ max_tests(suite.new_errors))
+ end
+
+ expose :resolved_errors, using: TestCaseEntity do |suite|
+ suite.resolved_errors.take(
+ max_tests(suite.new_errors, suite.existing_errors))
+ end
+
private
def max_tests(*used)
diff --git a/app/serializers/variable_entity.rb b/app/serializers/variable_entity.rb
index 8b19925f153..017035fa117 100644
--- a/app/serializers/variable_entity.rb
+++ b/app/serializers/variable_entity.rb
@@ -4,6 +4,7 @@ class VariableEntity < Grape::Entity
expose :id
expose :key
expose :value
+ expose :variable_type
expose :protected?, as: :protected
expose :masked?, as: :masked
diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb
deleted file mode 100644
index d8098c4a8f5..00000000000
--- a/app/services/akismet_service.rb
+++ /dev/null
@@ -1,73 +0,0 @@
-# frozen_string_literal: true
-
-class AkismetService
- attr_accessor :text, :options
-
- def initialize(owner_name, owner_email, text, options = {})
- @owner_name = owner_name
- @owner_email = owner_email
- @text = text
- @options = options
- end
-
- def spam?
- return false unless akismet_enabled?
-
- params = {
- type: 'comment',
- text: text,
- created_at: DateTime.now,
- author: owner_name,
- author_email: owner_email,
- referrer: options[:referrer]
- }
-
- begin
- is_spam, is_blatant = akismet_client.check(options[:ip_address], options[:user_agent], params)
- is_spam || is_blatant
- rescue => e
- Rails.logger.error("Unable to connect to Akismet: #{e}, skipping check") # rubocop:disable Gitlab/RailsLogger
- false
- end
- end
-
- def submit_ham
- submit(:ham)
- end
-
- def submit_spam
- submit(:spam)
- end
-
- private
-
- attr_accessor :owner_name, :owner_email
-
- def akismet_client
- @akismet_client ||= ::Akismet::Client.new(Gitlab::CurrentSettings.akismet_api_key,
- Gitlab.config.gitlab.url)
- end
-
- def akismet_enabled?
- Gitlab::CurrentSettings.akismet_enabled
- end
-
- def submit(type)
- return false unless akismet_enabled?
-
- params = {
- type: 'comment',
- text: text,
- author: owner_name,
- author_email: owner_email
- }
-
- begin
- akismet_client.public_send(type, options[:ip_address], options[:user_agent], params) # rubocop:disable GitlabSecurity/PublicSend
- true
- rescue => e
- Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!") # rubocop:disable Gitlab/RailsLogger
- false
- end
- end
-end
diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb
index 40761ee97d2..9fd892ead82 100644
--- a/app/services/audit_event_service.rb
+++ b/app/services/audit_event_service.rb
@@ -44,6 +44,8 @@ class AuditEventService
end
def log_security_event_to_database
+ return if Gitlab::Database.read_only?
+
SecurityEvent.create(base_payload.merge(details: @details))
end
end
diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb
index a9240e1d8a0..699fa17cb65 100644
--- a/app/services/boards/issues/list_service.rb
+++ b/app/services/boards/issues/list_service.rb
@@ -10,7 +10,7 @@ module Boards
end
def execute
- fetch_issues.order_by_position_and_priority
+ fetch_issues.order_by_position_and_priority(with_cte: can_attempt_search_optimization?)
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -63,6 +63,7 @@ module Boards
set_state
set_scope
set_non_archived
+ set_attempt_search_optimizations
params
end
@@ -87,6 +88,16 @@ module Boards
params[:non_archived] = parent.is_a?(Group)
end
+ def set_attempt_search_optimizations
+ return unless can_attempt_search_optimization?
+
+ if board.group_board?
+ params[:attempt_group_search_optimizations] = true
+ else
+ params[:attempt_project_search_optimizations] = true
+ end
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def board_label_ids
@board_label_ids ||= board.lists.movable.pluck(:label_id)
@@ -113,6 +124,15 @@ module Boards
.where("label_links.label_id = ?", list.label_id).limit(1))
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ def board_group
+ board.group_board? ? parent : parent.group
+ end
+
+ def can_attempt_search_optimization?
+ params[:search].present? &&
+ Feature.enabled?(:board_search_optimization, board_group, default_enabled: false)
+ end
end
end
end
diff --git a/app/services/boards/list_service.rb b/app/services/boards/list_service.rb
index 8258d5d07d3..729bca6580e 100644
--- a/app/services/boards/list_service.rb
+++ b/app/services/boards/list_service.rb
@@ -2,16 +2,10 @@
module Boards
class ListService < Boards::BaseService
- def execute
- create_board! if parent.boards.empty?
-
- if parent.multiple_issue_boards_available?
- boards
- else
- # When multiple issue boards are not available
- # a user is only allowed to view the default shown board
- first_board
- end
+ def execute(create_default_board: true)
+ create_board! if create_default_board && parent.boards.empty?
+
+ find_boards
end
private
@@ -27,5 +21,18 @@ module Boards
def create_board!
Boards::CreateService.new(parent, current_user).execute
end
+
+ def find_boards
+ found =
+ if parent.multiple_issue_boards_available?
+ boards
+ else
+ # When multiple issue boards are not available
+ # a user is only allowed to view the default shown board
+ first_board
+ end
+
+ params[:board_id].present? ? [found.find(params[:board_id])] : found
+ end
end
end
diff --git a/app/services/ci/create_cross_project_pipeline_service.rb b/app/services/ci/create_cross_project_pipeline_service.rb
new file mode 100644
index 00000000000..8de72ace261
--- /dev/null
+++ b/app/services/ci/create_cross_project_pipeline_service.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+module Ci
+ # TODO: rename this (and worker) to CreateDownstreamPipelineService
+ class CreateCrossProjectPipelineService < ::BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ def execute(bridge)
+ @bridge = bridge
+
+ pipeline_params = @bridge.downstream_pipeline_params
+ target_ref = pipeline_params.dig(:target_revision, :ref)
+
+ return unless ensure_preconditions!(target_ref)
+
+ service = ::Ci::CreatePipelineService.new(
+ pipeline_params.fetch(:project),
+ current_user,
+ pipeline_params.fetch(:target_revision))
+
+ service.execute(
+ pipeline_params.fetch(:source), pipeline_params[:execute_params]) do |pipeline|
+ @bridge.sourced_pipelines.build(
+ source_pipeline: @bridge.pipeline,
+ source_project: @bridge.project,
+ project: @bridge.downstream_project,
+ pipeline: pipeline)
+
+ pipeline.variables.build(@bridge.downstream_variables)
+ end
+ end
+
+ private
+
+ def ensure_preconditions!(target_ref)
+ unless downstream_project_accessible?
+ @bridge.drop!(:downstream_bridge_project_not_found)
+ return false
+ end
+
+ # TODO: Remove this condition if favour of model validation
+ # https://gitlab.com/gitlab-org/gitlab/issues/38338
+ if downstream_project == project && !@bridge.triggers_child_pipeline?
+ @bridge.drop!(:invalid_bridge_trigger)
+ return false
+ end
+
+ # TODO: Remove this condition if favour of model validation
+ # https://gitlab.com/gitlab-org/gitlab/issues/38338
+ if @bridge.triggers_child_pipeline? && @bridge.pipeline.parent_pipeline.present?
+ @bridge.drop!(:bridge_pipeline_is_child_pipeline)
+ return false
+ end
+
+ unless can_create_downstream_pipeline?(target_ref)
+ @bridge.drop!(:insufficient_bridge_permissions)
+ return false
+ end
+
+ true
+ end
+
+ def downstream_project_accessible?
+ downstream_project.present? &&
+ can?(current_user, :read_project, downstream_project)
+ end
+
+ def can_create_downstream_pipeline?(target_ref)
+ can?(current_user, :update_pipeline, project) &&
+ can?(current_user, :create_pipeline, downstream_project) &&
+ can_update_branch?(target_ref)
+ end
+
+ def can_update_branch?(target_ref)
+ ::Gitlab::UserAccess.new(current_user, project: downstream_project).can_update_branch?(target_ref)
+ end
+
+ def downstream_project
+ strong_memoize(:downstream_project) do
+ @bridge.downstream_project
+ end
+ end
+ end
+end
diff --git a/app/services/ci/create_job_artifacts_service.rb b/app/services/ci/create_job_artifacts_service.rb
new file mode 100644
index 00000000000..e633dc7f633
--- /dev/null
+++ b/app/services/ci/create_job_artifacts_service.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module Ci
+ class CreateJobArtifactsService
+ ArtifactsExistError = Class.new(StandardError)
+
+ def execute(job, artifacts_file, params, metadata_file: nil)
+ expire_in = params['expire_in'] ||
+ Gitlab::CurrentSettings.current_application_settings.default_artifacts_expire_in
+
+ job.job_artifacts.build(
+ project: job.project,
+ file: artifacts_file,
+ file_type: params['artifact_type'],
+ file_format: params['artifact_format'],
+ file_sha256: artifacts_file.sha256,
+ expire_in: expire_in)
+
+ if metadata_file
+ job.job_artifacts.build(
+ project: job.project,
+ file: metadata_file,
+ file_type: :metadata,
+ file_format: :gzip,
+ file_sha256: metadata_file.sha256,
+ expire_in: expire_in)
+ end
+
+ job.update(artifacts_expire_in: expire_in)
+ rescue ActiveRecord::RecordNotUnique => error
+ return true if sha256_matches_existing_artifact?(job, params['artifact_type'], artifacts_file)
+
+ Gitlab::ErrorTracking.track_exception(error,
+ job_id: job.id,
+ project_id: job.project_id,
+ uploading_type: params['artifact_type']
+ )
+
+ job.errors.add(:base, 'another artifact of the same type already exists')
+ false
+ end
+
+ private
+
+ def sha256_matches_existing_artifact?(job, artifact_type, artifacts_file)
+ existing_artifact = job.job_artifacts.find_by_file_type(artifact_type)
+ return false unless existing_artifact
+
+ existing_artifact.file_sha256 == artifacts_file.sha256
+ end
+ end
+end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 2daf3a51235..52977034b70 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -61,7 +61,7 @@ module Ci
Ci::ProcessPipelineService
.new(pipeline)
- .execute
+ .execute(nil, initial_process: true)
end
end
diff --git a/app/services/ci/pipeline_bridge_status_service.rb b/app/services/ci/pipeline_bridge_status_service.rb
new file mode 100644
index 00000000000..19ed5026a3a
--- /dev/null
+++ b/app/services/ci/pipeline_bridge_status_service.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Ci
+ class PipelineBridgeStatusService < ::BaseService
+ def execute(pipeline)
+ return unless pipeline.bridge_triggered?
+
+ pipeline.source_bridge.inherit_status_from_downstream!(pipeline)
+ end
+ end
+end
+
+Ci::PipelineBridgeStatusService.prepend_if_ee('EE::Ci::PipelineBridgeStatusService')
diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb
index 1ed295f5950..55846c3cb5c 100644
--- a/app/services/ci/pipeline_processing/atomic_processing_service.rb
+++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb
@@ -93,9 +93,9 @@ module Ci
end
def processable_status(processable)
- if needs_names = processable.aggregated_needs_names
+ if Feature.enabled?(:ci_dag_support, project, default_enabled: true) && processable.scheduling_type_dag?
# Processable uses DAG, get status of all dependent needs
- @collection.status_for_names(needs_names)
+ @collection.status_for_names(processable.aggregated_needs_names.to_a)
else
# Processable uses Stages, get status of prior stage
@collection.status_for_prior_stage_position(processable.stage_idx.to_i)
diff --git a/app/services/ci/pipeline_processing/legacy_processing_service.rb b/app/services/ci/pipeline_processing/legacy_processing_service.rb
index 400dc9f0abb..278fba20283 100644
--- a/app/services/ci/pipeline_processing/legacy_processing_service.rb
+++ b/app/services/ci/pipeline_processing/legacy_processing_service.rb
@@ -11,12 +11,13 @@ module Ci
@pipeline = pipeline
end
- def execute(trigger_build_ids = nil)
- success = process_stages_without_needs
+ def execute(trigger_build_ids = nil, initial_process: false)
+ success = process_stages_for_stage_scheduling
# we evaluate dependent needs,
# only when the another job has finished
- success = process_builds_with_needs(trigger_build_ids) || success
+ success = process_dag_builds_without_needs || success if initial_process
+ success = process_dag_builds_with_needs(trigger_build_ids) || success
@pipeline.update_legacy_status
@@ -25,23 +26,31 @@ module Ci
private
- def process_stages_without_needs
- stage_indexes_of_created_processables_without_needs.flat_map do |index|
- process_stage_without_needs(index)
+ def process_stages_for_stage_scheduling
+ stage_indexes_of_created_stage_scheduled_processables.flat_map do |index|
+ process_stage_for_stage_scheduling(index)
end.any?
end
- def process_stage_without_needs(index)
+ def process_stage_for_stage_scheduling(index)
current_status = status_for_prior_stages(index)
return unless HasStatus::COMPLETED_STATUSES.include?(current_status)
- created_processables_in_stage_without_needs(index).find_each.select do |build|
+ created_stage_scheduled_processables_in_stage(index).find_each.select do |build|
process_build(build, current_status)
end.any?
end
- def process_builds_with_needs(trigger_build_ids)
+ def process_dag_builds_without_needs
+ return false unless Feature.enabled?(:ci_dag_support, project, default_enabled: true)
+
+ created_processables.scheduling_type_dag.without_needs.each do |build|
+ process_build(build, 'success')
+ end
+ end
+
+ def process_dag_builds_with_needs(trigger_build_ids)
return false unless trigger_build_ids.present?
return false unless Feature.enabled?(:ci_dag_support, project, default_enabled: true)
@@ -56,14 +65,15 @@ module Ci
# Each found processable is guaranteed here to have completed status
created_processables
+ .scheduling_type_dag
.with_needs(trigger_build_names)
.without_needs(incomplete_build_names)
.find_each
- .map(&method(:process_build_with_needs))
+ .map(&method(:process_dag_build_with_needs))
.any?
end
- def process_build_with_needs(build)
+ def process_dag_build_with_needs(build)
current_status = status_for_build_needs(build.needs.map(&:name))
return unless HasStatus::COMPLETED_STATUSES.include?(current_status)
@@ -87,23 +97,23 @@ module Ci
end
# rubocop: disable CodeReuse/ActiveRecord
- def stage_indexes_of_created_processables_without_needs
- created_processables_without_needs.order(:stage_idx)
+ def stage_indexes_of_created_stage_scheduled_processables
+ created_stage_scheduled_processables.order(:stage_idx)
.pluck(Arel.sql('DISTINCT stage_idx'))
end
# rubocop: enable CodeReuse/ActiveRecord
- def created_processables_in_stage_without_needs(index)
- created_processables_without_needs
+ def created_stage_scheduled_processables_in_stage(index)
+ created_stage_scheduled_processables
.with_preloads
.for_stage(index)
end
- def created_processables_without_needs
+ def created_stage_scheduled_processables
if Feature.enabled?(:ci_dag_support, project, default_enabled: true)
- pipeline.processables.created.without_needs
+ created_processables.scheduling_type_stage
else
- pipeline.processables.created
+ created_processables
end
end
diff --git a/app/services/ci/process_build_service.rb b/app/services/ci/process_build_service.rb
index eb92c7d1a27..12cdca24066 100644
--- a/app/services/ci/process_build_service.rb
+++ b/app/services/ci/process_build_service.rb
@@ -3,7 +3,7 @@
module Ci
class ProcessBuildService < BaseService
def execute(build, current_status)
- if valid_statuses_for_when(build.when).include?(current_status)
+ if valid_statuses_for_build(build).include?(current_status)
if build.schedulable?
build.schedule
elsif build.action?
@@ -25,10 +25,10 @@ module Ci
build.enqueue
end
- def valid_statuses_for_when(value)
- case value
+ def valid_statuses_for_build(build)
+ case build.when
when 'on_success'
- %w[success skipped]
+ build.scheduling_type_dag? ? %w[success] : %w[success skipped]
when 'on_failure'
%w[failed]
when 'always'
diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb
index 1ecef256233..d1efa19eb0d 100644
--- a/app/services/ci/process_pipeline_service.rb
+++ b/app/services/ci/process_pipeline_service.rb
@@ -8,8 +8,9 @@ module Ci
@pipeline = pipeline
end
- def execute(trigger_build_ids = nil)
+ def execute(trigger_build_ids = nil, initial_process: false)
update_retried
+ ensure_scheduling_type_for_processables
if Feature.enabled?(:ci_atomic_processing, pipeline.project)
Ci::PipelineProcessing::AtomicProcessingService
@@ -18,7 +19,7 @@ module Ci
else
Ci::PipelineProcessing::LegacyProcessingService
.new(pipeline)
- .execute(trigger_build_ids)
+ .execute(trigger_build_ids, initial_process: initial_process)
end
end
@@ -43,5 +44,17 @@ module Ci
.update_all(retried: true) if latest_statuses.any?
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ # Set scheduling type of processables if they were created before scheduling_type
+ # data was deployed (https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22246).
+ # Given that this service runs multiple times during the pipeline
+ # life cycle we need to ensure we populate the data once.
+ # See more: https://gitlab.com/gitlab-org/gitlab/issues/205426
+ def ensure_scheduling_type_for_processables
+ lease = Gitlab::ExclusiveLease.new("set-scheduling-types:#{pipeline.id}", timeout: 1.hour.to_i)
+ return unless lease.try_obtain
+
+ pipeline.processables.populate_scheduling_type!
+ end
end
end
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index 1f00d54b6a7..838ed789155 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -5,7 +5,7 @@ module Ci
CLONE_ACCESSORS = %i[pipeline project ref tag options name
allow_failure stage stage_id stage_idx trigger_request
yaml_variables when environment coverage_regex
- description tag_list protected needs resource_group].freeze
+ description tag_list protected needs resource_group scheduling_type].freeze
def execute(build)
reprocess!(build).tap do |new_build|
@@ -27,9 +27,10 @@ module Ci
attributes = CLONE_ACCESSORS.map do |attribute|
[attribute, build.public_send(attribute)] # rubocop:disable GitlabSecurity/PublicSend
- end
+ end.to_h
- attributes.push([:user, current_user])
+ attributes[:user] = current_user
+ attributes[:scheduling_type] ||= build.find_legacy_scheduling_type
Ci::Build.transaction do
# mark all other builds of that name as retried
@@ -49,7 +50,7 @@ module Ci
private
def create_build!(attributes)
- build = project.builds.new(Hash[attributes])
+ build = project.builds.new(attributes)
build.deployment = ::Gitlab::Ci::Pipeline::Seed::Deployment.new(build).to_resource
build.retried = false
build.save!
diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb
index 7d01de9ee68..9bb236ac44c 100644
--- a/app/services/ci/retry_pipeline_service.rb
+++ b/app/services/ci/retry_pipeline_service.rb
@@ -36,7 +36,7 @@ module Ci
Ci::ProcessPipelineService
.new(pipeline)
- .execute(completed_build_ids)
+ .execute(completed_build_ids, initial_process: true)
end
end
end
diff --git a/app/services/ci/stop_environments_service.rb b/app/services/ci/stop_environments_service.rb
index d9a800791f2..14ef744ada1 100644
--- a/app/services/ci/stop_environments_service.rb
+++ b/app/services/ci/stop_environments_service.rb
@@ -16,6 +16,22 @@ module Ci
merge_request.environments.each { |environment| stop(environment) }
end
+ ##
+ # This method is for stopping multiple environments in a batch style.
+ # The maximum acceptable count of environments is roughly 5000. Please
+ # apply acceptable `LIMIT` clause to the `environments` relation.
+ def self.execute_in_batch(environments)
+ stop_actions = environments.stop_actions.load
+
+ environments.update_all(auto_stop_at: nil, state: 'stopped')
+
+ stop_actions.each do |stop_action|
+ stop_action.play(stop_action.user)
+ rescue => e
+ Gitlab::ErrorTracking.track_error(e, deployable_id: stop_action.id)
+ end
+ end
+
private
def environments
diff --git a/app/services/clusters/applications/base_service.rb b/app/services/clusters/applications/base_service.rb
index 844da11e5cb..2585d815e07 100644
--- a/app/services/clusters/applications/base_service.rb
+++ b/app/services/clusters/applications/base_service.rb
@@ -58,7 +58,7 @@ module Clusters
end
def instantiate_application
- raise_invalid_application_error if invalid_application?
+ raise_invalid_application_error if unknown_application?
builder || raise(InvalidApplicationError, "invalid application: #{application_name}")
end
@@ -67,10 +67,6 @@ module Clusters
raise(InvalidApplicationError, "invalid application: #{application_name}")
end
- def invalid_application?
- unknown_application? || (application_name == Applications::ElasticStack.application_name && !Feature.enabled?(:enable_cluster_application_elastic_stack))
- end
-
def unknown_application?
Clusters::Cluster::APPLICATIONS.keys.exclude?(application_name)
end
diff --git a/app/services/clusters/kubernetes.rb b/app/services/clusters/kubernetes.rb
index d29519999b2..aafea64c820 100644
--- a/app/services/clusters/kubernetes.rb
+++ b/app/services/clusters/kubernetes.rb
@@ -12,5 +12,7 @@ module Clusters
GITLAB_KNATIVE_SERVING_ROLE_BINDING_NAME = 'gitlab-knative-serving-rolebinding'
GITLAB_CROSSPLANE_DATABASE_ROLE_NAME = 'gitlab-crossplane-database-role'
GITLAB_CROSSPLANE_DATABASE_ROLE_BINDING_NAME = 'gitlab-crossplane-database-rolebinding'
+ KNATIVE_SERVING_NAMESPACE = 'knative-serving'
+ ISTIO_SYSTEM_NAMESPACE = 'istio-system'
end
end
diff --git a/app/services/clusters/kubernetes/configure_istio_ingress_service.rb b/app/services/clusters/kubernetes/configure_istio_ingress_service.rb
new file mode 100644
index 00000000000..fe577beaa8a
--- /dev/null
+++ b/app/services/clusters/kubernetes/configure_istio_ingress_service.rb
@@ -0,0 +1,108 @@
+# frozen_string_literal: true
+
+require 'openssl'
+
+module Clusters
+ module Kubernetes
+ class ConfigureIstioIngressService
+ PASSTHROUGH_RESOURCE = Kubeclient::Resource.new(
+ mode: 'PASSTHROUGH'
+ ).freeze
+
+ MTLS_RESOURCE = Kubeclient::Resource.new(
+ mode: 'MUTUAL',
+ privateKey: '/etc/istio/ingressgateway-certs/tls.key',
+ serverCertificate: '/etc/istio/ingressgateway-certs/tls.crt',
+ caCertificates: '/etc/istio/ingressgateway-ca-certs/cert.pem'
+ ).freeze
+
+ def initialize(cluster:)
+ @cluster = cluster
+ @platform = cluster.platform
+ @kubeclient = platform.kubeclient
+ @knative = cluster.application_knative
+ end
+
+ def execute
+ return configure_certificates if serverless_domain_cluster
+
+ configure_passthrough
+ end
+
+ private
+
+ attr_reader :cluster, :platform, :kubeclient, :knative
+
+ def serverless_domain_cluster
+ knative&.serverless_domain_cluster
+ end
+
+ def configure_certificates
+ create_or_update_istio_cert_and_key
+ set_gateway_wildcard_https(MTLS_RESOURCE)
+ end
+
+ def create_or_update_istio_cert_and_key
+ name = OpenSSL::X509::Name.parse("CN=#{knative.hostname}")
+
+ key = OpenSSL::PKey::RSA.new(2048)
+
+ cert = OpenSSL::X509::Certificate.new
+ cert.version = 2
+ cert.serial = 0
+ cert.not_before = Time.now
+ cert.not_after = Time.now + 1000.years
+
+ cert.public_key = key.public_key
+ cert.subject = name
+ cert.issuer = name
+ cert.sign(key, OpenSSL::Digest::SHA256.new)
+
+ serverless_domain_cluster.update!(
+ key: key.to_pem,
+ certificate: cert.to_pem
+ )
+
+ kubeclient.create_or_update_secret(istio_ca_certs_resource)
+ kubeclient.create_or_update_secret(istio_certs_resource)
+ end
+
+ def istio_ca_certs_resource
+ Gitlab::Kubernetes::GenericSecret.new(
+ 'istio-ingressgateway-ca-certs',
+ {
+ 'cert.pem': Base64.strict_encode64(serverless_domain_cluster.certificate)
+ },
+ Clusters::Kubernetes::ISTIO_SYSTEM_NAMESPACE
+ ).generate
+ end
+
+ def istio_certs_resource
+ Gitlab::Kubernetes::TlsSecret.new(
+ 'istio-ingressgateway-certs',
+ serverless_domain_cluster.certificate,
+ serverless_domain_cluster.key,
+ Clusters::Kubernetes::ISTIO_SYSTEM_NAMESPACE
+ ).generate
+ end
+
+ def set_gateway_wildcard_https(tls_resource)
+ gateway_resource = gateway
+ gateway_resource.spec.servers.each do |server|
+ next unless server.hosts == ['*'] && server.port.name == 'https'
+
+ server.tls = tls_resource
+ end
+ kubeclient.update_gateway(gateway_resource)
+ end
+
+ def configure_passthrough
+ set_gateway_wildcard_https(PASSTHROUGH_RESOURCE)
+ end
+
+ def gateway
+ kubeclient.get_gateway('knative-ingress-gateway', Clusters::Kubernetes::KNATIVE_SERVING_NAMESPACE)
+ end
+ end
+ end
+end
diff --git a/app/services/commits/cherry_pick_service.rb b/app/services/commits/cherry_pick_service.rb
index 4c5b15b2f95..91a18909e22 100644
--- a/app/services/commits/cherry_pick_service.rb
+++ b/app/services/commits/cherry_pick_service.rb
@@ -3,7 +3,24 @@
module Commits
class CherryPickService < ChangeService
def create_commit!
- commit_change(:cherry_pick)
+ commit_change(:cherry_pick).tap do |sha|
+ track_mr_picking(sha)
+ end
+ end
+
+ private
+
+ def track_mr_picking(pick_sha)
+ return unless Feature.enabled?(:track_mr_picking, project)
+
+ merge_request = project.merge_requests.by_merge_commit_sha(@commit.sha).first
+ return unless merge_request
+
+ ::SystemNotes::MergeRequestsService.new(
+ noteable: merge_request,
+ project: project,
+ author: current_user
+ ).picked_into_branch(@branch_name, pick_sha)
end
end
end
diff --git a/app/services/concerns/akismet_methods.rb b/app/services/concerns/akismet_methods.rb
index 1cbcf0d47b9..105b79785bd 100644
--- a/app/services/concerns/akismet_methods.rb
+++ b/app/services/concerns/akismet_methods.rb
@@ -2,23 +2,14 @@
module AkismetMethods
def spammable_owner
- @user ||= User.find(spammable_owner_id)
- end
-
- def spammable_owner_id
- @owner_id ||=
- if spammable.respond_to?(:author_id)
- spammable.author_id
- elsif spammable.respond_to?(:creator_id)
- spammable.creator_id
- end
+ @user ||= User.find(spammable.author_id)
end
def akismet
- @akismet ||= AkismetService.new(
+ @akismet ||= Spam::AkismetService.new(
spammable_owner.name,
spammable_owner.email,
- spammable.spammable_text,
+ spammable.try(:spammable_text) || spammable&.text,
options
)
end
diff --git a/app/services/concerns/spam_check_methods.rb b/app/services/concerns/spam_check_methods.rb
index 75d9759f1d1..695bdf92b49 100644
--- a/app/services/concerns/spam_check_methods.rb
+++ b/app/services/concerns/spam_check_methods.rb
@@ -22,14 +22,15 @@ module SpamCheckMethods
# a dirty instance, which means it should be already assigned with the new
# attribute values.
# rubocop:disable Gitlab/ModuleWithInstanceVariables
- # rubocop: disable CodeReuse/ActiveRecord
def spam_check(spammable, user)
- spam_service = SpamService.new(spammable: spammable, request: @request)
-
- spam_service.when_recaptcha_verified(@recaptcha_verified, @api) do
- user.spam_logs.find_by(id: @spam_log_id)&.update!(recaptcha_verified: true)
- end
+ Spam::SpamCheckService.new(
+ spammable: spammable,
+ request: @request
+ ).execute(
+ api: @api,
+ recaptcha_verified: @recaptcha_verified,
+ spam_log_id: @spam_log_id,
+ user_id: user.id)
end
- # rubocop: enable CodeReuse/ActiveRecord
# rubocop:enable Gitlab/ModuleWithInstanceVariables
end
diff --git a/app/services/container_expiration_policy_service.rb b/app/services/container_expiration_policy_service.rb
index 5d141d4d64d..82274fd8668 100644
--- a/app/services/container_expiration_policy_service.rb
+++ b/app/services/container_expiration_policy_service.rb
@@ -6,9 +6,11 @@ class ContainerExpirationPolicyService < BaseService
container_expiration_policy.container_repositories.find_each do |container_repository|
CleanupContainerRepositoryWorker.perform_async(
- current_user.id,
+ nil,
container_repository.id,
- container_expiration_policy.attributes.except("created_at", "updated_at")
+ container_expiration_policy.attributes
+ .except('created_at', 'updated_at')
+ .merge(container_expiration_policy: true)
)
end
end
diff --git a/app/services/deployments/link_merge_requests_service.rb b/app/services/deployments/link_merge_requests_service.rb
index a1d6d50bbb4..67a2230350d 100644
--- a/app/services/deployments/link_merge_requests_service.rb
+++ b/app/services/deployments/link_merge_requests_service.rb
@@ -38,6 +38,8 @@ module Deployments
.commits_between(from, to)
.map(&:id)
+ track_mr_picking = Feature.enabled?(:track_mr_picking, project)
+
# For some projects the list of commits to deploy may be very large. To
# ensure we do not end up running SQL queries with thousands of WHERE IN
# values, we run one query per a certain number of commits.
@@ -50,6 +52,13 @@ module Deployments
project.merge_requests.merged.by_merge_commit_sha(slice)
deployment.link_merge_requests(merge_requests)
+
+ next unless track_mr_picking
+
+ picked_merge_requests =
+ project.merge_requests.by_cherry_pick_sha(slice)
+
+ deployment.link_merge_requests(picked_merge_requests)
end
end
diff --git a/app/services/deployments/older_deployments_drop_service.rb b/app/services/deployments/older_deployments_drop_service.rb
new file mode 100644
index 00000000000..122f8ac89ed
--- /dev/null
+++ b/app/services/deployments/older_deployments_drop_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Deployments
+ class OlderDeploymentsDropService
+ attr_reader :deployment
+
+ def initialize(deployment_id)
+ @deployment = Deployment.find_by_id(deployment_id)
+ end
+
+ def execute
+ return unless @deployment&.running?
+
+ older_deployments.find_each do |older_deployment|
+ older_deployment.deployable&.drop!(:forward_deployment_failure)
+ rescue => e
+ Gitlab::ErrorTracking.track_exception(e, subject_id: @deployment.id, deployment_id: older_deployment.id)
+ end
+ end
+
+ private
+
+ def older_deployments
+ @deployment
+ .environment
+ .active_deployments
+ .older_than(@deployment)
+ .with_deployable
+ end
+ end
+end
diff --git a/app/services/environments/auto_stop_service.rb b/app/services/environments/auto_stop_service.rb
new file mode 100644
index 00000000000..ee7f25a4d76
--- /dev/null
+++ b/app/services/environments/auto_stop_service.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Environments
+ class AutoStopService
+ include ::Gitlab::ExclusiveLeaseHelpers
+ include ::Gitlab::LoopHelpers
+
+ BATCH_SIZE = 100
+ LOOP_TIMEOUT = 45.minutes
+ LOOP_LIMIT = 1000
+ EXCLUSIVE_LOCK_KEY = 'environments:auto_stop:lock'
+ LOCK_TIMEOUT = 50.minutes
+
+ ##
+ # Stop expired environments on GitLab instance
+ #
+ # This auto stop process cannot run for more than 45 minutes. This is for
+ # preventing multiple `AutoStopCronWorker` CRON jobs run concurrently,
+ # which is scheduled at every hour.
+ def execute
+ in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do
+ loop_until(timeout: LOOP_TIMEOUT, limit: LOOP_LIMIT) do
+ stop_in_batch
+ end
+ end
+ end
+
+ private
+
+ def stop_in_batch
+ environments = Environment.auto_stoppable(BATCH_SIZE)
+
+ return false unless environments.exists? && Feature.enabled?(:auto_stop_environments, default_enabled: true)
+
+ Ci::StopEnvironmentsService.execute_in_batch(environments)
+ end
+ end
+end
diff --git a/app/services/error_tracking/base_service.rb b/app/services/error_tracking/base_service.rb
index 430d9952332..289c125b9d1 100644
--- a/app/services/error_tracking/base_service.rb
+++ b/app/services/error_tracking/base_service.rb
@@ -3,36 +3,33 @@
module ErrorTracking
class BaseService < ::BaseService
def execute
- unauthorized = check_permissions
return unauthorized if unauthorized
- begin
- response = fetch
- rescue Sentry::Client::Error => e
- return error(e.message, :bad_request)
- rescue Sentry::Client::MissingKeysError => e
- return error(e.message, :internal_server_error)
- end
-
- errors = parse_errors(response)
- return errors if errors
-
- success(parse_response(response))
+ perform
end
private
- def fetch
+ def perform
raise NotImplementedError,
"#{self.class} does not implement #{__method__}"
end
+ def compose_response(response, &block)
+ errors = parse_errors(response)
+ return errors if errors
+
+ yield if block_given?
+
+ success(parse_response(response))
+ end
+
def parse_response(response)
raise NotImplementedError,
"#{self.class} does not implement #{__method__}"
end
- def check_permissions
+ def unauthorized
return error('Error Tracking is not enabled') unless enabled?
return error('Access denied', :unauthorized) unless can_read?
end
@@ -62,5 +59,9 @@ module ErrorTracking
def can_read?
can?(current_user, :read_sentry_issue, project)
end
+
+ def can_update?
+ can?(current_user, :update_sentry_issue, project)
+ end
end
end
diff --git a/app/services/error_tracking/issue_details_service.rb b/app/services/error_tracking/issue_details_service.rb
index 368cd4517fc..0068a9e9b6d 100644
--- a/app/services/error_tracking/issue_details_service.rb
+++ b/app/services/error_tracking/issue_details_service.rb
@@ -2,10 +2,35 @@
module ErrorTracking
class IssueDetailsService < ErrorTracking::BaseService
+ include Gitlab::Routing
+ include Gitlab::Utils::StrongMemoize
+
private
- def fetch
- project_error_tracking_setting.issue_details(issue_id: params[:issue_id])
+ def perform
+ response = project_error_tracking_setting.issue_details(issue_id: params[:issue_id])
+
+ compose_response(response) do
+ # The gitlab_issue attribute can contain an absolute GitLab url from the Sentry Client
+ # here we overwrite that in favor of our own data if we have it
+ response[:issue].gitlab_issue = gitlab_issue_url if gitlab_issue_url
+ end
+ end
+
+ def gitlab_issue_url
+ strong_memoize(:gitlab_issue_url) do
+ # Use the absolute url to match the GitLab issue url that the Sentry api provides
+ project_issue_url(project, gitlab_issue.iid) if gitlab_issue
+ end
+ end
+
+ def gitlab_issue
+ strong_memoize(:gitlab_issue) do
+ SentryIssueFinder
+ .new(project, current_user: current_user)
+ .execute(params[:issue_id])
+ &.issue
+ end
end
def parse_response(response)
diff --git a/app/services/error_tracking/issue_latest_event_service.rb b/app/services/error_tracking/issue_latest_event_service.rb
index b6ad8f8028b..a39f1cde1b2 100644
--- a/app/services/error_tracking/issue_latest_event_service.rb
+++ b/app/services/error_tracking/issue_latest_event_service.rb
@@ -4,8 +4,10 @@ module ErrorTracking
class IssueLatestEventService < ErrorTracking::BaseService
private
- def fetch
- project_error_tracking_setting.issue_latest_event(issue_id: params[:issue_id])
+ def perform
+ response = project_error_tracking_setting.issue_latest_event(issue_id: params[:issue_id])
+
+ compose_response(response)
end
def parse_response(response)
diff --git a/app/services/error_tracking/issue_update_service.rb b/app/services/error_tracking/issue_update_service.rb
index e433b4a11f2..e516ac95138 100644
--- a/app/services/error_tracking/issue_update_service.rb
+++ b/app/services/error_tracking/issue_update_service.rb
@@ -4,11 +4,53 @@ module ErrorTracking
class IssueUpdateService < ErrorTracking::BaseService
private
- def fetch
- project_error_tracking_setting.update_issue(
+ def perform
+ response = project_error_tracking_setting.update_issue(
issue_id: params[:issue_id],
params: update_params
)
+
+ compose_response(response) do
+ response[:closed_issue_iid] = update_related_issue&.iid
+ end
+ end
+
+ def update_related_issue
+ issue = related_issue
+ return unless issue
+
+ close_and_create_note(issue)
+ end
+
+ def close_and_create_note(issue)
+ return unless resolving? && issue.opened?
+
+ processed_issue = close_issue(issue)
+ return unless processed_issue.reset.closed?
+
+ create_system_note(processed_issue)
+ processed_issue
+ end
+
+ def close_issue(issue)
+ Issues::CloseService
+ .new(project, current_user)
+ .execute(issue, system_note: false)
+ end
+
+ def create_system_note(issue)
+ SystemNoteService.close_after_error_tracking_resolve(issue, project, current_user)
+ end
+
+ def related_issue
+ SentryIssueFinder
+ .new(project, current_user: current_user)
+ .execute(params[:issue_id])
+ &.issue
+ end
+
+ def resolving?
+ update_params[:status] == 'resolved'
end
def update_params
@@ -16,7 +58,15 @@ module ErrorTracking
end
def parse_response(response)
- { updated: response[:updated].present? }
+ {
+ updated: response[:updated].present?,
+ closed_issue_iid: response[:closed_issue_iid]
+ }
+ end
+
+ def unauthorized
+ return error('Error Tracking is not enabled') unless enabled?
+ return error('Access denied', :unauthorized) unless can_update?
end
end
end
diff --git a/app/services/error_tracking/list_issues_service.rb b/app/services/error_tracking/list_issues_service.rb
index 132e9dfa7bd..7087e3825d6 100644
--- a/app/services/error_tracking/list_issues_service.rb
+++ b/app/services/error_tracking/list_issues_service.rb
@@ -6,26 +6,45 @@ module ErrorTracking
DEFAULT_LIMIT = 20
DEFAULT_SORT = 'last_seen'
+ # Sentry client supports 'muted' and 'assigned' but GitLab does not
+ ISSUE_STATUS_VALUES = %w[
+ resolved
+ unresolved
+ ignored
+ ].freeze
+
def external_url
project_error_tracking_setting&.sentry_external_url
end
private
- def fetch
- project_error_tracking_setting.list_sentry_issues(
+ def perform
+ return invalid_status_error unless valid_status?
+
+ response = project_error_tracking_setting.list_sentry_issues(
issue_status: issue_status,
limit: limit,
search_term: params[:search_term].presence,
sort: sort,
cursor: params[:cursor].presence
)
+
+ compose_response(response)
end
def parse_response(response)
response.slice(:issues, :pagination)
end
+ def invalid_status_error
+ error('Bad Request: Invalid issue_status', http_status_for(:bad_Request))
+ end
+
+ def valid_status?
+ ISSUE_STATUS_VALUES.include?(issue_status)
+ end
+
def issue_status
params[:issue_status] || DEFAULT_ISSUE_STATUS
end
diff --git a/app/services/error_tracking/list_projects_service.rb b/app/services/error_tracking/list_projects_service.rb
index 09a0b952e84..625addaf915 100644
--- a/app/services/error_tracking/list_projects_service.rb
+++ b/app/services/error_tracking/list_projects_service.rb
@@ -2,18 +2,16 @@
module ErrorTracking
class ListProjectsService < ErrorTracking::BaseService
- def execute
+ private
+
+ def perform
unless project_error_tracking_setting.valid?
return error(project_error_tracking_setting.errors.full_messages.join(', '), :bad_request)
end
- super
- end
-
- private
+ response = project_error_tracking_setting.list_sentry_projects
- def fetch
- project_error_tracking_setting.list_sentry_projects
+ compose_response(response)
end
def parse_response(response)
diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb
index a49983a84fc..ea5b2f401b3 100644
--- a/app/services/git/base_hooks_service.rb
+++ b/app/services/git/base_hooks_service.rb
@@ -81,15 +81,17 @@ module Git
end
def pipeline_params
- {
- before: oldrev,
- after: newrev,
- ref: ref,
- variables_attributes: generate_vars_from_push_options || [],
- push_options: params[:push_options] || {},
- checkout_sha: Gitlab::DataBuilder::Push.checkout_sha(
- project.repository, newrev, ref)
- }
+ strong_memoize(:pipeline_params) do
+ {
+ before: oldrev,
+ after: newrev,
+ ref: ref,
+ variables_attributes: generate_vars_from_push_options || [],
+ push_options: params[:push_options] || {},
+ checkout_sha: Gitlab::DataBuilder::Push.checkout_sha(
+ project.repository, newrev, ref)
+ }
+ end
end
def ci_variables_from_push_options
@@ -156,12 +158,16 @@ module Git
project_path: project.full_path,
message: "Error creating pipeline",
errors: exception.to_s,
- pipeline_params: pipeline_params
+ pipeline_params: sanitized_pipeline_params
}
logger.warn(data)
end
+ def sanitized_pipeline_params
+ pipeline_params.except(:push_options)
+ end
+
def logger
if Gitlab::Runtime.sidekiq?
Sidekiq.logger
diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb
index 69f1f9eb31f..e1cc1f8c834 100644
--- a/app/services/git/branch_hooks_service.rb
+++ b/app/services/git/branch_hooks_service.rb
@@ -6,7 +6,7 @@ module Git
execute_branch_hooks
super.tap do
- enqueue_update_gpg_signatures
+ enqueue_update_signatures
end
end
@@ -103,14 +103,22 @@ module Git
end
end
- def enqueue_update_gpg_signatures
- unsigned = GpgSignature.unsigned_commit_shas(limited_commits.map(&:sha))
+ def unsigned_x509_shas(commits)
+ X509CommitSignature.unsigned_commit_shas(commits.map(&:sha))
+ end
+
+ def unsigned_gpg_shas(commits)
+ GpgSignature.unsigned_commit_shas(commits.map(&:sha))
+ end
+
+ def enqueue_update_signatures
+ unsigned = unsigned_x509_shas(commits) & unsigned_gpg_shas(commits)
return if unsigned.empty?
signable = Gitlab::Git::Commit.shas_with_signatures(project.repository, unsigned)
return if signable.empty?
- CreateGpgSignatureWorker.perform_async(signable, project.id)
+ CreateCommitSignatureWorker.perform_async(signable, project.id)
end
# It's not sufficient to just check for a blank SHA as it's possible for the
diff --git a/app/services/groups/import_export/export_service.rb b/app/services/groups/import_export/export_service.rb
index 26886fc67dc..2c3975961a8 100644
--- a/app/services/groups/import_export/export_service.rb
+++ b/app/services/groups/import_export/export_service.rb
@@ -11,6 +11,12 @@ module Groups
end
def execute
+ unless @current_user.can?(:admin_group, @group)
+ raise ::Gitlab::ImportExport::Error.new(
+ "User with ID: %s does not have permission to Group %s with ID: %s." %
+ [@current_user.id, @group.name, @group.id])
+ end
+
save!
end
diff --git a/app/services/groups/import_export/import_service.rb b/app/services/groups/import_export/import_service.rb
new file mode 100644
index 00000000000..628c8f5bac0
--- /dev/null
+++ b/app/services/groups/import_export/import_service.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+module Groups
+ module ImportExport
+ class ImportService
+ attr_reader :current_user, :group, :params
+
+ def initialize(group:, user:)
+ @group = group
+ @current_user = user
+ @shared = Gitlab::ImportExport::Shared.new(@group)
+ end
+
+ def execute
+ validate_user_permissions
+
+ if import_file && restorer.restore
+ @group
+ else
+ raise StandardError.new(@shared.errors.to_sentence)
+ end
+ rescue => e
+ raise StandardError.new(e.message)
+ ensure
+ remove_import_file
+ end
+
+ private
+
+ def import_file
+ @import_file ||= Gitlab::ImportExport::FileImporter.import(importable: @group,
+ archive_file: nil,
+ shared: @shared)
+ end
+
+ def restorer
+ @restorer ||= Gitlab::ImportExport::GroupTreeRestorer.new(user: @current_user,
+ shared: @shared,
+ group: @group,
+ group_hash: nil)
+ end
+
+ def remove_import_file
+ upload = @group.import_export_upload
+
+ return unless upload&.import_file&.file
+
+ upload.remove_import_file!
+ upload.save!
+ end
+
+ def validate_user_permissions
+ unless current_user.can?(:admin_group, group)
+ raise ::Gitlab::ImportExport::Error.new(
+ "User with ID: %s does not have permission to Group %s with ID: %s." %
+ [current_user.id, group.name, group.id])
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/ham_service.rb b/app/services/ham_service.rb
deleted file mode 100644
index 0bbdaa47a1b..00000000000
--- a/app/services/ham_service.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-# frozen_string_literal: true
-
-class HamService
- attr_accessor :spam_log
-
- def initialize(spam_log)
- @spam_log = spam_log
- end
-
- def mark_as_ham!
- if akismet.submit_ham
- spam_log.update_attribute(:submitted_as_ham, true)
- else
- false
- end
- end
-
- private
-
- def akismet
- user = spam_log.user
- @akismet ||= AkismetService.new(
- user.name,
- user.email,
- spam_log.text,
- ip_address: spam_log.source_ip,
- user_agent: spam_log.user_agent
- )
- end
-end
diff --git a/app/services/incident_management/create_issue_service.rb b/app/services/incident_management/create_issue_service.rb
new file mode 100644
index 00000000000..94b6f037924
--- /dev/null
+++ b/app/services/incident_management/create_issue_service.rb
@@ -0,0 +1,126 @@
+# frozen_string_literal: true
+
+module IncidentManagement
+ class CreateIssueService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ INCIDENT_LABEL = {
+ title: 'incident',
+ color: '#CC0033',
+ description: <<~DESCRIPTION.chomp
+ Denotes a disruption to IT services and \
+ the associated issues require immediate attention
+ DESCRIPTION
+ }.freeze
+
+ def initialize(project, params)
+ super(project, User.alert_bot, params)
+ end
+
+ def execute
+ return error_with('setting disabled') unless incident_management_setting.create_issue?
+ return error_with('invalid alert') unless alert.valid?
+
+ issue = create_issue
+ return error_with(issue_errors(issue)) unless issue.valid?
+
+ success(issue: issue)
+ end
+
+ private
+
+ def create_issue
+ issue = do_create_issue(label_ids: issue_label_ids)
+
+ # Create an unlabelled issue if we couldn't create the issue
+ # due to labels errors.
+ # See https://gitlab.com/gitlab-org/gitlab-foss/issues/65042
+ if issue.errors.include?(:labels)
+ log_label_error(issue)
+ issue = do_create_issue
+ end
+
+ issue
+ end
+
+ def do_create_issue(**params)
+ Issues::CreateService.new(
+ project,
+ current_user,
+ title: issue_title,
+ description: issue_description,
+ **params
+ ).execute
+ end
+
+ def issue_title
+ alert.full_title
+ end
+
+ def issue_description
+ horizontal_line = "\n---\n\n"
+
+ [
+ alert_summary,
+ alert_markdown,
+ issue_template_content
+ ].compact.join(horizontal_line)
+ end
+
+ def issue_label_ids
+ [
+ find_or_create_label(**INCIDENT_LABEL)
+ ].compact.map(&:id)
+ end
+
+ def find_or_create_label(**params)
+ Labels::FindOrCreateService
+ .new(current_user, project, **params)
+ .execute
+ end
+
+ def alert_summary
+ alert.issue_summary_markdown
+ end
+
+ def alert_markdown
+ alert.alert_markdown
+ end
+
+ def alert
+ strong_memoize(:alert) do
+ Gitlab::Alerting::Alert.new(project: project, payload: params).present
+ end
+ end
+
+ def issue_template_content
+ incident_management_setting.issue_template_content
+ end
+
+ def incident_management_setting
+ strong_memoize(:incident_management_setting) do
+ project.incident_management_setting ||
+ project.build_incident_management_setting
+ end
+ end
+
+ def issue_errors(issue)
+ issue.errors.full_messages.to_sentence
+ end
+
+ def log_label_error(issue)
+ log_info <<~TEXT.chomp
+ Cannot create incident issue with labels \
+ #{issue.labels.map(&:title).inspect} \
+ for "#{project.full_name}": #{issue.errors.full_messages.to_sentence}.
+ Retrying without labels.
+ TEXT
+ end
+
+ def error_with(message)
+ log_error(%{Cannot create incident issue for "#{project.full_name}": #{message}})
+
+ error(message)
+ end
+ end
+end
diff --git a/app/services/issuable/clone/attributes_rewriter.rb b/app/services/issuable/clone/attributes_rewriter.rb
index 334e50c0be5..2b436f6322c 100644
--- a/app/services/issuable/clone/attributes_rewriter.rb
+++ b/app/services/issuable/clone/attributes_rewriter.rb
@@ -12,7 +12,7 @@ module Issuable
def execute
update_attributes = { labels: cloneable_labels }
- milestone = cloneable_milestone
+ milestone = matching_milestone(original_entity.milestone&.title)
update_attributes[:milestone] = milestone if milestone.present?
new_entity.update(update_attributes)
@@ -23,11 +23,8 @@ module Issuable
private
- def cloneable_milestone
- return unless new_entity.supports_milestone?
-
- title = original_entity.milestone&.title
- return unless title
+ def matching_milestone(title)
+ return if title.blank? || !new_entity.supports_milestone?
params = { title: title, project_ids: new_entity.project&.id, group_ids: group&.id }
@@ -49,29 +46,32 @@ module Issuable
end
def copy_resource_label_events
- original_entity.resource_label_events.find_in_batches do |batch|
- events = batch.map do |event|
- entity_key = new_entity.is_a?(Issue) ? 'issue_id' : 'epic_id'
- event.attributes
- .except('id', 'reference', 'reference_html')
- .merge(entity_key => new_entity.id, 'action' => ResourceLabelEvent.actions[event.action])
- end
+ entity_key = new_entity.class.name.underscore.foreign_key
- Gitlab::Database.bulk_insert(ResourceLabelEvent.table_name, events)
+ copy_events(ResourceLabelEvent.table_name, original_entity.resource_label_events) do |event|
+ event.attributes
+ .except('id', 'reference', 'reference_html')
+ .merge(entity_key => new_entity.id, 'action' => ResourceLabelEvent.actions[event.action])
end
end
def copy_resource_weight_events
return unless original_entity.respond_to?(:resource_weight_events)
- original_entity.resource_weight_events.find_in_batches do |batch|
+ copy_events(ResourceWeightEvent.table_name, original_entity.resource_weight_events) do |event|
+ event.attributes
+ .except('id', 'reference', 'reference_html')
+ .merge('issue_id' => new_entity.id)
+ end
+ end
+
+ def copy_events(table_name, events_to_copy)
+ events_to_copy.find_in_batches do |batch|
events = batch.map do |event|
- event.attributes
- .except('id', 'reference', 'reference_html')
- .merge('issue_id' => new_entity.id)
- end
+ yield(event)
+ end.compact
- Gitlab::Database.bulk_insert(ResourceWeightEvent.table_name, events)
+ Gitlab::Database.bulk_insert(table_name, events)
end
end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 6cb84458d9b..830afbf4a43 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -11,10 +11,14 @@ class IssuableBaseService < BaseService
@skip_milestone_email = @params.delete(:skip_milestone_email)
end
- def filter_params(issuable)
+ def can_admin_issuable?(issuable)
ability_name = :"admin_#{issuable.to_ability_name}"
- unless can?(current_user, ability_name, issuable)
+ can?(current_user, ability_name, issuable)
+ end
+
+ def filter_params(issuable)
+ unless can_admin_issuable?(issuable)
params.delete(:milestone_id)
params.delete(:labels)
params.delete(:add_label_ids)
@@ -164,7 +168,7 @@ class IssuableBaseService < BaseService
before_create(issuable)
issuable_saved = issuable.with_transaction_returning_status do
- issuable.save && issuable.store_mentions!
+ issuable.save
end
if issuable_saved
@@ -229,7 +233,7 @@ class IssuableBaseService < BaseService
ensure_milestone_available(issuable)
issuable_saved = issuable.with_transaction_returning_status do
- issuable.save(touch: should_touch) && issuable.store_mentions!
+ issuable.save(touch: should_touch)
end
if issuable_saved
diff --git a/app/services/merge_requests/add_context_service.rb b/app/services/merge_requests/add_context_service.rb
new file mode 100644
index 00000000000..bb82fa23468
--- /dev/null
+++ b/app/services/merge_requests/add_context_service.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class AddContextService < MergeRequests::BaseService
+ def execute
+ return error("You are not allowed to access the requested resource", 403) unless current_user&.can?(:update_merge_request, merge_request)
+ return error("Context commits: #{duplicates} are already created", 400) unless duplicates.empty?
+ return error("One or more context commits' sha is not valid.", 400) if commits.size != commit_ids.size
+
+ context_commit_ids = []
+ MergeRequestContextCommit.transaction do
+ context_commit_ids = MergeRequestContextCommit.bulk_insert(context_commit_rows, return_ids: true)
+ MergeRequestContextCommitDiffFile.bulk_insert(diff_rows(context_commit_ids))
+ end
+
+ commits
+ end
+
+ private
+
+ def raw_repository
+ project.repository.raw_repository
+ end
+
+ def merge_request
+ params[:merge_request]
+ end
+
+ def commit_ids
+ params[:commits]
+ end
+
+ def commits
+ project.repository.commits_by(oids: commit_ids)
+ end
+
+ def context_commit_rows
+ @context_commit_rows ||= build_context_commit_rows(merge_request.id, commits)
+ end
+
+ def diff_rows(context_commit_ids)
+ @diff_rows ||= build_diff_rows(raw_repository, commits, context_commit_ids)
+ end
+
+ def encode_in_base64?(diff_text)
+ (diff_text.encoding == Encoding::BINARY && !diff_text.ascii_only?) ||
+ diff_text.include?("\0")
+ end
+
+ def duplicates
+ existing_oids = merge_request.merge_request_context_commits.map { |commit| commit.sha.to_s }
+ duplicate_oids = existing_oids.select do |existing_oid|
+ commit_ids.select { |commit_id| existing_oid.start_with?(commit_id) }.count > 0
+ end
+
+ duplicate_oids
+ end
+
+ def build_context_commit_rows(merge_request_id, commits)
+ commits.map.with_index do |commit, index|
+ # generate context commit information for given commit
+ commit_hash = commit.to_hash.except(:parent_ids)
+ sha = Gitlab::Database::ShaAttribute.serialize(commit_hash.delete(:id))
+ commit_hash.merge(
+ merge_request_id: merge_request_id,
+ relative_order: index,
+ sha: sha,
+ authored_date: Gitlab::Database.sanitize_timestamp(commit_hash[:authored_date]),
+ committed_date: Gitlab::Database.sanitize_timestamp(commit_hash[:committed_date])
+ )
+ end
+ end
+
+ def build_diff_rows(raw_repository, commits, context_commit_ids)
+ diff_rows = []
+ diff_order = 0
+
+ commits.flat_map.with_index do |commit, index|
+ commit_hash = commit.to_hash.except(:parent_ids)
+ sha = Gitlab::Database::ShaAttribute.serialize(commit_hash.delete(:id))
+ # generate context commit diff information for given commit
+ diffs = commit.diffs
+
+ compare = Gitlab::Git::Compare.new(
+ raw_repository,
+ diffs.diff_refs.start_sha,
+ diffs.diff_refs.head_sha
+ )
+ compare.diffs.map do |diff|
+ diff_hash = diff.to_hash.merge(
+ sha: sha,
+ binary: false,
+ merge_request_context_commit_id: context_commit_ids[index],
+ relative_order: diff_order
+ )
+
+ # Compatibility with old diffs created with Psych.
+ diff_hash.tap do |hash|
+ diff_text = hash[:diff]
+
+ if encode_in_base64?(diff_text)
+ hash[:binary] = true
+ hash[:diff] = [diff_text].pack('m0')
+ end
+ end
+
+ # Increase order for commit so when present the diffs we can use it to keep order
+ diff_order += 1
+ diff_rows << diff_hash
+ end
+ end
+
+ diff_rows
+ end
+ end
+end
diff --git a/app/services/merge_requests/create_pipeline_service.rb b/app/services/merge_requests/create_pipeline_service.rb
index 9eb11820f7a..8258efba6bf 100644
--- a/app/services/merge_requests/create_pipeline_service.rb
+++ b/app/services/merge_requests/create_pipeline_service.rb
@@ -24,7 +24,7 @@ module MergeRequests
##
# UpdateMergeRequestsWorker could be retried by an exception.
# pipelines for merge request should not be recreated in such case.
- return false if !allow_duplicate && merge_request.find_actual_head_pipeline&.triggered_by_merge_request?
+ return false if !allow_duplicate && merge_request.find_actual_head_pipeline&.merge_request?
return false if merge_request.has_no_commits?
true
diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb
index 9a37a0330fc..4a05d1fd7ef 100644
--- a/app/services/merge_requests/create_service.rb
+++ b/app/services/merge_requests/create_service.rb
@@ -27,6 +27,7 @@ module MergeRequests
create_pipeline_for(issuable, current_user)
issuable.update_head_pipeline
Gitlab::UsageDataCounters::MergeRequestCounter.count(:create)
+ link_lfs_objects(issuable)
super
end
@@ -64,6 +65,10 @@ module MergeRequests
raise Gitlab::Access::AccessDeniedError
end
end
+
+ def link_lfs_objects(issuable)
+ LinkLfsObjectsService.new(issuable.target_project).execute(issuable)
+ end
end
end
diff --git a/app/services/merge_requests/delete_non_latest_diffs_service.rb b/app/services/merge_requests/delete_non_latest_diffs_service.rb
index bdb7ec8a7c2..49ec3c7538c 100644
--- a/app/services/merge_requests/delete_non_latest_diffs_service.rb
+++ b/app/services/merge_requests/delete_non_latest_diffs_service.rb
@@ -13,7 +13,7 @@ module MergeRequests
diffs.each_batch(of: BATCH_SIZE) do |relation, index|
ids = relation.pluck_primary_key.map { |id| [id] }
- DeleteDiffFilesWorker.bulk_perform_in(index * 5.minutes, ids)
+ DeleteDiffFilesWorker.bulk_perform_in(index * 5.minutes, ids) # rubocop:disable Scalability/BulkPerformWithContext
end
end
end
diff --git a/app/services/merge_requests/link_lfs_objects_service.rb b/app/services/merge_requests/link_lfs_objects_service.rb
new file mode 100644
index 00000000000..191da594095
--- /dev/null
+++ b/app/services/merge_requests/link_lfs_objects_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class LinkLfsObjectsService < ::BaseService
+ def execute(merge_request, oldrev: merge_request.diff_base_sha, newrev: merge_request.diff_head_sha)
+ return if merge_request.source_project == project
+ return if no_changes?(oldrev, newrev)
+
+ new_lfs_oids = lfs_oids(merge_request.source_project.repository, oldrev, newrev)
+
+ return if new_lfs_oids.empty?
+
+ Projects::LfsPointers::LfsLinkService
+ .new(project)
+ .execute(new_lfs_oids)
+ end
+
+ private
+
+ def no_changes?(oldrev, newrev)
+ oldrev == newrev
+ end
+
+ def lfs_oids(source_repository, oldrev, newrev)
+ Gitlab::Git::LfsChanges
+ .new(source_repository, newrev)
+ .new_pointers(not_in: [oldrev])
+ .map(&:lfs_oid)
+ end
+ end
+end
diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb
index 4a109fe4e16..31097b9151a 100644
--- a/app/services/merge_requests/merge_service.rb
+++ b/app/services/merge_requests/merge_service.rb
@@ -79,6 +79,8 @@ module MergeRequests
end
merge_request.update!(merge_commit_sha: commit_id)
+ ensure
+ merge_request.update_column(:in_progress_merge_commit_sha, nil)
end
def try_merge
@@ -89,8 +91,6 @@ module MergeRequests
rescue => e
handle_merge_error(log_message: e.message)
raise_error('Something went wrong during merge')
- ensure
- merge_request.update!(in_progress_merge_commit_sha: nil)
end
def after_merge
diff --git a/app/services/merge_requests/mergeability_check_service.rb b/app/services/merge_requests/mergeability_check_service.rb
index 962e2327b3e..5b79e4d01f2 100644
--- a/app/services/merge_requests/mergeability_check_service.rb
+++ b/app/services/merge_requests/mergeability_check_service.rb
@@ -12,6 +12,13 @@ module MergeRequests
@merge_request = merge_request
end
+ def async_execute
+ return service_error if service_error
+ return unless merge_request.mark_as_checking
+
+ MergeRequestMergeabilityCheckWorker.perform_async(merge_request.id)
+ end
+
# Updates the MR merge_status. Whenever it switches to a can_be_merged state,
# the merge-ref is refreshed.
#
@@ -30,8 +37,7 @@ module MergeRequests
# and the merge-ref is synced. Success in case of being/becoming mergeable,
# error otherwise.
def execute(recheck: false, retry_lease: true)
- return ServiceResponse.error(message: 'Invalid argument') unless merge_request
- return ServiceResponse.error(message: 'Unsupported operation') if Gitlab::Database.read_only?
+ return service_error if service_error
return check_mergeability(recheck) unless merge_ref_auto_sync_lock_enabled?
in_write_lock(retry_lease: retry_lease) do |retried|
@@ -155,5 +161,15 @@ module MergeRequests
def merge_ref_auto_sync_lock_enabled?
Feature.enabled?(:merge_ref_auto_sync_lock, project, default_enabled: true)
end
+
+ def service_error
+ strong_memoize(:service_error) do
+ if !merge_request
+ ServiceResponse.error(message: 'Invalid argument')
+ elsif Gitlab::Database.read_only?
+ ServiceResponse.error(message: 'Unsupported operation')
+ end
+ end
+ end
end
end
diff --git a/app/services/merge_requests/migrate_external_diffs_service.rb b/app/services/merge_requests/migrate_external_diffs_service.rb
index 16050244637..89b1e594c95 100644
--- a/app/services/merge_requests/migrate_external_diffs_service.rb
+++ b/app/services/merge_requests/migrate_external_diffs_service.rb
@@ -9,7 +9,10 @@ module MergeRequests
def self.enqueue!
ids = MergeRequestDiff.ids_for_external_storage_migration(limit: MAX_JOBS)
+ # rubocop:disable Scalability/BulkPerformWithContext
+ # https://gitlab.com/gitlab-org/gitlab/issues/202100
MigrateExternalDiffsWorker.bulk_perform_async(ids.map { |id| [id] })
+ # rubocop:enable Scalability/BulkPerformWithContext
end
def initialize(merge_request_diff)
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index 396ddec6383..c6e1651fa26 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -21,6 +21,7 @@ module MergeRequests
# empty diff during a manual merge
close_upon_missing_source_branch_ref
post_merge_manually_merged
+ link_forks_lfs_objects
reload_merge_requests
outdate_suggestions
refresh_pipelines_on_merge_requests
@@ -91,17 +92,25 @@ module MergeRequests
end
# rubocop: enable CodeReuse/ActiveRecord
+ # Link LFS objects that exists in forks but does not exists in merge requests
+ # target project
+ def link_forks_lfs_objects
+ return unless @push.branch_updated?
+
+ merge_requests_for_forks.find_each do |mr|
+ LinkLfsObjectsService
+ .new(mr.target_project)
+ .execute(mr, oldrev: @push.oldrev, newrev: @push.newrev)
+ end
+ end
+
# Refresh merge request diff if we push to source or target branch of merge request
# Note: we should update merge requests from forks too
- # rubocop: disable CodeReuse/ActiveRecord
def reload_merge_requests
merge_requests = @project.merge_requests.opened
.by_source_or_target_branch(@push.branch_name).to_a
- # Fork merge requests
- merge_requests += MergeRequest.opened
- .where(source_branch: @push.branch_name, source_project: @project)
- .where.not(target_project: @project).to_a
+ merge_requests += merge_requests_for_forks.to_a
filter_merge_requests(merge_requests).each do |merge_request|
if branch_and_project_match?(merge_request) || @push.force_push?
@@ -117,7 +126,6 @@ module MergeRequests
# @source_merge_requests diffs (for MergeRequest#commit_shas for instance).
merge_requests_for_source_branch(reload: true)
end
- # rubocop: enable CodeReuse/ActiveRecord
def push_commit_ids
@push_commit_ids ||= @commits.map(&:id)
@@ -282,6 +290,15 @@ module MergeRequests
@source_merge_requests = nil if reload
@source_merge_requests ||= merge_requests_for(@push.branch_name)
end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def merge_requests_for_forks
+ @merge_requests_for_forks ||=
+ MergeRequest.opened
+ .where(source_branch: @push.branch_name, source_project: @project)
+ .where.not(target_project: @project)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
end
end
diff --git a/app/services/metrics/dashboard/base_service.rb b/app/services/metrics/dashboard/base_service.rb
index c51c88d776a..3cd7d8437b1 100644
--- a/app/services/metrics/dashboard/base_service.rb
+++ b/app/services/metrics/dashboard/base_service.rb
@@ -38,22 +38,22 @@ module Metrics
# Determines whether users should be able to view
# dashboards at all.
def allowed?
- if params[:environment]
- Ability.allowed?(current_user, :read_environment, project)
- elsif params[:cluster]
- true # Authorization handled at controller level
- else
- false
- end
+ return false unless params[:environment]
+
+ Ability.allowed?(current_user, :read_environment, project)
end
# Returns a new dashboard Hash, supplemented with DB info
def process_dashboard
::Gitlab::Metrics::Dashboard::Processor
- .new(project, raw_dashboard, sequence, params)
+ .new(project, raw_dashboard, sequence, process_params)
.process
end
+ def process_params
+ params
+ end
+
# @return [String] Relative filepath of the dashboard yml
def dashboard_path
params[:dashboard_path]
diff --git a/app/services/metrics/dashboard/clone_dashboard_service.rb b/app/services/metrics/dashboard/clone_dashboard_service.rb
index b2ec44cb814..990dc462432 100644
--- a/app/services/metrics/dashboard/clone_dashboard_service.rb
+++ b/app/services/metrics/dashboard/clone_dashboard_service.rb
@@ -8,8 +8,18 @@ module Metrics
ALLOWED_FILE_TYPE = '.yml'
USER_DASHBOARDS_DIR = ::Metrics::Dashboard::ProjectDashboardService::DASHBOARD_ROOT
- def self.allowed_dashboard_templates
- @allowed_dashboard_templates ||= Set[::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH].freeze
+ class << self
+ def allowed_dashboard_templates
+ @allowed_dashboard_templates ||= Set[::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH].freeze
+ end
+
+ def sequences
+ @sequences ||= {
+ ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH => [::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter,
+ ::Gitlab::Metrics::Dashboard::Stages::ProjectMetricsInserter,
+ ::Gitlab::Metrics::Dashboard::Stages::Sorter].freeze
+ }.freeze
+ end
end
def execute
@@ -92,7 +102,9 @@ module Metrics
end
def new_dashboard_content
- File.read(Rails.root.join(dashboard_template))
+ ::Gitlab::Metrics::Dashboard::Processor
+ .new(project, raw_dashboard, sequence, {})
+ .process.deep_stringify_keys.to_yaml
end
def repository
@@ -106,6 +118,14 @@ module Metrics
result
end
end
+
+ def raw_dashboard
+ YAML.safe_load(File.read(Rails.root.join(dashboard_template)))
+ end
+
+ def sequence
+ self.class.sequences[dashboard_template]
+ end
end
end
end
diff --git a/app/services/metrics/dashboard/default_embed_service.rb b/app/services/metrics/dashboard/default_embed_service.rb
index e1bd98bd5c2..39f7c3943dd 100644
--- a/app/services/metrics/dashboard/default_embed_service.rb
+++ b/app/services/metrics/dashboard/default_embed_service.rb
@@ -20,6 +20,12 @@ module Metrics
system_metrics_kubernetes_container_cores_total
).freeze
+ class << self
+ def valid_params?(params)
+ params[:embedded].present?
+ end
+ end
+
# Returns a new dashboard with only the matching
# metrics from the system dashboard, stripped of groups.
# @return [Hash]
diff --git a/app/services/metrics/dashboard/predefined_dashboard_service.rb b/app/services/metrics/dashboard/predefined_dashboard_service.rb
index 1be1a000854..297f00b1be9 100644
--- a/app/services/metrics/dashboard/predefined_dashboard_service.rb
+++ b/app/services/metrics/dashboard/predefined_dashboard_service.rb
@@ -15,6 +15,10 @@ module Metrics
].freeze
class << self
+ def valid_params?(params)
+ matching_dashboard?(params[:dashboard_path])
+ end
+
def matching_dashboard?(filepath)
filepath == self::DASHBOARD_PATH
end
diff --git a/app/services/metrics/dashboard/project_dashboard_service.rb b/app/services/metrics/dashboard/project_dashboard_service.rb
index b0d54ee9347..fadbe0fae01 100644
--- a/app/services/metrics/dashboard/project_dashboard_service.rb
+++ b/app/services/metrics/dashboard/project_dashboard_service.rb
@@ -9,6 +9,10 @@ module Metrics
DASHBOARD_ROOT = ".gitlab/dashboards"
class << self
+ def valid_params?(params)
+ params[:dashboard_path].present?
+ end
+
def all_dashboard_paths(project)
file_finder(project)
.list_files_for(DASHBOARD_ROOT)
diff --git a/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb b/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb
new file mode 100644
index 00000000000..d705c3f3ce5
--- /dev/null
+++ b/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+# Fetches the self monitoring metrics dashboard and formats the output.
+# Use Gitlab::Metrics::Dashboard::Finder to retrieve dashboards.
+module Metrics
+ module Dashboard
+ class SelfMonitoringDashboardService < ::Metrics::Dashboard::PredefinedDashboardService
+ DASHBOARD_PATH = 'config/prometheus/self_monitoring_default.yml'
+ DASHBOARD_NAME = 'Default'
+
+ SEQUENCE = [
+ STAGES::ProjectMetricsInserter,
+ STAGES::EndpointInserter,
+ STAGES::Sorter
+ ].freeze
+
+ class << self
+ def valid_params?(params)
+ matching_dashboard?(params[:dashboard_path]) || self_monitoring_project?(params)
+ end
+
+ def all_dashboard_paths(_project)
+ [{
+ path: DASHBOARD_PATH,
+ display_name: DASHBOARD_NAME,
+ default: true,
+ system_dashboard: false
+ }]
+ end
+
+ def self_monitoring_project?(params)
+ params[:dashboard_path].nil? && params[:environment]&.project&.self_monitoring?
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/metrics/dashboard/system_dashboard_service.rb b/app/services/metrics/dashboard/system_dashboard_service.rb
index bef65dbe1c2..aa8421e10d5 100644
--- a/app/services/metrics/dashboard/system_dashboard_service.rb
+++ b/app/services/metrics/dashboard/system_dashboard_service.rb
@@ -11,6 +11,7 @@ module Metrics
SEQUENCE = [
STAGES::CommonMetricsInserter,
STAGES::ProjectMetricsInserter,
+ STAGES::ProjectMetricsDetailsInserter,
STAGES::EndpointInserter,
STAGES::Sorter
].freeze
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index 50dc98b88e9..4a0d85038ee 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -2,7 +2,6 @@
module Notes
class CreateService < ::Notes::BaseService
- # rubocop:disable Metrics/CyclomaticComplexity
def execute
note = Notes::BuildService.new(project, current_user, params.except(:merge_request_diff_head_sha)).execute
@@ -34,7 +33,7 @@ module Notes
end
note_saved = note.with_transaction_returning_status do
- !only_commands && note.save && note.store_mentions!
+ !only_commands && note.save
end
if note_saved
@@ -67,7 +66,6 @@ module Notes
note
end
- # rubocop:enable Metrics/CyclomaticComplexity
private
diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb
index 15c556498ec..3070e7b0e53 100644
--- a/app/services/notes/update_service.rb
+++ b/app/services/notes/update_service.rb
@@ -3,14 +3,14 @@
module Notes
class UpdateService < BaseService
def execute(note)
- return note unless note.editable?
+ return note unless note.editable? && params.present?
old_mentioned_users = note.mentioned_users(current_user).to_a
note.assign_attributes(params.merge(updated_by: current_user))
note.with_transaction_returning_status do
- note.save && note.store_mentions!
+ note.save
end
only_commands = false
diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb
new file mode 100644
index 00000000000..e3818e76c4c
--- /dev/null
+++ b/app/services/post_receive_service.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+# PostReceiveService class
+#
+# Used for scheduling related jobs after a push action has been performed
+class PostReceiveService
+ attr_reader :user, :project, :params
+
+ def initialize(user, project, params)
+ @user = user
+ @project = project
+ @params = params
+ end
+
+ def execute
+ response = Gitlab::InternalPostReceive::Response.new
+
+ push_options = Gitlab::PushOptions.new(params[:push_options])
+
+ response.reference_counter_decreased = Gitlab::ReferenceCounter.new(params[:gl_repository]).decrease
+
+ PostReceive.perform_async(params[:gl_repository], params[:identifier],
+ params[:changes], push_options.as_json)
+
+ mr_options = push_options.get(:merge_request)
+ if mr_options.present?
+ message = process_mr_push_options(mr_options, project, user, params[:changes])
+ response.add_alert_message(message)
+ end
+
+ broadcast_message = BroadcastMessage.current&.last&.message
+ response.add_alert_message(broadcast_message)
+
+ response.add_merge_request_urls(merge_request_urls)
+
+ # Neither User nor Project are guaranteed to be returned; an orphaned write deploy
+ # key could be used
+ if user && project
+ redirect_message = Gitlab::Checks::ProjectMoved.fetch_message(user.id, project.id)
+ project_created_message = Gitlab::Checks::ProjectCreated.fetch_message(user.id, project.id)
+
+ response.add_basic_message(redirect_message)
+ response.add_basic_message(project_created_message)
+ end
+
+ response
+ end
+
+ def process_mr_push_options(push_options, project, user, changes)
+ Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/61359')
+
+ service = ::MergeRequests::PushOptionsHandlerService.new(
+ project, user, changes, push_options
+ ).execute
+
+ if service.errors.present?
+ push_options_warning(service.errors.join("\n\n"))
+ end
+ end
+
+ def push_options_warning(warning)
+ options = Array.wrap(params[:push_options]).map { |p| "'#{p}'" }.join(' ')
+ "WARNINGS:\nError encountered with push options #{options}: #{warning}"
+ end
+
+ def merge_request_urls
+ ::MergeRequests::GetUrlsService.new(project).execute(params[:changes])
+ end
+end
diff --git a/app/services/projects/after_import_service.rb b/app/services/projects/after_import_service.rb
index 6fc15db9b4c..ee2dde8aa7f 100644
--- a/app/services/projects/after_import_service.rb
+++ b/app/services/projects/after_import_service.rb
@@ -12,7 +12,9 @@ module Projects
service = Projects::HousekeepingService.new(@project)
service.execute do
- repository.delete_all_refs_except(RESERVED_REF_PREFIXES)
+ import_failure_service.with_retry(action: 'delete_all_refs') do
+ repository.delete_all_refs_except(RESERVED_REF_PREFIXES)
+ end
end
# Right now we don't actually have a way to know if a project
@@ -26,8 +28,12 @@ module Projects
private
+ def import_failure_service
+ Gitlab::ImportExport::ImportFailureService.new(@project)
+ end
+
def repository
- @repository ||= @project.repository
+ @project.repository
end
end
end
diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb
new file mode 100644
index 00000000000..4ca3b154e4b
--- /dev/null
+++ b/app/services/projects/alerting/notify_service.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Projects
+ module Alerting
+ class NotifyService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ def execute(token)
+ return forbidden unless alerts_service_activated?
+ return unauthorized unless valid_token?(token)
+
+ process_incident_issues
+
+ ServiceResponse.success
+ rescue Gitlab::Alerting::NotificationPayloadParser::BadPayloadError
+ bad_request
+ end
+
+ private
+
+ delegate :alerts_service, :alerts_service_activated?, to: :project
+
+ def process_incident_issues
+ IncidentManagement::ProcessAlertWorker
+ .perform_async(project.id, parsed_payload)
+ end
+
+ def parsed_payload
+ Gitlab::Alerting::NotificationPayloadParser.call(params.to_h)
+ end
+
+ def valid_token?(token)
+ token == alerts_service.token
+ end
+
+ def bad_request
+ ServiceResponse.error(message: 'Bad Request', http_status: 400)
+ end
+
+ def unauthorized
+ ServiceResponse.error(message: 'Unauthorized', http_status: 401)
+ end
+
+ def forbidden
+ ServiceResponse.error(message: 'Forbidden', http_status: 403)
+ end
+ end
+ end
+end
diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb
index b995df12e56..046745d725e 100644
--- a/app/services/projects/container_repository/cleanup_tags_service.rb
+++ b/app/services/projects/container_repository/cleanup_tags_service.rb
@@ -5,7 +5,7 @@ module Projects
class CleanupTagsService < BaseService
def execute(container_repository)
return error('feature disabled') unless can_use?
- return error('access denied') unless can_admin?
+ return error('access denied') unless can_destroy?
tags = container_repository.tags
tags_by_digest = group_by_digest(tags)
@@ -82,8 +82,10 @@ module Projects
end
end
- def can_admin?
- can?(current_user, :admin_container_image, project)
+ def can_destroy?
+ return true if params['container_expiration_policy']
+
+ can?(current_user, :destroy_container_image, project)
end
def can_use?
diff --git a/app/services/projects/container_repository/delete_tags_service.rb b/app/services/projects/container_repository/delete_tags_service.rb
index 88ff3c2c9df..21081bd077f 100644
--- a/app/services/projects/container_repository/delete_tags_service.rb
+++ b/app/services/projects/container_repository/delete_tags_service.rb
@@ -14,12 +14,25 @@ module Projects
private
+ # Delete tags by name with a single DELETE request. This is only supported
+ # by the GitLab Container Registry fork. See
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23325 for details.
+ def fast_delete(container_repository, tag_names)
+ deleted_tags = tag_names.select do |name|
+ container_repository.delete_tag_by_name(name)
+ end
+
+ deleted_tags.any? ? success(deleted: deleted_tags) : error('could not delete 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.
+ # This is used to preverse compatibility with third-party registries that
+ # don't support fast delete.
# See https://gitlab.com/gitlab-org/gitlab/issues/15737 for a discussion
- def smart_delete(container_repository, tag_names)
+ def slow_delete(container_repository, tag_names)
# generates the blobs for the dummy image
dummy_manifest = container_repository.client.generate_empty_manifest(container_repository.path)
return error('could not generate manifest') if dummy_manifest.nil?
@@ -29,13 +42,22 @@ module Projects
# 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 deleted_tags.any? && container_repository.delete_tag_by_digest(deleted_tags.values.first)
+ if deleted_tags.any? && container_repository.delete_tag_by_digest(deleted_tags.each_value.first)
success(deleted: deleted_tags.keys)
else
error('could not delete tags')
end
end
+ def smart_delete(container_repository, tag_names)
+ fast_delete_enabled = Feature.enabled?(:container_registry_fast_tag_delete, default_enabled: true)
+ if fast_delete_enabled && container_repository.client.supports_tag_delete?
+ fast_delete(container_repository, tag_names)
+ else
+ slow_delete(container_repository, tag_names)
+ end
+ end
+
# update the manifests of the tags with the new dummy image
def replace_tag_manifests(container_repository, dummy_manifest, tag_names)
deleted_tags = {}
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index ef06545b27d..7bf68e7d315 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -90,6 +90,7 @@ module Projects
end
@project.track_project_repository
+ @project.create_project_setting unless @project.project_setting
event_service.create_project(@project, current_user)
system_hook_service.execute_hooks_for(@project, :create)
diff --git a/app/services/projects/destroy_rollback_service.rb b/app/services/projects/destroy_rollback_service.rb
new file mode 100644
index 00000000000..7f0ca63a406
--- /dev/null
+++ b/app/services/projects/destroy_rollback_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Projects
+ class DestroyRollbackService < BaseService
+ include Gitlab::ShellAdapter
+
+ def execute
+ return unless project
+
+ Projects::ForksCountService.new(project).delete_cache
+
+ unless rollback_repository(project.repository)
+ raise_error(s_('DeleteProject|Failed to restore project repository. Please contact the administrator.'))
+ end
+
+ unless rollback_repository(project.wiki.repository)
+ raise_error(s_('DeleteProject|Failed to restore wiki repository. Please contact the administrator.'))
+ end
+ end
+
+ private
+
+ def rollback_repository(repository)
+ return true unless repository
+
+ result = Repositories::DestroyRollbackService.new(repository).execute
+
+ result[:status] == :success
+ end
+ end
+end
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index cbed794f92e..066d1f1ca72 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -6,9 +6,6 @@ module Projects
DestroyError = Class.new(StandardError)
- DELETED_FLAG = '+deleted'
- REPO_REMOVAL_DELAY = 5.minutes.to_i
-
def async_execute
project.update_attribute(:pending_delete, true)
@@ -18,7 +15,7 @@ module Projects
schedule_stale_repos_removal
job_id = ProjectDestroyWorker.perform_async(project.id, current_user.id, params)
- Rails.logger.info("User #{current_user.id} scheduled destruction of project #{project.full_path} with job ID #{job_id}") # rubocop:disable Gitlab/RailsLogger
+ log_info("User #{current_user.id} scheduled destruction of project #{project.full_path} with job ID #{job_id}")
end
def execute
@@ -48,82 +45,34 @@ module Projects
raise
end
- def attempt_repositories_rollback
- return unless @project
-
- flush_caches(@project)
-
- unless rollback_repository(removal_path(repo_path), repo_path)
- raise_error(s_('DeleteProject|Failed to restore project repository. Please contact the administrator.'))
- end
-
- unless rollback_repository(removal_path(wiki_path), wiki_path)
- raise_error(s_('DeleteProject|Failed to restore wiki repository. Please contact the administrator.'))
- end
- end
-
private
- def repo_path
- project.disk_path
- end
-
- def wiki_path
- project.wiki.disk_path
- end
-
def trash_repositories!
- unless remove_repository(repo_path)
+ unless remove_repository(project.repository)
raise_error(s_('DeleteProject|Failed to remove project repository. Please try again or contact administrator.'))
end
- unless remove_repository(wiki_path)
+ unless remove_repository(project.wiki.repository)
raise_error(s_('DeleteProject|Failed to remove wiki repository. Please try again or contact administrator.'))
end
end
- def remove_repository(path)
- # There is a possibility project does not have repository or wiki
- return true unless repo_exists?(path)
+ def remove_repository(repository)
+ return true unless repository
- new_path = removal_path(path)
+ result = Repositories::DestroyService.new(repository).execute
- if mv_repository(path, new_path)
- log_info(%Q{Repository "#{path}" moved to "#{new_path}" for project "#{project.full_path}"})
-
- project.run_after_commit do
- GitlabShellWorker.perform_in(REPO_REMOVAL_DELAY, :remove_repository, self.repository_storage, new_path)
- end
- else
- false
- end
+ result[:status] == :success
end
def schedule_stale_repos_removal
- repo_paths = [removal_path(repo_path), removal_path(wiki_path)]
+ repos = [project.repository, project.wiki.repository]
- # Ideally it should wait until the regular removal phase finishes,
- # so let's delay it a bit further.
- repo_paths.each do |path|
- GitlabShellWorker.perform_in(REPO_REMOVAL_DELAY * 2, :remove_repository, project.repository_storage, path)
- end
- end
+ repos.each do |repository|
+ next unless repository
- def rollback_repository(old_path, new_path)
- # There is a possibility project does not have repository or wiki
- return true unless repo_exists?(old_path)
-
- mv_repository(old_path, new_path)
- end
-
- def repo_exists?(path)
- gitlab_shell.repository_exists?(project.repository_storage, path + '.git')
- end
-
- def mv_repository(from_path, to_path)
- return true unless repo_exists?(from_path)
-
- gitlab_shell.mv_repository(project.repository_storage, from_path, to_path)
+ Repositories::ShellDestroyService.new(repository).execute(Repositories::ShellDestroyService::STALE_REMOVAL_DELAY)
+ end
end
def attempt_rollback(project, message)
@@ -191,32 +140,9 @@ module Projects
raise DestroyError.new(message)
end
- # Build a path for removing repositories
- # We use `+` because its not allowed by GitLab so user can not create
- # project with name cookies+119+deleted and capture someone stalled repository
- #
- # gitlab/cookies.git -> gitlab/cookies+119+deleted.git
- #
- def removal_path(path)
- "#{path}+#{project.id}#{DELETED_FLAG}"
- end
-
def flush_caches(project)
- ignore_git_errors(repo_path) { project.repository.before_delete }
-
- ignore_git_errors(wiki_path) { Repository.new(wiki_path, project, disk_path: repo_path).before_delete }
-
Projects::ForksCountService.new(project).delete_cache
end
-
- # If we get a Gitaly error, the repository may be corrupted. We can
- # ignore these errors since we're going to trash the repositories
- # anyway.
- def ignore_git_errors(disk_path, &block)
- yield
- rescue Gitlab::Git::CommandError => e
- Gitlab::GitLogger.warn(class: self.class.name, project_id: project.id, disk_path: disk_path, message: e.to_s)
- end
end
end
diff --git a/app/services/projects/detect_repository_languages_service.rb b/app/services/projects/detect_repository_languages_service.rb
index d3680637217..942cd8162e4 100644
--- a/app/services/projects/detect_repository_languages_service.rb
+++ b/app/services/projects/detect_repository_languages_service.rb
@@ -12,7 +12,7 @@ module Projects
matching_programming_languages = ensure_programming_languages(detection)
RepositoryLanguage.transaction do
- project.repository_languages.where(programming_language_id: detection.deletions).delete_all
+ RepositoryLanguage.where(project_id: project.id, programming_language_id: detection.deletions).delete_all
detection.updates.each do |update|
RepositoryLanguage
diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb
index e66a0ed181a..fcfea567885 100644
--- a/app/services/projects/fork_service.rb
+++ b/app/services/projects/fork_service.rb
@@ -26,17 +26,7 @@ module Projects
build_fork_network_member(fork_to_project)
- if link_fork_network(fork_to_project)
- # A forked project stores its LFS objects in the `forked_from_project`.
- # So the LFS objects become inaccessible, and therefore delete them from
- # the database so they'll get cleaned up.
- #
- # TODO: refactor this to get the correct lfs objects when implementing
- # https://gitlab.com/gitlab-org/gitlab-foss/issues/39769
- fork_to_project.lfs_objects_projects.delete_all
-
- fork_to_project
- end
+ fork_to_project if link_fork_network(fork_to_project)
end
def fork_new_project
diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb
index cc12aacaf02..a4771e864d4 100644
--- a/app/services/projects/import_service.rb
+++ b/app/services/projects/import_service.rb
@@ -66,23 +66,21 @@ module Projects
end
def import_repository
- begin
- refmap = importer_class.try(:refmap) if has_importer?
-
- if refmap
- project.ensure_repository
- project.repository.fetch_as_mirror(project.import_url, refmap: refmap)
- else
- gitlab_shell.import_project_repository(project)
- end
- rescue Gitlab::Shell::Error => e
- # Expire cache to prevent scenarios such as:
- # 1. First import failed, but the repo was imported successfully, so +exists?+ returns true
- # 2. Retried import, repo is broken or not imported but +exists?+ still returns true
- project.repository.expire_content_cache if project.repository_exists?
+ refmap = importer_class.try(:refmap) if has_importer?
- raise Error, e.message
+ if refmap
+ project.ensure_repository
+ project.repository.fetch_as_mirror(project.import_url, refmap: refmap)
+ else
+ gitlab_shell.import_project_repository(project)
end
+ rescue Gitlab::Shell::Error => e
+ # Expire cache to prevent scenarios such as:
+ # 1. First import failed, but the repo was imported successfully, so +exists?+ returns true
+ # 2. Retried import, repo is broken or not imported but +exists?+ still returns true
+ project.repository.expire_content_cache if project.repository_exists?
+
+ raise Error, e.message
end
def download_lfs_objects
diff --git a/app/services/projects/lfs_pointers/lfs_download_service.rb b/app/services/projects/lfs_pointers/lfs_download_service.rb
index a009f479d5d..bd70012c76c 100644
--- a/app/services/projects/lfs_pointers/lfs_download_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_download_service.rb
@@ -39,9 +39,9 @@ module Projects
def download_lfs_file!
with_tmp_file do |tmp_file|
download_and_save_file!(tmp_file)
- project.all_lfs_objects << LfsObject.new(oid: lfs_oid,
- size: lfs_size,
- file: tmp_file)
+ project.lfs_objects << LfsObject.new(oid: lfs_oid,
+ size: lfs_size,
+ file: tmp_file)
success
end
diff --git a/app/services/projects/lsif_data_service.rb b/app/services/projects/lsif_data_service.rb
new file mode 100644
index 00000000000..971885b680e
--- /dev/null
+++ b/app/services/projects/lsif_data_service.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+module Projects
+ class LsifDataService
+ attr_reader :file, :project, :path, :commit_id,
+ :docs, :doc_ranges, :ranges, :def_refs, :hover_refs
+
+ CACHE_EXPIRE_IN = 1.hour
+
+ def initialize(file, project, params)
+ @file = file
+ @project = project
+ @path = params[:path]
+ @commit_id = params[:commit_id]
+ end
+
+ def execute
+ fetch_data!
+
+ doc_ranges[doc_id]&.map do |range_id|
+ location, ref_id = ranges[range_id].values_at('loc', 'ref_id')
+ line_data, column_data = location
+
+ {
+ start_line: line_data.first,
+ end_line: line_data.last,
+ start_char: column_data.first,
+ end_char: column_data.last,
+ definition_url: definition_url_for(def_refs[ref_id]),
+ hover: highlighted_hover(hover_refs[ref_id])
+ }
+ end
+ end
+
+ private
+
+ def fetch_data
+ Rails.cache.fetch("project:#{project.id}:lsif:#{commit_id}", expires_in: CACHE_EXPIRE_IN) do
+ data = nil
+
+ file.open do |stream|
+ Zlib::GzipReader.wrap(stream) do |gz_stream|
+ data = JSON.parse(gz_stream.read)
+ end
+ end
+
+ data
+ end
+ end
+
+ def fetch_data!
+ data = fetch_data
+
+ @docs = data['docs']
+ @doc_ranges = data['doc_ranges']
+ @ranges = data['ranges']
+ @def_refs = data['def_refs']
+ @hover_refs = data['hover_refs']
+ end
+
+ def doc_id
+ @doc_id ||= docs.reduce(nil) do |doc_id, (id, doc_path)|
+ next doc_id unless doc_path =~ /#{path}$/
+
+ if doc_id.nil? || docs[doc_id].size > doc_path.size
+ doc_id = id
+ end
+
+ doc_id
+ end
+ end
+
+ def dir_absolute_path
+ @dir_absolute_path ||= docs[doc_id]&.delete_suffix(path)
+ end
+
+ def definition_url_for(ref_id)
+ return unless range = ranges[ref_id]
+
+ def_doc_id, location = range.values_at('doc_id', 'loc')
+ localized_doc_url = docs[def_doc_id].delete_prefix(dir_absolute_path)
+
+ # location is stored as [[start_line, end_line], [start_char, end_char]]
+ start_line = location.first.first
+
+ line_anchor = "L#{start_line + 1}"
+ definition_ref_path = [commit_id, localized_doc_url].join('/')
+
+ Gitlab::Routing.url_helpers.project_blob_path(project, definition_ref_path, anchor: line_anchor)
+ end
+
+ def highlighted_hover(hovers)
+ hovers&.map do |hover|
+ # Documentation for a method which is added as comments on top of the method
+ # is stored as a raw string value in LSIF file
+ next { value: hover } unless hover.is_a?(Hash)
+
+ value = Gitlab::Highlight.highlight(nil, hover['value'], language: hover['language'])
+ { language: hover['language'], value: value }
+ end
+ end
+ end
+end
diff --git a/app/services/projects/move_access_service.rb b/app/services/projects/move_access_service.rb
index 8e2c3ad2f69..cddc544170f 100644
--- a/app/services/projects/move_access_service.rb
+++ b/app/services/projects/move_access_service.rb
@@ -20,6 +20,8 @@ module Projects
::Projects::MoveProjectAuthorizationsService.new(@project, @current_user)
.execute(source_project, remove_remaining_elements: remove_remaining_elements)
+ @project.save(touch: false)
+
success
end
end
diff --git a/app/services/projects/move_lfs_objects_projects_service.rb b/app/services/projects/move_lfs_objects_projects_service.rb
index 10e19014db4..8cc420d7ba7 100644
--- a/app/services/projects/move_lfs_objects_projects_service.rb
+++ b/app/services/projects/move_lfs_objects_projects_service.rb
@@ -16,7 +16,7 @@ module Projects
private
def move_lfs_objects_projects
- non_existent_lfs_objects_projects.update_all(project_id: @project.lfs_storage_project.id)
+ non_existent_lfs_objects_projects.update_all(project_id: @project.id)
end
def remove_remaining_lfs_objects_project
diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb
index 706a6f01a75..27bbf5c6e57 100644
--- a/app/services/projects/operations/update_service.rb
+++ b/app/services/projects/operations/update_service.rb
@@ -15,6 +15,8 @@ module Projects
error_tracking_params
.merge(metrics_setting_params)
.merge(grafana_integration_params)
+ .merge(prometheus_integration_params)
+ .merge(incident_management_setting_params)
end
def metrics_setting_params
@@ -30,6 +32,27 @@ module Projects
settings = params[:error_tracking_setting_attributes]
return {} if settings.blank?
+ if error_tracking_params_partial_updates?(settings)
+ error_tracking_params_for_partial_update(settings)
+ else
+ error_tracking_params_for_update(settings)
+ end
+ end
+
+ def error_tracking_params_partial_updates?(settings)
+ # Help from @splattael :bow:
+ # Make sure we're converting to symbols because
+ # * ActionController::Parameters#keys returns a list of strings
+ # * in specs we're using hashes with symbols as keys
+
+ settings.keys.map(&:to_sym) == %i[enabled]
+ end
+
+ def error_tracking_params_for_partial_update(settings)
+ { error_tracking_setting_attributes: settings }
+ end
+
+ def error_tracking_params_for_update(settings)
api_url = ::ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from(
api_host: settings[:api_host],
project_slug: settings.dig(:project, :slug),
@@ -56,6 +79,19 @@ module Projects
{ grafana_integration_attributes: attrs.merge(_destroy: destroy) }
end
+
+ def prometheus_integration_params
+ return {} unless attrs = params[:prometheus_integration_attributes]
+
+ service = project.find_or_initialize_service(::PrometheusService.to_param)
+ service.assign_attributes(attrs)
+
+ { prometheus_service_attributes: service.attributes.except(*%w(id project_id created_at updated_at)) }
+ end
+
+ def incident_management_setting_params
+ params.slice(:incident_management_setting_attributes)
+ end
end
end
end
diff --git a/app/services/projects/overwrite_project_service.rb b/app/services/projects/overwrite_project_service.rb
index c5e38f166da..958a00afbb8 100644
--- a/app/services/projects/overwrite_project_service.rb
+++ b/app/services/projects/overwrite_project_service.rb
@@ -55,13 +55,13 @@ module Projects
end
def attempt_restore_repositories(project)
- ::Projects::DestroyService.new(project, @current_user).attempt_repositories_rollback
+ ::Projects::DestroyRollbackService.new(project, @current_user).execute
end
def add_source_project_to_fork_network(source_project)
return unless @project.fork_network
- # Because he have moved all references in the fork network from the source_project
+ # Because they have moved all references in the fork network from the source_project
# we won't be able to query the database (only through its cached data),
# for its former relationships. That's why we're adding it to the network
# as a fork of the target project
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index 718416a03d4..309eab59463 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -13,8 +13,6 @@ module Projects
include Gitlab::ShellAdapter
TransferError = Class.new(StandardError)
- attr_reader :new_namespace
-
def execute(new_namespace)
@new_namespace = new_namespace
@@ -39,6 +37,8 @@ module Projects
private
+ attr_reader :old_path, :new_path, :new_namespace
+
# rubocop: disable CodeReuse/ActiveRecord
def transfer(project)
@old_path = project.full_path
@@ -132,6 +132,8 @@ module Projects
end
def rollback_folder_move
+ return if project.hashed_storage?(:repository)
+
move_repo_folder(@new_path, @old_path)
move_repo_folder("#{@new_path}.wiki", "#{@old_path}.wiki")
end
diff --git a/app/services/projects/unlink_fork_service.rb b/app/services/projects/unlink_fork_service.rb
index e7e0141099e..b3cf27373cd 100644
--- a/app/services/projects/unlink_fork_service.rb
+++ b/app/services/projects/unlink_fork_service.rb
@@ -52,6 +52,10 @@ module Projects
Projects::ForksCountService.new(project).refresh_cache
end
+ # TODO: Remove this method once all LfsObjectsProject records are backfilled
+ # for forks.
+ #
+ # See https://gitlab.com/gitlab-org/gitlab/issues/122002 for more info.
def save_lfs_objects
return unless @project.forked?
diff --git a/app/services/repositories/base_service.rb b/app/services/repositories/base_service.rb
new file mode 100644
index 00000000000..6a39399c791
--- /dev/null
+++ b/app/services/repositories/base_service.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+class Repositories::BaseService < BaseService
+ include Gitlab::ShellAdapter
+
+ DELETED_FLAG = '+deleted'
+
+ attr_reader :repository
+
+ delegate :project, :disk_path, :full_path, to: :repository
+ delegate :repository_storage, to: :project
+
+ def initialize(repository)
+ @repository = repository
+ end
+
+ def repo_exists?(path)
+ gitlab_shell.repository_exists?(repository_storage, path + '.git')
+ end
+
+ def mv_repository(from_path, to_path)
+ return true unless repo_exists?(from_path)
+
+ gitlab_shell.mv_repository(repository_storage, from_path, to_path)
+ end
+
+ # Build a path for removing repositories
+ # We use `+` because its not allowed by GitLab so user can not create
+ # project with name cookies+119+deleted and capture someone stalled repository
+ #
+ # gitlab/cookies.git -> gitlab/cookies+119+deleted.git
+ #
+ def removal_path
+ "#{disk_path}+#{project.id}#{DELETED_FLAG}"
+ end
+
+ # If we get a Gitaly error, the repository may be corrupted. We can
+ # ignore these errors since we're going to trash the repositories
+ # anyway.
+ def ignore_git_errors(&block)
+ yield
+ rescue Gitlab::Git::CommandError => e
+ Gitlab::GitLogger.warn(class: self.class.name, project_id: project.id, disk_path: disk_path, message: e.to_s)
+ end
+
+ def move_error(path)
+ error = %Q{Repository "#{path}" could not be moved}
+
+ log_error(error)
+ error(error)
+ end
+end
diff --git a/app/services/repositories/destroy_rollback_service.rb b/app/services/repositories/destroy_rollback_service.rb
new file mode 100644
index 00000000000..5ef4e11bf55
--- /dev/null
+++ b/app/services/repositories/destroy_rollback_service.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class Repositories::DestroyRollbackService < Repositories::BaseService
+ def execute
+ # There is a possibility project does not have repository or wiki
+ return success unless repo_exists?(removal_path)
+
+ # Flush the cache for both repositories.
+ ignore_git_errors { repository.before_delete }
+
+ if mv_repository(removal_path, disk_path)
+ log_info(%Q{Repository "#{removal_path}" moved to "#{disk_path}" for repository "#{full_path}"})
+
+ success
+ else
+ move_error(removal_path)
+ end
+ end
+end
diff --git a/app/services/repositories/destroy_service.rb b/app/services/repositories/destroy_service.rb
new file mode 100644
index 00000000000..374968f610e
--- /dev/null
+++ b/app/services/repositories/destroy_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class Repositories::DestroyService < Repositories::BaseService
+ def execute
+ return success unless repository
+ return success unless repo_exists?(disk_path)
+
+ # Flush the cache for both repositories. This has to be done _before_
+ # removing the physical repositories as some expiration code depends on
+ # Git data (e.g. a list of branch names).
+ ignore_git_errors { repository.before_delete }
+
+ if mv_repository(disk_path, removal_path)
+ log_info(%Q{Repository "#{disk_path}" moved to "#{removal_path}" for repository "#{full_path}"})
+
+ current_repository = repository
+ project.run_after_commit do
+ Repositories::ShellDestroyService.new(current_repository).execute
+ end
+
+ log_info("Project \"#{project.full_path}\" was removed")
+
+ success
+ else
+ move_error(disk_path)
+ end
+ end
+end
diff --git a/app/services/repositories/shell_destroy_service.rb b/app/services/repositories/shell_destroy_service.rb
new file mode 100644
index 00000000000..2f5af10e24c
--- /dev/null
+++ b/app/services/repositories/shell_destroy_service.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class Repositories::ShellDestroyService < Repositories::BaseService
+ REPO_REMOVAL_DELAY = 5.minutes.to_i
+ STALE_REMOVAL_DELAY = REPO_REMOVAL_DELAY * 2
+
+ def execute(delay = REPO_REMOVAL_DELAY)
+ return success unless repository
+
+ GitlabShellWorker.perform_in(delay,
+ :remove_repository,
+ repository_storage,
+ removal_path)
+ end
+end
diff --git a/app/services/snippets/count_service.rb b/app/services/snippets/count_service.rb
new file mode 100644
index 00000000000..9a3d33c75cf
--- /dev/null
+++ b/app/services/snippets/count_service.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+# Service for calculating visible Snippet counts via one query
+# for the given user or project.
+#
+# Authorisation level checks will be included, ensuring the correct
+# counts will be returned for the given user (if any).
+#
+# Basic usage:
+#
+# user = User.find(1)
+#
+# Snippets::CountService.new(user, author: user).execute
+# #=> {
+# are_public: 1,
+# are_internal: 1,
+# are_private: 1,
+# all: 3
+# }
+#
+# Counts can be scoped to a project:
+#
+# user = User.find(1)
+# project = Project.find(1)
+#
+# Snippets::CountService.new(user, project: project).execute
+# #=> {
+# are_public: 1,
+# are_internal: 1,
+# are_private: 0,
+# all: 2
+# }
+#
+# Either a project or an author *must* be supplied.
+module Snippets
+ class CountService
+ def initialize(current_user, author: nil, project: nil)
+ if !author && !project
+ raise(
+ ArgumentError, 'Must provide either an author or a project'
+ )
+ end
+
+ @snippets_finder = SnippetsFinder.new(current_user, author: author, project: project)
+ end
+
+ def execute
+ counts = snippet_counts
+ return {} unless counts
+
+ counts.slice(
+ :are_public,
+ :are_private,
+ :are_internal,
+ :are_public_or_internal,
+ :total
+ )
+ end
+
+ private
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def snippet_counts
+ @snippets_finder.execute
+ .reorder(nil)
+ .select("
+ count(case when snippets.visibility_level=#{Snippet::PUBLIC} and snippets.secret is FALSE then 1 else null end) as are_public,
+ count(case when snippets.visibility_level=#{Snippet::INTERNAL} then 1 else null end) as are_internal,
+ count(case when snippets.visibility_level=#{Snippet::PRIVATE} then 1 else null end) as are_private,
+ count(case when visibility_level=#{Snippet::PUBLIC} OR visibility_level=#{Snippet::INTERNAL} then 1 else null end) as are_public_or_internal,
+ count(*) as total
+ ")
+ .first
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb
index 250e99c466a..7ded185a6f9 100644
--- a/app/services/snippets/create_service.rb
+++ b/app/services/snippets/create_service.rb
@@ -24,7 +24,9 @@ module Snippets
spam_check(snippet, current_user)
snippet_saved = snippet.with_transaction_returning_status do
- snippet.save && snippet.store_mentions!
+ (snippet.save && snippet.store_mentions!).tap do |saved|
+ create_repository_for(snippet, current_user) if saved
+ end
end
if snippet_saved
@@ -36,5 +38,11 @@ module Snippets
snippet_error_response(snippet, 400)
end
end
+
+ private
+
+ def create_repository_for(snippet, user)
+ snippet.create_repository if Feature.enabled?(:version_snippets, user)
+ end
end
end
diff --git a/app/services/snippets/destroy_service.rb b/app/services/snippets/destroy_service.rb
index f253817d94f..c1e87e74aa4 100644
--- a/app/services/snippets/destroy_service.rb
+++ b/app/services/snippets/destroy_service.rb
@@ -36,9 +36,7 @@ module Snippets
attr_reader :snippet
def user_can_delete_snippet?
- return can?(current_user, :admin_project_snippet, snippet) if project
-
- can?(current_user, :admin_personal_snippet, snippet)
+ can?(current_user, :admin_snippet, snippet)
end
def service_response_error(message, http_status)
diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb
index 8d2c8cac148..c0c0aec2050 100644
--- a/app/services/snippets/update_service.rb
+++ b/app/services/snippets/update_service.rb
@@ -21,7 +21,7 @@ module Snippets
spam_check(snippet, current_user)
snippet_saved = snippet.with_transaction_returning_status do
- snippet.save && snippet.store_mentions!
+ snippet.save
end
if snippet_saved
diff --git a/app/services/spam/akismet_service.rb b/app/services/spam/akismet_service.rb
new file mode 100644
index 00000000000..7d16743b3ed
--- /dev/null
+++ b/app/services/spam/akismet_service.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+module Spam
+ class AkismetService
+ attr_accessor :text, :options
+
+ def initialize(owner_name, owner_email, text, options = {})
+ @owner_name = owner_name
+ @owner_email = owner_email
+ @text = text
+ @options = options
+ end
+
+ def spam?
+ return false unless akismet_enabled?
+
+ params = {
+ type: 'comment',
+ text: text,
+ created_at: DateTime.now,
+ author: owner_name,
+ author_email: owner_email,
+ referrer: options[:referrer]
+ }
+
+ begin
+ is_spam, is_blatant = akismet_client.check(options[:ip_address], options[:user_agent], params)
+ is_spam || is_blatant
+ rescue => e
+ Rails.logger.error("Unable to connect to Akismet: #{e}, skipping check") # rubocop:disable Gitlab/RailsLogger
+ false
+ end
+ end
+
+ def submit_ham
+ submit(:ham)
+ end
+
+ def submit_spam
+ submit(:spam)
+ end
+
+ private
+
+ attr_accessor :owner_name, :owner_email
+
+ def akismet_client
+ @akismet_client ||= ::Akismet::Client.new(Gitlab::CurrentSettings.akismet_api_key,
+ Gitlab.config.gitlab.url)
+ end
+
+ def akismet_enabled?
+ Gitlab::CurrentSettings.akismet_enabled
+ end
+
+ def submit(type)
+ return false unless akismet_enabled?
+
+ params = {
+ type: 'comment',
+ text: text,
+ author: owner_name,
+ author_email: owner_email
+ }
+
+ begin
+ akismet_client.public_send(type, options[:ip_address], options[:user_agent], params) # rubocop:disable GitlabSecurity/PublicSend
+ true
+ rescue => e
+ Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!") # rubocop:disable Gitlab/RailsLogger
+ false
+ end
+ end
+ end
+end
diff --git a/app/services/spam/ham_service.rb b/app/services/spam/ham_service.rb
new file mode 100644
index 00000000000..d0f53eea90c
--- /dev/null
+++ b/app/services/spam/ham_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Spam
+ class HamService
+ include AkismetMethods
+
+ attr_accessor :spam_log, :options
+
+ def initialize(spam_log)
+ @spam_log = spam_log
+ @user = spam_log.user
+ @options = {
+ ip_address: spam_log.source_ip,
+ user_agent: spam_log.user_agent
+ }
+ end
+
+ def execute
+ if akismet.submit_ham
+ spam_log.update_attribute(:submitted_as_ham, true)
+ else
+ false
+ end
+ end
+
+ alias_method :spammable, :spam_log
+ end
+end
diff --git a/app/services/spam/spam_check_service.rb b/app/services/spam/spam_check_service.rb
new file mode 100644
index 00000000000..d19ef03976f
--- /dev/null
+++ b/app/services/spam/spam_check_service.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+module Spam
+ class SpamCheckService
+ include AkismetMethods
+
+ attr_accessor :spammable, :request, :options
+ attr_reader :spam_log
+
+ def initialize(spammable:, request:)
+ @spammable = spammable
+ @request = request
+ @options = {}
+
+ if @request
+ @options[:ip_address] = @request.env['action_dispatch.remote_ip'].to_s
+ @options[:user_agent] = @request.env['HTTP_USER_AGENT']
+ @options[:referrer] = @request.env['HTTP_REFERRER']
+ else
+ @options[:ip_address] = @spammable.ip_address
+ @options[:user_agent] = @spammable.user_agent
+ end
+ end
+
+ def execute(api: false, recaptcha_verified:, spam_log_id:, user_id:)
+ if recaptcha_verified
+ # If it's a request which is already verified through recaptcha,
+ # update the spam log accordingly.
+ SpamLog.verify_recaptcha!(user_id: user_id, id: spam_log_id)
+ else
+ # Otherwise, it goes to Akismet for spam check.
+ # If so, it assigns spammable object as "spam" and creates a SpamLog record.
+ possible_spam = check(api)
+ spammable.spam = possible_spam unless spammable.allow_possible_spam?
+ spammable.spam_log = spam_log
+ end
+ end
+
+ private
+
+ def check(api)
+ return unless request
+ return unless check_for_spam?
+ return unless akismet.spam?
+
+ create_spam_log(api)
+ true
+ end
+
+ def check_for_spam?
+ spammable.check_for_spam?
+ end
+
+ def create_spam_log(api)
+ @spam_log = SpamLog.create!(
+ {
+ user_id: spammable.author_id,
+ title: spammable.spam_title,
+ description: spammable.spam_description,
+ source_ip: options[:ip_address],
+ user_agent: options[:user_agent],
+ noteable_type: spammable.class.to_s,
+ via_api: api
+ }
+ )
+ end
+ end
+end
diff --git a/app/services/spam_service.rb b/app/services/spam_service.rb
deleted file mode 100644
index ba9b812a01c..00000000000
--- a/app/services/spam_service.rb
+++ /dev/null
@@ -1,66 +0,0 @@
-# frozen_string_literal: true
-
-class SpamService
- include AkismetMethods
-
- attr_accessor :spammable, :request, :options
- attr_reader :spam_log
-
- def initialize(spammable:, request:)
- @spammable = spammable
- @request = request
- @options = {}
-
- if @request
- @options[:ip_address] = @request.env['action_dispatch.remote_ip'].to_s
- @options[:user_agent] = @request.env['HTTP_USER_AGENT']
- @options[:referrer] = @request.env['HTTP_REFERRER']
- else
- @options[:ip_address] = @spammable.ip_address
- @options[:user_agent] = @spammable.user_agent
- end
- end
-
- def when_recaptcha_verified(recaptcha_verified, api = false)
- # In case it's a request which is already verified through recaptcha, yield
- # block.
- if recaptcha_verified
- yield
- 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.
- possible_spam = check(api)
- spammable.spam = possible_spam unless spammable.allow_possible_spam?
- spammable.spam_log = spam_log
- end
- end
-
- private
-
- def check(api)
- return false unless request && check_for_spam?
-
- return false unless akismet.spam?
-
- create_spam_log(api)
- true
- end
-
- def check_for_spam?
- spammable.check_for_spam?
- end
-
- def create_spam_log(api)
- @spam_log = SpamLog.create!(
- {
- user_id: spammable_owner_id,
- title: spammable.spam_title,
- description: spammable.spam_description,
- source_ip: options[:ip_address],
- user_agent: options[:user_agent],
- noteable_type: spammable.class.to_s,
- via_api: api
- }
- )
- end
-end
diff --git a/app/services/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb
index 7927ab265c5..3265eb106eb 100644
--- a/app/services/submit_usage_ping_service.rb
+++ b/app/services/submit_usage_ping_service.rb
@@ -36,10 +36,12 @@ class SubmitUsagePingService
private
def store_metrics(response)
- return unless response['conv_index'].present?
+ metrics = response['conv_index'] || response['dev_ops_score']
+
+ return unless metrics.present?
DevOpsScore::Metric.create!(
- response['conv_index'].slice(*METRICS)
+ metrics.slice(*METRICS)
)
end
end
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 38e0a7d34ad..8a0f44b4e93 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -99,6 +99,10 @@ module SystemNoteService
::SystemNotes::TimeTrackingService.new(noteable: noteable, project: project, author: author).change_time_spent
end
+ def close_after_error_tracking_resolve(issue, project, author)
+ ::SystemNotes::IssuablesService.new(noteable: issue, project: project, author: author).close_after_error_tracking_resolve
+ end
+
def change_status(noteable, project, author, status, source = nil)
::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).change_status(status, source)
end
@@ -237,23 +241,6 @@ module SystemNoteService
def zoom_link_removed(issue, project, author)
::SystemNotes::ZoomService.new(noteable: issue, project: project, author: author).zoom_link_removed
end
-
- private
-
- 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 url_helpers
- @url_helpers ||= Gitlab::Routing.url_helpers
- end
-
- def content_tag(*args)
- ActionController::Base.helpers.content_tag(*args)
- end
end
SystemNoteService.prepend_if_ee('EE::SystemNoteService')
diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb
index 6fffd2ed4bf..d7787dac4b8 100644
--- a/app/services/system_notes/issuables_service.rb
+++ b/app/services/system_notes/issuables_service.rb
@@ -282,6 +282,12 @@ module SystemNotes
create_note(NoteSummary.new(noteable, project, author, body, action: action))
end
+ def close_after_error_tracking_resolve
+ body = _('resolved the corresponding error and closed the issue.')
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'closed'))
+ end
+
private
def cross_reference_note_content(gfm_reference)
diff --git a/app/services/system_notes/merge_requests_service.rb b/app/services/system_notes/merge_requests_service.rb
index 1d17f0ded57..a26fc0f7d35 100644
--- a/app/services/system_notes/merge_requests_service.rb
+++ b/app/services/system_notes/merge_requests_service.rb
@@ -139,6 +139,17 @@ module SystemNotes
create_note(NoteSummary.new(noteable, project, author, body, action: 'merge'))
end
+
+ def picked_into_branch(branch_name, pick_commit)
+ link = url_helpers.project_tree_path(project, branch_name)
+
+ body = "picked this merge request into branch [`#{branch_name}`](#{link}) with commit #{pick_commit}"
+
+ summary = NoteSummary.new(noteable, project, author, body, action: 'cherry_pick')
+ summary.note[:commit_id] = pick_commit
+
+ create_note(summary)
+ end
end
end
diff --git a/app/services/user_project_access_changed_service.rb b/app/services/user_project_access_changed_service.rb
index 21b52944800..21d0861ac3f 100644
--- a/app/services/user_project_access_changed_service.rb
+++ b/app/services/user_project_access_changed_service.rb
@@ -11,7 +11,7 @@ class UserProjectAccessChangedService
if blocking
AuthorizedProjectsWorker.bulk_perform_and_wait(bulk_args)
else
- AuthorizedProjectsWorker.bulk_perform_async(bulk_args)
+ AuthorizedProjectsWorker.bulk_perform_async(bulk_args) # rubocop:disable Scalability/BulkPerformWithContext
end
end
end
diff --git a/app/services/users/block_service.rb b/app/services/users/block_service.rb
new file mode 100644
index 00000000000..9c393832d8f
--- /dev/null
+++ b/app/services/users/block_service.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Users
+ class BlockService < BaseService
+ def initialize(current_user)
+ @current_user = current_user
+ end
+
+ def execute(user)
+ if user.block
+ after_block_hook(user)
+ success
+ else
+ messages = user.errors.full_messages
+ error(messages.uniq.join('. '))
+ end
+ end
+
+ private
+
+ def after_block_hook(user)
+ # overriden by EE module
+ end
+ end
+end
+
+Users::BlockService.prepend_if_ee('EE::Users::BlockService')
diff --git a/app/services/users/create_service.rb b/app/services/users/create_service.rb
index 2ac6dfd90fa..ec8b3cea664 100644
--- a/app/services/users/create_service.rb
+++ b/app/services/users/create_service.rb
@@ -11,12 +11,19 @@ module Users
def execute(skip_authorization: false)
user = Users::BuildService.new(current_user, params).execute(skip_authorization: skip_authorization)
+ reset_token = user.generate_reset_token if user.recently_sent_password_reset?
- @reset_token = user.generate_reset_token if user.recently_sent_password_reset?
-
- notify_new_user(user, @reset_token) if user.save
+ after_create_hook(user, reset_token) if user.save
user
end
+
+ private
+
+ def after_create_hook(user, reset_token)
+ notify_new_user(user, reset_token)
+ end
end
end
+
+Users::CreateService.prepend_if_ee('EE::Users::CreateService')
diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb
index 643ebdc6839..ef79ee3d06e 100644
--- a/app/services/users/destroy_service.rb
+++ b/app/services/users/destroy_service.rb
@@ -56,12 +56,10 @@ module Users
MigrateToGhostUserService.new(user).execute unless options[:hard_delete]
- if Feature.enabled?(:destroy_user_associations_in_batches)
- # Rails attempts to load all related records into memory before
- # destroying: https://github.com/rails/rails/issues/22510
- # This ensures we delete records in batches.
- user.destroy_dependent_associations_in_batches
- end
+ # Rails attempts to load all related records into memory before
+ # destroying: https://github.com/rails/rails/issues/22510
+ # This ensures we delete records in batches.
+ user.destroy_dependent_associations_in_batches
# Destroy the namespace after destroying the user since certain methods may depend on the namespace existing
user_data = user.destroy
diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb
index e7667b0ca18..57209043e3b 100644
--- a/app/services/users/update_service.rb
+++ b/app/services/users/update_service.rb
@@ -53,7 +53,11 @@ module Users
end
def discard_read_only_attributes
- discard_synced_attributes
+ if Feature.enabled?(:ldap_readonly_attributes, default_enabled: true)
+ params.reject! { |key, _| @user.read_only_attribute?(key.to_sym) }
+ else
+ discard_synced_attributes
+ end
end
def discard_synced_attributes
diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb
index 87edac36e33..514ba998d2c 100644
--- a/app/services/web_hook_service.rb
+++ b/app/services/web_hook_service.rb
@@ -2,12 +2,14 @@
class WebHookService
class InternalErrorResponse
+ ERROR_MESSAGE = 'internal error'
+
attr_reader :body, :headers, :code
def initialize
@headers = Gitlab::HTTP::Response::Headers.new({})
@body = ''
- @code = 'internal error'
+ @code = ERROR_MESSAGE
end
end
diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb
index b79a5deb9c0..e4046e4b7e6 100644
--- a/app/uploaders/avatar_uploader.rb
+++ b/app/uploaders/avatar_uploader.rb
@@ -25,6 +25,10 @@ class AvatarUploader < GitlabUploader
self.class.absolute_path(upload)
end
+ def mounted_as
+ super || 'avatar'
+ end
+
private
def dynamic_segment
diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb
index b326b266017..0fc71d2e3f3 100644
--- a/app/uploaders/file_uploader.rb
+++ b/app/uploaders/file_uploader.rb
@@ -36,7 +36,7 @@ class FileUploader < GitlabUploader
def self.base_dir(model, store = Store::LOCAL)
decorated_model = model
- decorated_model = Storage::HashedProject.new(model) if store == Store::REMOTE
+ decorated_model = Storage::Hashed.new(model) if store == Store::REMOTE
model_path_segment(decorated_model)
end
@@ -57,7 +57,7 @@ class FileUploader < GitlabUploader
# Returns a String without a trailing slash
def self.model_path_segment(model)
case model
- when Storage::HashedProject then model.disk_path
+ when Storage::Hashed then model.disk_path
else
model.hashed_storage?(:attachments) ? model.disk_path : model.full_path
end
diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb
index 36bde629f9c..450ebb00b49 100644
--- a/app/uploaders/object_storage.rb
+++ b/app/uploaders/object_storage.rb
@@ -125,7 +125,7 @@ module ObjectStorage
included do
include AfterCommitQueue
- after_save on: [:create, :update] do
+ after_save do
background_upload(changed_mounts)
end
end
diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml
index 80a53dba2aa..aa377886edc 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -8,21 +8,21 @@
= f.label :gravatar_enabled, class: 'form-check-label' do
= _('Gravatar enabled')
.form-group
- = f.label :default_projects_limit, class: 'label-bold'
- = f.number_field :default_projects_limit, class: 'form-control'
+ = f.label :default_projects_limit, _('Default projects limit'), class: 'label-bold'
+ = f.number_field :default_projects_limit, class: 'form-control', title: _('Maximum number of projects.'), data: { toggle: 'tooltip', container: 'body' }
.form-group
= f.label :max_attachment_size, _('Maximum attachment size (MB)'), class: 'label-bold'
- = f.number_field :max_attachment_size, class: 'form-control'
+ = f.number_field :max_attachment_size, class: 'form-control', title: _('Maximum size of individual attachments in comments.'), data: { toggle: 'tooltip', container: 'body' }
= render_if_exists 'admin/application_settings/repository_size_limit_setting', form: f
.form-group
= f.label :receive_max_input_size, _('Maximum push size (MB)'), class: 'label-light'
- = f.number_field :receive_max_input_size, class: 'form-control qa-receive-max-input-size-field'
+ = f.number_field :receive_max_input_size, class: 'form-control qa-receive-max-input-size-field', title: _('Maximum size limit for a single commit.'), data: { toggle: 'tooltip', container: 'body' }
.form-group
= f.label :session_expire_delay, _('Session duration (minutes)'), class: 'label-light'
- = f.number_field :session_expire_delay, class: 'form-control'
- %span.form-text.text-muted#session_expire_delay_help_block= _('GitLab restart is required to apply changes')
+ = f.number_field :session_expire_delay, class: 'form-control', title: _('Maximum duration of a session.'), data: { toggle: 'tooltip', container: 'body' }
+ %span.form-text.text-muted#session_expire_delay_help_block= _('GitLab restart is required to apply changes.')
= render_if_exists 'admin/application_settings/personal_access_token_expiration_policy', form: f
diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml
index d716b52be05..9421585b70c 100644
--- a/app/views/admin/application_settings/_usage.html.haml
+++ b/app/views/admin/application_settings/_usage.html.haml
@@ -9,7 +9,7 @@
Enable version check
.form-text.text-muted
GitLab will inform you if a new version is available.
- = link_to 'Learn more', help_page_path("user/admin_area/settings/usage_statistics", anchor: "version-check")
+ = link_to 'Learn more', help_page_path('user/admin_area/settings/usage_statistics', anchor: 'version-check-core-only')
about what information is shared with GitLab Inc.
.form-group
- can_be_configured = @application_setting.usage_ping_can_be_configured?
@@ -21,12 +21,12 @@
- if can_be_configured
%p.mb-2= _('To help improve GitLab and its user experience, GitLab will periodically collect usage information.')
- - usage_ping_path = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'usage-ping')
+ - usage_ping_path = help_page_path('user/admin_area/settings/usage_statistics', anchor: 'usage-ping-core-only')
- usage_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: usage_ping_path }
%p.mb-2= s_('%{usage_ping_link_start}Learn more%{usage_ping_link_end} about what information is shared with GitLab Inc.').html_safe % { usage_ping_link_start: usage_ping_link_start, usage_ping_link_end: '</a>'.html_safe }
%button.btn.js-usage-ping-payload-trigger{ type: 'button' }
- .js-spinner.d-none= icon('spinner spin')
+ .spinner.js-spinner.d-none
.js-text.d-inline= _('Preview payload')
%pre.usage-data.js-usage-ping-payload.js-syntax-highlight.code.highlight.mt-2.d-none{ data: { endpoint: usage_data_admin_application_settings_path(format: :html) } }
- else
diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml
index b9f49fdc9de..94048060767 100644
--- a/app/views/admin/application_settings/general.html.haml
+++ b/app/views/admin/application_settings/general.html.haml
@@ -20,7 +20,7 @@
%button.btn.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = _('Session expiration, projects limit and attachment size.')
+ = _('Set projects and maximum size limits, session duration, user options, and check feature availability for namespace plan.')
.settings-content
= render 'account_and_limit'
diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml
index ff40d7da892..0b747082de0 100644
--- a/app/views/admin/application_settings/metrics_and_profiling.html.haml
+++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml
@@ -47,8 +47,7 @@
.settings-content
= render 'performance_bar'
-- if Feature.enabled?(:self_monitoring_project)
- .js-self-monitoring-settings{ data: self_monitoring_project_data }
+.js-self-monitoring-settings{ data: self_monitoring_project_data }
%section.settings.as-usage.no-animate#js-usage-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header#usage-statistics
diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml
index 21e84016c66..8338401bea5 100644
--- a/app/views/admin/applications/_form.html.haml
+++ b/app/views/admin/applications/_form.html.haml
@@ -30,6 +30,14 @@
%span.form-text.text-muted
Trusted applications are automatically authorized on GitLab OAuth flow.
+ = content_tag :div, class: 'form-group row' do
+ .col-sm-2.col-form-label.pt-0
+ = f.label :confidential
+ .col-sm-10
+ = f.check_box :confidential
+ %span.form-text.text-muted
+ = _('The application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential.')
+
.form-group.row
.col-sm-2.col-form-label.pt-0
= f.label :scopes
diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml
index 758d722cc63..c3861f335b8 100644
--- a/app/views/admin/applications/index.html.haml
+++ b/app/views/admin/applications/index.html.haml
@@ -12,6 +12,7 @@
%th Callback URL
%th Clients
%th Trusted
+ %th Confidential
%th
%th
%tbody.oauth-applications
@@ -21,6 +22,7 @@
%td= application.redirect_uri
%td= @application_counts[application.id].to_i
%td= application.trusted? ? 'Y': 'N'
+ %td= application.confidential? ? 'Y': 'N'
%td= link_to 'Edit', edit_admin_application_path(application), class: 'btn btn-link'
%td= render 'delete_form', application: application
= paginate @applications, theme: 'gitlab'
diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml
index aca9302aff7..146674a2fac 100644
--- a/app/views/admin/applications/show.html.haml
+++ b/app/views/admin/applications/show.html.haml
@@ -36,6 +36,12 @@
%td
= @application.trusted? ? 'Y' : 'N'
+ %tr
+ %td
+ Confidential
+ %td
+ = @application.confidential? ? 'Y' : 'N'
+
= render "shared/tokens/scopes_list", token: @application
.form-actions
diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml
index 33b56655206..9577a2a79df 100644
--- a/app/views/admin/broadcast_messages/_form.html.haml
+++ b/app/views/admin/broadcast_messages/_form.html.haml
@@ -5,15 +5,14 @@
= render_broadcast_message(@broadcast_message)
- else
Your message here
-- if Feature.enabled?(:broadcast_notification_type)
- .d-flex.justify-content-center
- .broadcast-notification-message.preview.js-broadcast-notification-message-preview.mt-2{ class: ('hidden' unless @broadcast_message.notification? ) }
- = sprite_icon('bullhorn', size: 16, css_class:'vertical-align-text-top')
- .js-broadcast-message-preview
- - if @broadcast_message.message.present?
- = render_broadcast_message(@broadcast_message)
- - else
- Your message here
+.d-flex.justify-content-center
+ .broadcast-notification-message.preview.js-broadcast-notification-message-preview.mt-2{ class: ('hidden' unless @broadcast_message.notification? ) }
+ = sprite_icon('bullhorn', size: 16, css_class:'vertical-align-text-top')
+ .js-broadcast-message-preview
+ - if @broadcast_message.message.present?
+ = render_broadcast_message(@broadcast_message)
+ - else
+ Your message here
= form_for [:admin, @broadcast_message], html: { class: 'broadcast-message-form js-quick-submit js-requires-input'} do |f|
= form_errors(@broadcast_message)
@@ -26,12 +25,11 @@
required: true,
dir: 'auto',
data: { preview_path: preview_admin_broadcast_messages_path }
- - if Feature.enabled?(:broadcast_notification_type)
- .form-group.row
- .col-sm-2.col-form-label
- = f.label :broadcast_type, _('Type')
- .col-sm-10
- = f.select :broadcast_type, broadcast_type_options, {}, class: 'form-control js-broadcast-message-type'
+ .form-group.row
+ .col-sm-2.col-form-label
+ = f.label :broadcast_type, _('Type')
+ .col-sm-10
+ = f.select :broadcast_type, broadcast_type_options, {}, class: 'form-control js-broadcast-message-type'
.form-group.row.js-broadcast-message-background-color-form-group{ class: ('hidden' unless @broadcast_message.banner? ) }
.col-sm-2.col-form-label
= f.label :color, _("Background color")
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 6b138445675..0ec81d0eb04 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -42,7 +42,7 @@
.well-segment.admin-well.admin-well-features
%h4 Features
= feature_entry(_('Sign up'),
- href: admin_application_settings_path(anchor: 'js-signup-settings'),
+ href: general_admin_application_settings_path(anchor: 'js-signup-settings'),
enabled: allow_signup?)
= feature_entry(_('LDAP'),
@@ -50,11 +50,11 @@
doc_href: help_page_path('administration/auth/ldap'))
= feature_entry(_('Gravatar'),
- href: admin_application_settings_path(anchor: 'js-account-settings'),
+ href: general_admin_application_settings_path(anchor: 'js-account-settings'),
enabled: gravatar_enabled?)
= feature_entry(_('OmniAuth'),
- href: admin_application_settings_path(anchor: 'js-signin-settings'),
+ href: general_admin_application_settings_path(anchor: 'js-signin-settings'),
enabled: Gitlab::Auth.omniauth_enabled?,
doc_href: help_page_path('integration/omniauth'))
@@ -85,7 +85,7 @@
.float-right
= version_status_badge
%p
- %a{ href: admin_application_settings_path }
+ %a{ href: general_admin_application_settings_path }
GitLab
%span.float-right
= Gitlab::VERSION
diff --git a/app/views/admin/groups/_group.html.haml b/app/views/admin/groups/_group.html.haml
index 3444e423235..855858ff929 100644
--- a/app/views/admin/groups/_group.html.haml
+++ b/app/views/admin/groups/_group.html.haml
@@ -10,6 +10,7 @@
= storage_counter(group.storage_size)
= render_if_exists 'admin/namespace_plan_badge', namespace: group
+ = render_if_exists 'admin/groups/marked_for_deletion_badge', group: group
%span
= icon('bookmark')
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index 818d265c767..59e28a3b244 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -47,7 +47,7 @@
.filtered-search-box
= dropdown_tag(_('Recent searches'),
options: { wrapper_class: 'filtered-search-history-dropdown-wrapper',
- toggle_class: 'filtered-search-history-dropdown-toggle-button',
+ toggle_class: 'btn filtered-search-history-dropdown-toggle-button',
dropdown_class: 'filtered-search-history-dropdown',
content_class: 'filtered-search-history-dropdown-content' }) do
.js-filtered-search-history-dropdown{ data: { full_path: admin_runners_path } }
diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml
index 62be38e9dd2..f860b7a61a2 100644
--- a/app/views/admin/runners/show.html.haml
+++ b/app/views/admin/runners/show.html.haml
@@ -48,7 +48,7 @@
= project.full_name
%td
.float-right
- = link_to 'Disable', [:admin, project.namespace.becomes(Namespace), project, runner_project], method: :delete, class: 'btn btn-danger btn-sm'
+ = link_to 'Disable', admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'btn btn-danger btn-sm'
%table.table.unassigned-projects
%thead
@@ -70,10 +70,10 @@
= project.full_name
%td
.float-right
- = form_for [:admin, project.namespace.becomes(Namespace), project, project.runner_projects.new] do |f|
+ = form_for project.runner_projects.new, url: admin_namespace_project_runner_projects_path(project.namespace, project), method: :post do |f|
= f.hidden_field :runner_id, value: @runner.id
= f.submit 'Enable', class: 'btn btn-sm'
- = paginate @projects, theme: "gitlab"
+ = paginate_without_count @projects
.col-md-6
%h4 Recent jobs served by this Runner
diff --git a/app/views/admin/serverless/domains/_form.html.haml b/app/views/admin/serverless/domains/_form.html.haml
new file mode 100644
index 00000000000..8c1c1d41caa
--- /dev/null
+++ b/app/views/admin/serverless/domains/_form.html.haml
@@ -0,0 +1,68 @@
+- form_name = 'js-serverless-domain-settings'
+- form_url = @domain.persisted? ? admin_serverless_domain_path(@domain.id, anchor: form_name) : admin_serverless_domains_path(anchor: form_name)
+- show_certificate_card = @domain.persisted? && @domain.errors.blank?
+= form_for @domain, url: form_url, html: { class: 'fieldset-form' } do |f|
+ = form_errors(@domain)
+
+ %fieldset
+ - if @domain.persisted?
+ - dns_record = "*.#{@domain.domain} CNAME #{Settings.pages.host}."
+ - verification_record = "#{@domain.verification_domain} TXT #{@domain.keyed_verification_code}"
+ .form-group.row
+ .col-sm-6.position-relative
+ = f.label :domain, _('Domain'), class: 'label-bold'
+ = f.text_field :domain, class: 'form-control has-floating-status-badge', readonly: true
+ .status-badge.floating-status-badge
+ - text, status = @domain.unverified? ? [_('Unverified'), 'badge-danger'] : [_('Verified'), 'badge-success']
+ .badge{ class: status }
+ = text
+ = link_to sprite_icon("redo"), verify_admin_serverless_domain_path(@domain.id), method: :post, class: "btn has-tooltip", title: _("Retry verification")
+
+ .col-sm-6
+ = f.label :serverless_domain_dns, _('DNS'), class: 'label-bold'
+ .input-group
+ = text_field_tag :serverless_domain_dns, dns_record , class: "monospace js-select-on-focus form-control", readonly: true
+ .input-group-append
+ = clipboard_button(target: '#serverless_domain_dns', class: 'btn-default input-group-text d-none d-sm-block')
+
+ .col-sm-12.form-text.text-muted
+ = _("To access this domain create a new DNS record")
+
+ .form-group
+ = f.label :serverless_domain_verification, _('Verification status'), class: 'label-bold'
+ .input-group
+ = text_field_tag :serverless_domain_verification, verification_record, class: "monospace js-select-on-focus form-control", readonly: true
+ .input-group-append
+ = clipboard_button(target: '#serverless_domain_verification', class: 'btn-default d-none d-sm-block')
+ %p.form-text.text-muted
+ - link_to_help = link_to(_('verify ownership'), help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: '4-verify-the-domains-ownership'))
+ = _("To %{link_to_help} of your domain, add the above key to a TXT record within to your DNS configuration.").html_safe % { link_to_help: link_to_help }
+
+ - else
+ .form-group
+ = f.label :domain, _('Domain'), class: 'label-bold'
+ = f.text_field :domain, class: 'form-control'
+
+ - if show_certificate_card
+ .card.js-domain-cert-show
+ .card-header
+ = _('Certificate')
+ .d-flex.justify-content-between.align-items-center.p-3
+ %span
+ = @domain.subject || _('missing')
+ %button.btn.btn-remove.btn-sm.js-domain-cert-replace-btn{ type: 'button' }
+ = _('Replace')
+
+ .js-domain-cert-inputs{ class: ('hidden' if show_certificate_card) }
+ .form-group
+ = f.label :user_provided_certificate, _('Certificate (PEM)'), class: 'label-bold'
+ = f.text_area :user_provided_certificate, rows: 5, class: 'form-control', value: ''
+ %span.form-text.text-muted
+ = _("Upload a certificate for your domain with all intermediates")
+ .form-group
+ = f.label :user_provided_key, _('Key (PEM)'), class: 'label-bold'
+ = f.text_area :user_provided_key, rows: 5, class: 'form-control', value: ''
+ %span.form-text.text-muted
+ = _("Upload a private key for your certificate")
+
+ = f.submit @domain.persisted? ? _('Save changes') : _('Add domain'), class: "btn btn-success js-serverless-domain-submit", disabled: @domain.persisted?
diff --git a/app/views/admin/serverless/domains/index.html.haml b/app/views/admin/serverless/domains/index.html.haml
new file mode 100644
index 00000000000..bd3c6bc6e04
--- /dev/null
+++ b/app/views/admin/serverless/domains/index.html.haml
@@ -0,0 +1,25 @@
+- breadcrumb_title _("Operations")
+- page_title _("Operations")
+- @content_class = "limit-container-width" unless fluid_layout
+
+-# normally expanded_by_default? is used here, but since this is the only panel
+-# in this settings page, let's leave it always open by default
+- expanded = true
+
+%section.settings.as-serverless-domain.no-animate#js-serverless-domain-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Serverless domain')
+ %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded ? _('Collapse') : _('Expand')
+ %p
+ = _('Set an instance-wide domain that will be available to all clusters when installing Knative.')
+ .settings-content
+ - if Gitlab.config.pages.enabled
+ = render 'form'
+ - else
+ .card
+ .card-header
+ = s_('GitLabPages|Domains')
+ .nothing-here-block
+ = s_("GitLabPages|Support for domains and certificates is disabled. Ask your system's administrator to enable it.")
diff --git a/app/views/clusters/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml
index 77f7c478ffa..d823cd0412b 100644
--- a/app/views/clusters/clusters/_advanced_settings.html.haml
+++ b/app/views/clusters/clusters/_advanced_settings.html.haml
@@ -6,27 +6,25 @@
- if can?(current_user, :admin_cluster, @cluster)
- unless @cluster.provided_by_user?
- .append-bottom-20
- %label.append-bottom-10
+ .sub-section.form-group
+ %h4
= @cluster.provider_label
%p
- provider_link = link_to(@cluster.provider_label, @cluster.provider_management_url, target: '_blank', rel: 'noopener noreferrer')
= s_('ClusterIntegration|Manage your Kubernetes cluster by visiting %{provider_link}').html_safe % { provider_link: provider_link }
- = form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'cluster_management_form' } do |field|
-
- %h5
- = s_('ClusterIntegration|Cluster management project (alpha)')
+ .sub-section.form-group
+ = form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'cluster_management_form' } do |field|
+ %h4
+ = s_('ClusterIntegration|Cluster management project (alpha)')
- .form-group
- .form-text.text-muted
- = project_select_tag('cluster[management_project_id]', class: 'hidden-filter-value', toggle_class: 'js-project-search js-project-filter js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit',
- placeholder: _('Select project'), idAttribute: 'id', data: { order_by: 'last_activity_at', idattribute: 'id', simple_filter: true, allow_clear: true, include_groups: false, include_projects_in_subgroups: true, group_id: group_id, user_id: user_id }, value: @cluster.management_project_id)
- .text-muted
- = s_('ClusterIntegration|A cluster management project can be used to run deployment jobs with Kubernetes <code>cluster-admin</code> privileges.').html_safe
- = link_to _('More information'), help_page_path('user/clusters/management_project.md'), target: '_blank'
- .form-group
- = field.submit _('Save changes'), class: 'btn btn-success qa-save-domain'
+ %p
+ = project_select_tag('cluster[management_project_id]', class: 'hidden-filter-value', toggle_class: 'js-project-search js-project-filter js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit',
+ placeholder: _('Select project'), idAttribute: 'id', data: { order_by: 'last_activity_at', idattribute: 'id', simple_filter: true, allow_clear: true, include_groups: false, include_projects_in_subgroups: true, group_id: group_id, user_id: user_id }, value: @cluster.management_project_id)
+ .text-muted
+ = s_('ClusterIntegration|A cluster management project can be used to run deployment jobs with Kubernetes <code>cluster-admin</code> privileges.').html_safe
+ = link_to _('More information'), help_page_path('user/clusters/management_project.md'), target: '_blank'
+ = field.submit _('Save changes'), class: 'btn btn-success'
- if @cluster.managed?
.sub-section.form-group
diff --git a/app/views/clusters/clusters/_cluster.html.haml b/app/views/clusters/clusters/_cluster.html.haml
index 04afc38a056..9b6c0c20080 100644
--- a/app/views/clusters/clusters/_cluster.html.haml
+++ b/app/views/clusters/clusters/_cluster.html.haml
@@ -4,6 +4,8 @@
.table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Kubernetes cluster")
.table-mobile-content
= cluster.item_link(clusterable, html_options: { data: { qa_selector: 'cluster', qa_cluster_name: cluster.name } })
+ - if cluster.status_name == :creating
+ .spinner.ml-2.align-middle.has-tooltip{ title: s_("ClusterIntegration|Cluster being created") }
- unless cluster.enabled?
%span.badge.badge-danger Connection disabled
.table-section.section-25
diff --git a/app/views/clusters/clusters/_form.html.haml b/app/views/clusters/clusters/_form.html.haml
index f9085b781fb..a85b005b2b4 100644
--- a/app/views/clusters/clusters/_form.html.haml
+++ b/app/views/clusters/clusters/_form.html.haml
@@ -31,7 +31,7 @@
= s_('ClusterIntegration|Alternatively')
%code{ :class => "js-ingress-domain-snippet" } #{@cluster.application_ingress_external_ip}.nip.io
= s_('ClusterIntegration| can be used instead of a custom domain.')
- - custom_domain_url = help_page_path('user/project/clusters/index', anchor: 'pointing-your-dns-at-the-external-endpoint')
+ - custom_domain_url = help_page_path('user/clusters/applications.md', anchor: 'pointing-your-dns-at-the-external-endpoint')
- custom_domain_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: custom_domain_url }
= s_('ClusterIntegration| %{custom_domain_start}More information%{custom_domain_end}.').html_safe % { custom_domain_start: custom_domain_start, custom_domain_end: '</a>'.html_safe }
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 56d46580b9e..c10983a5405 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
@@ -1,10 +1,12 @@
- provider = local_assigns.fetch(:provider)
+- is_current_provider = provider == params[:provider]
- logo_path = local_assigns.fetch(:logo_path)
- label = local_assigns.fetch(:label)
- last = local_assigns.fetch(:last, false)
-- classes = ['btn btn-light btn-outline flex-fill d-inline-flex flex-column justify-content-center align-items-center', ('mr-3' unless last)]
+- classes = ["btn btn-light btn-outline flex-fill d-inline-flex flex-column justify-content-center align-items-center w-50 js-create-#{provider}-cluster-button"]
+- conditional_classes = [('mr-3' unless last), ('active' if is_current_provider)]
-= link_to clusterable.new_path(provider: provider), class: classes do
+= link_to clusterable.new_path(provider: provider), class: classes + conditional_classes do
.svg-content.p-2= image_tag logo_path, alt: label, class: 'gl-w-64 gl-h-64'
%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 91925f5f96f..aee355bbf71 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
@@ -1,8 +1,8 @@
- gke_label = s_('ClusterIntegration|Google GKE')
- eks_label = s_('ClusterIntegration|Amazon EKS')
- create_cluster_label = s_('ClusterIntegration|Create cluster on')
-.d-flex.flex-column
- %h5.mb-3
+.d-flex.flex-column.p-3
+ %h4.mb-3
= create_cluster_label
.d-flex
= render partial: 'clusters/clusters/cloud_providers/cloud_provider_button',
diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml
index ab01569b8fd..e83bf61ab9b 100644
--- a/app/views/clusters/clusters/gcp/_form.html.haml
+++ b/app/views/clusters/clusters/gcp/_form.html.haml
@@ -66,11 +66,11 @@
- if Feature.enabled?(:create_cloud_run_clusters, clusterable, default_enabled: true)
.form-group
- = provider_gcp_field.check_box :cloud_run, { label: s_('ClusterIntegration|Enable Cloud Run on GKE (beta)'),
+ = provider_gcp_field.check_box :cloud_run, { label: s_('ClusterIntegration|Enable Cloud Run for Anthos'),
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'
+ = link_to _('More information'), help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'cloud-run-for-anthos'), target: '_blank'
.form-group
= field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'),
@@ -79,6 +79,6 @@
= s_('ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster.')
= link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank'
- .form-group
+ .form-group.js-gke-cluster-creation-submit-container
= field.submit s_('ClusterIntegration|Create Kubernetes cluster'),
class: 'js-gke-cluster-creation-submit btn btn-success', disabled: true
diff --git a/app/views/clusters/clusters/new.html.haml b/app/views/clusters/clusters/new.html.haml
index 629585d82cd..fae78fbb7f4 100644
--- a/app/views/clusters/clusters/new.html.haml
+++ b/app/views/clusters/clusters/new.html.haml
@@ -1,6 +1,7 @@
- breadcrumb_title _('Kubernetes')
- page_title _('Kubernetes Cluster')
- active_tab = local_assigns.fetch(:active_tab, 'create')
+- provider = params[:provider]
= javascript_include_tag 'https://apis.google.com/js/api.js'
= render_gcp_signup_offer
@@ -19,8 +20,12 @@
%span Add existing cluster
.tab-content.gitlab-tab-content
- .tab-pane{ id: 'create-cluster-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' }
- = render new_cluster_partial(provider: params[:provider])
+ .tab-pane.p-0{ id: 'create-cluster-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' }
+ = render 'clusters/clusters/cloud_providers/cloud_provider_selector'
+
+ - if ['aws', 'gcp'].include?(provider)
+ .p-3.border-top
+ = render "clusters/clusters/#{provider}/new"
.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 4b295cd022d..e1f011a3225 100644
--- a/app/views/clusters/clusters/show.html.haml
+++ b/app/views/clusters/clusters/show.html.haml
@@ -29,12 +29,12 @@
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'),
+ ingress_dns_help_path: help_page_path('user/clusters/applications.md', anchor: 'pointing-your-dns-at-the-external-endpoint'),
ingress_mod_security_help_path: help_page_path('user/clusters/applications.md', anchor: 'web-application-firewall-modsecurity'),
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'),
+ cloud_run_help_path: help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'cloud-run-for-anthos'),
manage_prometheus_path: manage_prometheus_path,
cluster_id: @cluster.id } }
diff --git a/app/views/dashboard/_activities.html.haml b/app/views/dashboard/_activities.html.haml
index 4359a2c3c2b..2db3e35250f 100644
--- a/app/views/dashboard/_activities.html.haml
+++ b/app/views/dashboard/_activities.html.haml
@@ -5,4 +5,5 @@
%i.fa.fa-rss
.content_list
-= spinner
+.loading
+ .spinner.spinner-md
diff --git a/app/views/dashboard/_snippets_head.html.haml b/app/views/dashboard/_snippets_head.html.haml
index 4958cdc3745..d2fb4a3cd43 100644
--- a/app/views/dashboard/_snippets_head.html.haml
+++ b/app/views/dashboard/_snippets_head.html.haml
@@ -3,7 +3,7 @@
- if current_user && current_user.snippets.any? || @snippets.any?
.page-title-controls
- - if can?(current_user, :create_personal_snippet)
+ - if can?(current_user, :create_snippet)
= link_to _("New snippet"), new_snippet_path, class: "btn btn-success", title: _("New snippet")
.top-area
diff --git a/app/views/dashboard/projects/_projects.html.haml b/app/views/dashboard/projects/_projects.html.haml
index 5122164dbcb..ca201e626b8 100644
--- a/app/views/dashboard/projects/_projects.html.haml
+++ b/app/views/dashboard/projects/_projects.html.haml
@@ -1 +1 @@
-= render 'shared/projects/list', projects: @projects, pipeline_status: Feature.enabled?(:dashboard_pipeline_status, default_enabled: true), user: current_user
+= render 'shared/projects/list', projects: @projects, user: current_user
diff --git a/app/views/dashboard/snippets/index.html.haml b/app/views/dashboard/snippets/index.html.haml
index 44a9270971a..05214346496 100644
--- a/app/views/dashboard/snippets/index.html.haml
+++ b/app/views/dashboard/snippets/index.html.haml
@@ -1,11 +1,11 @@
- @hide_top_links = true
- page_title "Snippets"
- header_title "Snippets", dashboard_snippets_path
-- button_path = new_snippet_path if can?(current_user, :create_personal_snippet)
+- button_path = new_snippet_path if can?(current_user, :create_snippet)
= render 'dashboard/snippets_head'
- if current_user.snippets.exists?
- = render partial: 'snippets/snippets_scope_menu', locals: { include_private: true }
+ = render partial: 'snippets/snippets_scope_menu', locals: { include_private: true, counts: @snippet_counts }
- if current_user.snippets.exists?
= render partial: 'shared/snippets/list', locals: { link_project: true }
diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml
index 5f85235e8fa..232dffa28b4 100644
--- a/app/views/devise/registrations/new.html.haml
+++ b/app/views/devise/registrations/new.html.haml
@@ -1,7 +1,16 @@
- page_title "Sign up"
- if experiment_enabled?(:signup_flow)
- = render 'devise/shared/experimental_separate_sign_up_flow_box'
+ .row
+ .col-lg-7
+ %h1.mb-3.font-weight-bold.text-6.mt-0
+ = _("Speed up your DevOps<br>with GitLab").html_safe
+ %p.text-3
+ = _("GitLab is a single application for the entire software development lifecycle. From project planning and source code management to CI/CD, monitoring, and security.")
+ .col-lg-5.order-12
+ .text-center.mb-3
+ %h2.font-weight-bold.gl-font-size-20= _('Register for GitLab')
+ = render 'devise/shared/experimental_separate_sign_up_flow_box'
+ = render 'devise/shared/sign_in_link'
- else
= render 'devise/shared/signup_box'
-
-= render 'devise/shared/sign_in_link'
+ = render 'devise/shared/sign_in_link'
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
index 4832861445b..7bc3042c94d 100644
--- 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
@@ -1,4 +1,3 @@
-- content_for(:page_title, _('Register for GitLab'))
- max_first_name_length = max_last_name_length = 127
- max_username_length = 255
.signup-box.p-3.mb-2
@@ -41,3 +40,5 @@
= recaptcha_tags
.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' }
+ - if omniauth_enabled? && button_based_providers_enabled?
+ = render 'devise/shared/experimental_separate_sign_up_flow_omniauth_box'
diff --git a/app/views/devise/shared/_experimental_separate_sign_up_flow_omniauth_box.haml b/app/views/devise/shared/_experimental_separate_sign_up_flow_omniauth_box.haml
new file mode 100644
index 00000000000..d9143d90430
--- /dev/null
+++ b/app/views/devise/shared/_experimental_separate_sign_up_flow_omniauth_box.haml
@@ -0,0 +1,13 @@
+.omniauth-divider.d-flex.align-items-center.text-center
+ = _("or")
+%label.label-bold.d-block
+ = _("Create an account using:")
+- providers = enabled_button_based_providers
+.d-flex.justify-content-between.flex-wrap
+ - providers.each do |provider|
+ - has_icon = provider_has_icon?(provider)
+ = link_to omniauth_authorize_path(:user, provider), method: :post, class: "btn d-flex align-items-center omniauth-btn text-left oauth-login mb-2 p-2 #{qa_class_for_provider(provider)}", id: "oauth-login-#{provider}" do
+ - if has_icon
+ = provider_image_tag(provider)
+ %span.ml-2
+ = label_for_provider(provider)
diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml
index 78904f550c7..79abe31a056 100644
--- a/app/views/doorkeeper/applications/_form.html.haml
+++ b/app/views/doorkeeper/applications/_form.html.haml
@@ -15,6 +15,12 @@
%span.form-text.text-muted
= _('Use <code>%{native_redirect_uri}</code> for local tests').html_safe % { native_redirect_uri: Doorkeeper.configuration.native_redirect_uri }
+ .form-group.form-check
+ = f.check_box :confidential, class: 'form-check-input'
+ = f.label :confidential, class: 'label-bold form-check-label'
+ %span.form-text.text-muted
+ = _('The application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential.')
+
.form-group
= f.label :scopes, class: 'label-bold'
= render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes
diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml
index 8a1b7500abf..7b29269dbb1 100644
--- a/app/views/doorkeeper/applications/show.html.haml
+++ b/app/views/doorkeeper/applications/show.html.haml
@@ -34,6 +34,12 @@
%div
%span.monospace= uri
+ %tr
+ %td
+ = _('Confidential')
+ %td
+ = @application.confidential? ? _('Yes') : _('No')
+
= render "shared/tokens/scopes_list", token: @application
.form-actions
diff --git a/app/views/explore/projects/_nav.html.haml b/app/views/explore/projects/_nav.html.haml
index bf65c19b720..65b7d055843 100644
--- a/app/views/explore/projects/_nav.html.haml
+++ b/app/views/explore/projects/_nav.html.haml
@@ -1,14 +1,14 @@
.top-area
%ul.nav-links.nav.nav-tabs
- = nav_link(page: [trending_explore_projects_path, explore_root_path]) do
- = link_to trending_explore_projects_path do
- = _('Trending')
+ = nav_link(page: [explore_projects_path, explore_root_path]) do
+ = link_to explore_projects_path do
+ = _('All')
= nav_link(page: starred_explore_projects_path) do
= link_to starred_explore_projects_path do
= _('Most stars')
- = nav_link(page: explore_projects_path) do
- = link_to explore_projects_path do
- = _('All')
+ = nav_link(page: trending_explore_projects_path) do
+ = link_to trending_explore_projects_path do
+ = _('Trending')
.nav-controls
- unless current_user
diff --git a/app/views/explore/projects/_projects.html.haml b/app/views/explore/projects/_projects.html.haml
index d819c4ea554..35b32662b8a 100644
--- a/app/views/explore/projects/_projects.html.haml
+++ b/app/views/explore/projects/_projects.html.haml
@@ -1,2 +1,2 @@
- is_explore_page = defined?(explore_page) && explore_page
-= render 'shared/projects/list', projects: projects, user: current_user, explore_page: is_explore_page, pipeline_status: Feature.enabled?(:dashboard_pipeline_status, default_enabled: true)
+= render 'shared/projects/list', projects: projects, user: current_user, explore_page: is_explore_page
diff --git a/app/views/explore/projects/page_out_of_bounds.html.haml b/app/views/explore/projects/page_out_of_bounds.html.haml
new file mode 100644
index 00000000000..57114dd0752
--- /dev/null
+++ b/app/views/explore/projects/page_out_of_bounds.html.haml
@@ -0,0 +1,21 @@
+- @hide_top_links = true
+- page_title _("Projects")
+- header_title _("Projects"), dashboard_projects_path
+
+= render_dashboard_gold_trial(current_user)
+
+- if current_user
+ = render 'dashboard/projects_head', project_tab_filter: :explore
+- else
+ = render 'explore/head'
+
+= render 'explore/projects/nav' unless Feature.enabled?(:project_list_filter_bar) && current_user
+
+.nothing-here-block
+ .svg-content
+ = image_tag 'illustrations/profile-page/personal-project.svg', size: '75'
+ .text-content
+ %h5= _("Maximum page reached")
+ %p= _("Sorry, you have exceeded the maximum browsable page number. Please use the API to explore further.")
+
+ = link_to _("Back to page %{number}") % { number: @max_page_number }, request.params.merge(page: @max_page_number), class: 'btn btn-inverted'
diff --git a/app/views/groups/_create_chat_team.html.haml b/app/views/groups/_create_chat_team.html.haml
index 2531993a095..07394eec107 100644
--- a/app/views/groups/_create_chat_team.html.haml
+++ b/app/views/groups/_create_chat_team.html.haml
@@ -6,7 +6,7 @@
Mattermost
.col-sm-10
.form-check.js-toggle-container
- .js-toggle-button.form-check-input= f.check_box(:create_chat_team, { checked: true }, true, false)
+ .js-toggle-button.form-check-input= f.check_box(:create_chat_team, { checked: false }, true, false)
= f.label :create_chat_team, class: 'form-check-label' do
= _('Create a Mattermost team for this group')
%br
diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml
index e50d2b8e994..6772ee94d46 100644
--- a/app/views/groups/_home_panel.html.haml
+++ b/app/views/groups/_home_panel.html.haml
@@ -30,7 +30,7 @@
.btn-group.new-project-subgroup.droplab-dropdown.home-panel-action-button.prepend-top-default.js-new-project-subgroup.qa-new-project-or-subgroup-dropdown{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } }
%input.btn.btn-success.dropdown-primary.js-new-group-child.qa-new-in-group-button{ type: "button", value: new_project_label, data: { action: "new-project" } }
%button.btn.btn-success.dropdown-toggle.js-dropdown-toggle.qa-new-project-or-subgroup-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown", 'display' => 'static' } }
- = sprite_icon("arrow-down", css_class: "icon dropdown-btn-icon")
+ = sprite_icon("chevron-down", css_class: "icon dropdown-btn-icon")
%ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-right{ data: { dropdown: true } }
%li.droplab-item-selected.qa-new-project-option{ role: "button", data: { value: "new-project", text: new_project_label } }
.menu-item
diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml
index e85b0713230..b82910df5d5 100644
--- a/app/views/groups/registry/repositories/index.html.haml
+++ b/app/views/groups/registry/repositories/index.html.haml
@@ -3,10 +3,21 @@
%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 } }
+ - if Feature.enabled?(:vue_container_registry_explorer)
+ #js-container-registry{ data: { endpoint: group_container_registries_path(@group),
+ "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'),
+ "registry_host_url_with_port" => escape_once(registry_config.host_port),
+ is_group_page: true,
+ character_error: @character_error.to_s } }
+ - else
+ #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/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml
index 307309c6ca3..2734ab538a0 100644
--- a/app/views/groups/settings/_advanced.html.haml
+++ b/app/views/groups/settings/_advanced.html.haml
@@ -39,12 +39,5 @@
%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')
- = form_tag(@group, method: :delete) do
- %p
- = _('Removing group will cause all child projects and resources to be removed.')
- %br
- %strong= _('Removed group can not be restored!')
-
- = button_to _('Remove group'), '#', class: 'btn btn-remove js-confirm-danger', data: { 'confirm-danger-message' => remove_group_message(@group) }
+= render 'groups/settings/remove', group: @group
+= render_if_exists 'groups/settings/restore', group: @group
diff --git a/app/views/groups/settings/_permanent_deletion.html.haml b/app/views/groups/settings/_permanent_deletion.html.haml
new file mode 100644
index 00000000000..31e2bac70be
--- /dev/null
+++ b/app/views/groups/settings/_permanent_deletion.html.haml
@@ -0,0 +1,9 @@
+.sub-section
+ %h4.danger-title= _('Remove group')
+ = form_tag(group, method: :delete) do
+ %p
+ = _('Removing group will cause all child projects and resources to be removed.')
+ %br
+ %strong= _('Removed group can not be restored!')
+
+ = button_to _('Remove group'), '#', class: 'btn btn-remove js-confirm-danger', data: { 'confirm-danger-message' => remove_group_message(group) }
diff --git a/app/views/groups/settings/_remove.html.haml b/app/views/groups/settings/_remove.html.haml
new file mode 100644
index 00000000000..a617467019a
--- /dev/null
+++ b/app/views/groups/settings/_remove.html.haml
@@ -0,0 +1,5 @@
+- if group.adjourned_deletion?
+ = render_if_exists 'groups/settings/adjourned_deletion', group: group
+- else
+ = render 'groups/settings/permanent_deletion', group: group
+
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index 457d05b4a97..4916c4651dd 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -9,6 +9,8 @@
= render 'groups/home_panel'
+ = render_if_exists 'groups/self_or_ancestor_marked_for_deletion_notice', group: @group
+
.groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } }
.top-area.group-nav-container.justify-content-between
.scrolling-tabs-container.inner-page-scroll-tabs
diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml
index 5f8f2333e40..4b9304cfdb9 100644
--- a/app/views/help/_shortcuts.html.haml
+++ b/app/views/help/_shortcuts.html.haml
@@ -6,6 +6,7 @@
= _('Keyboard Shortcuts')
%small
= link_to _('(Show all)'), '#', class: 'js-more-help-button'
+ .js-toggle-shortcuts
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
%span{ "aria-hidden": true } &times;
.modal-body
diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml
index b8a421ac9d3..7e0b444e5d7 100644
--- a/app/views/help/ui.html.haml
+++ b/app/views/help/ui.html.haml
@@ -283,7 +283,7 @@
Dropdown option
.dropdown-footer
%strong Tip:
- If an author is not a member of this project, you can still filter by his name while using the search field.
+ If an author is not a member of this project, you can still filter by their name while using the search field.
.dropdown.inline
%button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } }
Dropdown loading
@@ -322,7 +322,7 @@
Dropdown option
.dropdown-footer
%strong Tip:
- If an author is not a member of this project, you can still filter by his name while using the search field.
+ If an author is not a member of this project, you can still filter by their name while using the search field.
.dropdown-loading
= icon('spinner spin')
diff --git a/app/views/import/shared/_new_project_form.html.haml b/app/views/import/shared/_new_project_form.html.haml
index 4d13d4f2869..35059229a55 100644
--- a/app/views/import/shared/_new_project_form.html.haml
+++ b/app/views/import/shared/_new_project_form.html.haml
@@ -10,7 +10,7 @@
.input-group-prepend.flex-shrink-0.has-tooltip{ title: root_url }
.input-group-text
= root_url
- = select_tag :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), class: 'select2 js-select-namespace', tabindex: 1
+ = select_tag :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), class: 'select2 js-select-namespace block-truncated', tabindex: 1
- else
.input-group-prepend.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' }
.input-group-text.border-0
diff --git a/app/views/instance_statistics/cohorts/_usage_ping.html.haml b/app/views/instance_statistics/cohorts/_usage_ping.html.haml
deleted file mode 100644
index 3dda386fcf7..00000000000
--- a/app/views/instance_statistics/cohorts/_usage_ping.html.haml
+++ /dev/null
@@ -1,10 +0,0 @@
-%h2#usage-ping Usage ping
-
-.bs-callout.clearfix
- %p
- User cohorts are shown because the usage ping is enabled. The data sent with
- this is shown below. To disable this, visit
- = succeed '.' do
- = link_to 'application settings', admin_application_settings_path(anchor: 'usage-statistics')
-
-%pre.usage-data.js-syntax-highlight.code.highlight{ data: { endpoint: usage_data_admin_application_settings_path(format: :html) } }
diff --git a/app/views/instance_statistics/cohorts/index.html.haml b/app/views/instance_statistics/cohorts/index.html.haml
index c438566cb05..5333f8b7a1f 100644
--- a/app/views/instance_statistics/cohorts/index.html.haml
+++ b/app/views/instance_statistics/cohorts/index.html.haml
@@ -9,6 +9,6 @@
- usage_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: usage_ping_path }
= s_('User Cohorts are only shown when the %{usage_ping_link_start}usage ping%{usage_ping_link_end} is enabled.').html_safe % { usage_ping_link_start: usage_ping_link_start, usage_ping_link_end: '</a>'.html_safe }
- if current_user.admin?
- - application_settings_path = admin_application_settings_path(anchor: 'usage-statistics')
+ - application_settings_path = metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings')
- application_settings_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: application_settings_path }
= s_('To enable it and see User Cohorts, visit %{application_settings_link_start}application settings%{application_settings_link_end}.').html_safe % { application_settings_link_start: application_settings_link_start, application_settings_link_end: '</a>'.html_safe }
diff --git a/app/views/kaminari/gitlab/_first_page.html.haml b/app/views/kaminari/gitlab/_first_page.html.haml
deleted file mode 100644
index 3b7d4a1c578..00000000000
--- a/app/views/kaminari/gitlab/_first_page.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
--# Link to the "First" page
--# available local variables
--# url: url to the first page
--# current_page: a page object for the currently displayed page
--# total_pages: total number of pages
--# per_page: number of items to fetch per page
--# remote: data-remote
-%li.page-item.js-first-button
- = link_to_unless current_page.first?, raw(t 'views.pagination.first'), url, remote: remote, class: 'page-link'
diff --git a/app/views/kaminari/gitlab/_last_page.html.haml b/app/views/kaminari/gitlab/_last_page.html.haml
deleted file mode 100644
index 7836e17f877..00000000000
--- a/app/views/kaminari/gitlab/_last_page.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
--# Link to the "Last" page
--# available local variables
--# url: url to the last page
--# current_page: a page object for the currently displayed page
--# total_pages: total number of pages
--# per_page: number of items to fetch per page
--# remote: data-remote
-%li.page-item.js-last-button
- = link_to_unless current_page.last?, raw(t 'views.pagination.last'), url, {remote: remote, class: 'page-link'}
diff --git a/app/views/kaminari/gitlab/_next_page.html.haml b/app/views/kaminari/gitlab/_next_page.html.haml
index a7fa1a21a6c..9572dd91330 100644
--- a/app/views/kaminari/gitlab/_next_page.html.haml
+++ b/app/views/kaminari/gitlab/_next_page.html.haml
@@ -9,4 +9,6 @@
- page_url = current_page.last? ? '#' : url
%li.page-item.js-next-button{ class: ('disabled' if current_page.last?) }
- = link_to raw(t 'views.pagination.next'), page_url, rel: 'next', remote: remote, class: 'page-link'
+ = link_to page_url, rel: 'next', remote: remote, class: 'page-link' do
+ = s_('Pagination|Next')
+ = sprite_icon('angle-right', size: 8)
diff --git a/app/views/kaminari/gitlab/_page.html.haml b/app/views/kaminari/gitlab/_page.html.haml
index d0dc1784540..33e00256100 100644
--- a/app/views/kaminari/gitlab/_page.html.haml
+++ b/app/views/kaminari/gitlab/_page.html.haml
@@ -6,5 +6,9 @@
-# total_pages: total number of pages
-# per_page: number of items to fetch per page
-# remote: data-remote
-%li.page-item.js-pagination-page{ class: [active_when(page.current?), ('sibling' if page.next? || page.prev?), ('d-none d-md-block' if !page.current?) ] }
+%li.page-item.js-pagination-page{ class: [active_when(page.current?),
+ ('sibling' if page.next? || page.prev?),
+ ('js-first-button' if page.first?),
+ ('js-last-button' if page.last?),
+ ('d-none d-md-block' if !page.current?) ] }
= link_to page, url, { remote: remote, rel: page.next? ? 'next' : page.prev? ? 'prev' : nil, class: 'page-link' }
diff --git a/app/views/kaminari/gitlab/_paginator.html.haml b/app/views/kaminari/gitlab/_paginator.html.haml
index ac9e274dbc7..1b2edc0ad22 100644
--- a/app/views/kaminari/gitlab/_paginator.html.haml
+++ b/app/views/kaminari/gitlab/_paginator.html.haml
@@ -8,14 +8,10 @@
= paginator.render do
.gl-pagination.prepend-top-default
%ul.pagination.justify-content-center
- - unless current_page.first?
- = first_page_tag unless total_pages < 5 # As kaminari will always show the first 5 pages
= prev_page_tag
- each_page do |page|
- - if page.left_outer? || page.right_outer? || page.inside_window?
+ - if page.left_outer? || page.right_outer? || page.inside_window? || page.first? || page.last?
= page_tag page
- elsif !page.was_truncated?
= gap_tag
= next_page_tag
- - unless current_page.last?
- = last_page_tag unless total_pages < 5
diff --git a/app/views/kaminari/gitlab/_prev_page.html.haml b/app/views/kaminari/gitlab/_prev_page.html.haml
index 12b0e106a62..4ba7ab6488a 100644
--- a/app/views/kaminari/gitlab/_prev_page.html.haml
+++ b/app/views/kaminari/gitlab/_prev_page.html.haml
@@ -9,4 +9,6 @@
- page_url = current_page.first? ? '#' : url
%li.page-item.js-previous-button{ class: ('disabled' if current_page.first?) }
- = link_to raw(t 'views.pagination.previous'), page_url, rel: 'prev', remote: remote, class: 'page-link'
+ = link_to page_url, rel: 'prev', remote: remote, class: 'page-link' do
+ = sprite_icon('angle-left', size: 8)
+ = s_('Pagination|Prev')
diff --git a/app/views/kaminari/gitlab/_without_count.html.haml b/app/views/kaminari/gitlab/_without_count.html.haml
index f780400ebcb..d13f6ca5fa8 100644
--- a/app/views/kaminari/gitlab/_without_count.html.haml
+++ b/app/views/kaminari/gitlab/_without_count.html.haml
@@ -2,7 +2,11 @@
%ul.pagination.justify-content-center
- if previous_path
%li.page-item.prev
- = link_to(t('views.pagination.previous'), previous_path, rel: 'prev', class: 'page-link')
+ = link_to previous_path, rel: 'prev', class: 'page-link' do
+ = sprite_icon('angle-left', size: 8)
+ = s_('Pagination|Prev')
- if next_path
%li.page-item.next
- = link_to(t('views.pagination.next'), next_path, rel: 'next', class: 'page-link')
+ = link_to next_path, rel: 'next', class: 'page-link' do
+ = s_('Pagination|Next')
+ = sprite_icon('angle-right', size: 8)
diff --git a/app/views/layouts/_broadcast.html.haml b/app/views/layouts/_broadcast.html.haml
index 9d7ad249ac8..4c4fc6411b8 100644
--- a/app/views/layouts/_broadcast.html.haml
+++ b/app/views/layouts/_broadcast.html.haml
@@ -1,4 +1,3 @@
- current_broadcast_banner_messages.each do |message|
= broadcast_message(message)
-- if Feature.enabled?(:broadcast_notification_type)
- = broadcast_message(current_broadcast_notification_message)
+= broadcast_message(current_broadcast_notification_message)
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 443a73f5cce..2b2ffd6abeb 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -11,6 +11,7 @@
= render "layouts/nav/classification_level_banner"
= yield :flash_message
= render "shared/ping_consent"
+ = render_account_recovery_regular_check
- unless @hide_breadcrumbs
= render "layouts/nav/breadcrumbs"
.d-flex
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 7af190f5a0b..eb58115451d 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -4,7 +4,7 @@
!!! 5
%html{ lang: I18n.locale, class: page_classes }
= render "layouts/head"
- %body{ class: "#{user_application_theme} #{@body_class} #{client_class_list}", data: body_data }
+ %body{ class: "#{user_application_theme} #{user_tab_width} #{@body_class} #{client_class_list}", data: body_data }
= render "layouts/init_auto_complete" if @gfm_form
= render "layouts/init_client_detection_flags"
= render 'peek/bar'
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
index 2f05717fc0e..fddfe14e05f 100644
--- a/app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml
+++ b/app/views/layouts/devise_experimental_separate_sign_up_flow.html.haml
@@ -1,22 +1,15 @@
!!! 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
+ %body.ui-indigo.signup-page{ class: "#{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } }
+ = render "layouts/header/logo_with_title"
= render "layouts/init_client_detection_flags"
.page-wrap
- .container.signup-box-container.navless-container.mt-0
+ .container.signup-box-container.navless-container
= 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
+ = yield
%hr.footer-fixed
.footer-container
.container
diff --git a/app/views/layouts/fullscreen.html.haml b/app/views/layouts/fullscreen.html.haml
index 91a7777514c..8d0775f6f27 100644
--- a/app/views/layouts/fullscreen.html.haml
+++ b/app/views/layouts/fullscreen.html.haml
@@ -1,7 +1,7 @@
!!! 5
%html{ lang: I18n.locale, class: page_class }
= render "layouts/head"
- %body{ class: "#{user_application_theme} #{@body_class} fullscreen-layout", data: { page: body_data_page } }
+ %body{ class: "#{user_application_theme} #{user_tab_width} #{@body_class} fullscreen-layout", data: { page: body_data_page } }
= render 'peek/bar'
= header_message
= render partial: "layouts/header/default", locals: { project: @project, group: @group }
diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml
index 93854c212df..a003d6f8903 100644
--- a/app/views/layouts/header/_help_dropdown.html.haml
+++ b/app/views/layouts/header/_help_dropdown.html.haml
@@ -4,6 +4,10 @@
= link_to _("Help"), help_path
%li
= link_to _("Support"), support_url
+ %li
+ %button.js-shortcuts-modal-trigger{ type: "button" }
+ = _("Keyboard shortcuts")
+ %span.text-secondary.float-right{ "aria-hidden": true }= '?'.html_safe
= render_if_exists "shared/learn_gitlab_menu_item"
%li.divider
%li
diff --git a/app/views/layouts/header/_logo_with_title.html.haml b/app/views/layouts/header/_logo_with_title.html.haml
new file mode 100644
index 00000000000..1ea6168fc9a
--- /dev/null
+++ b/app/views/layouts/header/_logo_with_title.html.haml
@@ -0,0 +1,4 @@
+%header.navbar.fixed-top.navbar-gitlab.justify-content-center
+ = render 'shared/logo.svg'
+ %span.logo-text.d-none.d-lg-block.prepend-left-8.pt-1
+ = render 'shared/logo_type.svg'
diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml
index 30109621515..3cbfb24a868 100644
--- a/app/views/layouts/header/_new_dropdown.haml
+++ b/app/views/layouts/header/_new_dropdown.haml
@@ -21,7 +21,7 @@
- if @project&.persisted?
- create_project_issue = show_new_issue_link?(@project)
- merge_project = merge_request_source_project_for_project(@project)
- - create_project_snippet = can?(current_user, :create_project_snippet, @project)
+ - create_project_snippet = can?(current_user, :create_snippet, @project)
- if create_project_issue || merge_project || create_project_snippet
%li.dropdown-bold-header
@@ -38,5 +38,5 @@
%li= link_to _('New project'), new_project_path, class: 'qa-global-new-project-link'
- if current_user.can_create_group?
%li= link_to _('New group'), new_group_path
- - if current_user.can?(:create_personal_snippet)
+ - if current_user.can?(:create_snippet)
%li= link_to _('New snippet'), new_snippet_path, class: 'qa-global-new-snippet-link'
diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml
index 379ba976040..2efb304b397 100644
--- a/app/views/layouts/nav/_dashboard.html.haml
+++ b/app/views/layouts/nav/_dashboard.html.haml
@@ -76,7 +76,7 @@
- if Feature.enabled?(:user_mode_in_session)
- if header_link?(:admin_mode)
= nav_link(controller: 'admin/sessions', html_options: { class: "d-none d-lg-block d-xl-block"}) do
- = link_to destroy_admin_session_path, title: _('Leave Admin Mode'), aria: { label: _('Leave Admin Mode') }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
+ = link_to destroy_admin_session_path, method: :post, title: _('Leave Admin Mode'), aria: { label: _('Leave Admin Mode') }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
= sprite_icon('lock-open', size: 18)
- elsif current_user.admin?
= nav_link(controller: 'admin/sessions', html_options: { class: "d-none d-lg-block d-xl-block"}) do
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index 71fef5df5bc..9f70124ba0d 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -221,7 +221,7 @@
= _('Appearance')
= nav_link(controller: :application_settings) do
- = link_to admin_application_settings_path do
+ = link_to general_admin_application_settings_path do
.nav-icon-container
= sprite_icon('settings')
%span.nav-item-name.qa-admin-settings-item
@@ -229,11 +229,11 @@
%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
+ = link_to general_admin_application_settings_path do
%strong.fly-out-top-item-name
= _('Settings')
%li.divider.fly-out-top-item
- = nav_link(path: 'application_settings#show') do
+ = nav_link(path: 'application_settings#general') do
= link_to general_admin_application_settings_path, title: _('General'), class: 'qa-admin-settings-general-item' do
%span
= _('General')
@@ -254,6 +254,11 @@
= link_to ci_cd_admin_application_settings_path, title: _('CI/CD') do
%span
= _('CI/CD')
+ - if Feature.enabled?(:serverless_domain)
+ = nav_link(path: 'application_settings#operations') do
+ = link_to admin_serverless_domains_path, title: _('Operations') do
+ %span
+ = _('Operations')
= nav_link(path: 'application_settings#reporting') do
= link_to reporting_admin_application_settings_path, title: _('Reporting') do
%span
diff --git a/app/views/layouts/nav/sidebar/_analytics_links.html.haml b/app/views/layouts/nav/sidebar/_analytics_links.html.haml
new file mode 100644
index 00000000000..e87cf92374a
--- /dev/null
+++ b/app/views/layouts/nav/sidebar/_analytics_links.html.haml
@@ -0,0 +1,16 @@
+- navbar_links = links.sort_by(&:title)
+- all_paths = navbar_links.map(&:path)
+
+- if navbar_links.any?
+ = nav_link(path: all_paths) do
+ = link_to navbar_links.first.link do
+ .nav-icon-container
+ = sprite_icon('chart')
+ %span.nav-item-name{ data: { qa_selector: 'analytics_link' } }
+ = _('Analytics')
+
+ %ul.sidebar-sub-level-items{ data: { qa_selector: 'analytics_sidebar_submenu' } }
+ - navbar_links.each do |menu_item|
+ = nav_link(path: menu_item.path) do
+ = link_to(menu_item.link, menu_item.link_to_options) do
+ %span= menu_item.title
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index 88bb0a97487..c00c48b623c 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -1,3 +1,4 @@
+- should_display_analytics_pages_in_sidebar = Feature.enabled?(:analytics_pages_under_group_analytics_sidebar, @group, default_enabled: true)
- issues_count = group_issues_count(state: 'opened')
- merge_requests_count = group_merge_requests_count(state: 'opened')
@@ -11,7 +12,9 @@
= @group.name
%ul.sidebar-top-level-items.qa-group-sidebar
- if group_sidebar_link?(:overview)
- = nav_link(path: group_overview_nav_link_paths, html_options: { class: 'home' }) do
+ - paths = group_overview_nav_link_paths
+ - paths << 'contribution_analytics#show' unless should_display_analytics_pages_in_sidebar
+ = nav_link(path: paths, unless: -> { should_display_analytics_pages_in_sidebar && current_path?('groups/contribution_analytics#show') }, html_options: { class: 'home' }) do
= link_to group_path(@group) do
.nav-icon-container
= sprite_icon('home')
@@ -42,18 +45,19 @@
%span
= _('Activity')
- - if group_sidebar_link?(:contribution_analytics)
- = nav_link(path: 'analytics#show') do
- = link_to group_contribution_analytics_path(@group), title: _('Contribution Analytics'), data: { placement: 'right', qa_selector: 'contribution_analytics_link' } do
- %span
- = _('Contribution Analytics')
+ - unless should_display_analytics_pages_in_sidebar
+ - if group_sidebar_link?(:contribution_analytics)
+ = nav_link(path: 'contribution_analytics#show') do
+ = link_to group_contribution_analytics_path(@group), title: _('Contribution Analytics'), data: { placement: 'right', qa_selector: 'contribution_analytics_link' } do
+ %span
+ = _('Contribution Analytics')
- = render_if_exists 'layouts/nav/group_insights_link'
+ = render_if_exists 'layouts/nav/group_insights_link'
= render_if_exists "layouts/nav/ee/epic_link", group: @group
- if group_sidebar_link?(:issues)
- = nav_link(path: group_issues_sub_menu_items) do
+ = nav_link(path: group_issues_sub_menu_items, unless: -> { should_display_analytics_pages_in_sidebar && current_path?('issues_analytics#show') }) do
= link_to issues_group_path(@group), data: { qa_selector: 'group_issues_item' } do
.nav-icon-container
= sprite_icon('issues')
@@ -80,7 +84,8 @@
%span
= boards_link_text
- = render_if_exists 'layouts/nav/issues_analytics_link'
+ - unless should_display_analytics_pages_in_sidebar
+ = render_if_exists 'layouts/nav/issues_analytics_link'
- if group_sidebar_link?(:labels)
= nav_link(path: 'labels#index') do
@@ -126,6 +131,8 @@
= render_if_exists 'groups/sidebar/packages'
+ = render 'layouts/nav/sidebar/analytics_links', links: group_analytics_navbar_links(@group, current_user)
+
- if group_sidebar_link?(:group_members)
= nav_link(path: 'group_members#index') do
= link_to group_group_members_path(@group) do
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 3464cc1ea07..b9324f0596c 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -1,3 +1,5 @@
+- should_display_analytics_pages_in_sidebar = Feature.enabled?(:analytics_pages_under_project_analytics_sidebar, @project, default_enabled: true)
+
.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) }
.nav-sidebar-inner-scroll
- can_edit = can?(current_user, :admin_project, @project)
@@ -7,8 +9,10 @@
= project_icon(@project, alt: @project.name, class: 'avatar s40 avatar-tile', width: 40, height: 40)
.sidebar-context-title
= @project.name
- %ul.sidebar-top-level-items
- = nav_link(path: sidebar_projects_paths, html_options: { class: 'home' }) do
+ %ul.sidebar-top-level-items.qa-project-sidebar
+ - paths = sidebar_projects_paths
+ - paths << 'cycle_analytics#show' unless should_display_analytics_pages_in_sidebar
+ = nav_link(path: paths, html_options: { class: 'home' }) do
= link_to project_path(@project), class: 'shortcuts-project rspec-project-link', data: { qa_selector: 'project_link' } do
.nav-icon-container
= sprite_icon('home')
@@ -34,15 +38,18 @@
= link_to project_releases_path(@project), title: _('Releases'), class: 'shortcuts-project-releases' do
%span= _('Releases')
- - if can?(current_user, :read_cycle_analytics, @project)
- = nav_link(path: 'cycle_analytics#show') do
- = link_to project_cycle_analytics_path(@project), title: _('Cycle Analytics'), class: 'shortcuts-project-cycle-analytics' do
- %span= _('Cycle Analytics')
- = render_if_exists 'layouts/nav/project_insights_link'
+ - unless should_display_analytics_pages_in_sidebar
+ - if can?(current_user, :read_cycle_analytics, @project)
+ = nav_link(path: 'cycle_analytics#show') do
+ = link_to project_cycle_analytics_path(@project), title: _('Value Stream Analytics'), class: 'shortcuts-project-cycle-analytics' do
+ %span= _('Value Stream Analytics')
+
+ = render_if_exists 'layouts/nav/project_insights_link'
+
- if project_nav_tab? :files
- = nav_link(controller: sidebar_repository_paths) do
+ = nav_link(controller: sidebar_repository_paths, unless: -> { should_display_analytics_pages_in_sidebar && current_path?('projects/graphs#charts') }) do
= link_to project_tree_path(@project), class: 'shortcuts-tree qa-project-menu-repo' do
.nav-icon-container
= sprite_icon('doc-text')
@@ -83,9 +90,10 @@
= link_to project_compare_index_path(@project, from: @repository.root_ref, to: current_ref) do
= _('Compare')
- = nav_link(path: 'graphs#charts') do
- = link_to charts_project_graph_path(@project, current_ref) do
- = _('Charts')
+ - unless should_display_analytics_pages_in_sidebar
+ = nav_link(path: 'graphs#charts') do
+ = link_to charts_project_graph_path(@project, current_ref) do
+ = _('Charts')
= render_if_exists 'projects/sidebar/repository_locked_files'
@@ -170,7 +178,7 @@
= number_with_delimiter(@project.open_merge_requests_count)
- if project_nav_tab? :pipelines
- = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts]) do
+ = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts], unless: -> { should_display_analytics_pages_in_sidebar && current_path?('projects/pipelines#charts') }) do
= link_to project_pipelines_path(@project), class: 'shortcuts-pipelines qa-link-pipelines rspec-link-pipelines', data: { qa_selector: 'ci_cd_link' } do
.nav-icon-container
= sprite_icon('rocket')
@@ -201,13 +209,13 @@
%span
= _('Artifacts')
- - if project_nav_tab? :pipelines
+ - if project_nav_tab?(:pipelines)
= nav_link(controller: :pipeline_schedules) do
= link_to pipeline_schedules_path(@project), title: _('Schedules'), class: 'shortcuts-builds' do
%span
= _('Schedules')
- - if @project.feature_available?(:builds, current_user) && !@project.empty_repo?
+ - if !should_display_analytics_pages_in_sidebar && @project.feature_available?(:builds, current_user) && !@project.empty_repo?
= nav_link(path: 'pipelines#charts') do
= link_to charts_project_pipelines_path(@project), title: _('Charts'), class: 'shortcuts-pipelines-charts' do
%span
@@ -232,7 +240,7 @@
- if project_nav_tab? :environments
= nav_link(controller: :environments, action: [:metrics, :metrics_redirect]) do
- = link_to metrics_project_environments_path(@project), title: _('Metrics'), class: 'shortcuts-metrics' do
+ = link_to metrics_project_environments_path(@project), title: _('Metrics'), class: 'shortcuts-metrics', data: { qa_selector: 'operations_metrics_link' } do
%span
= _('Metrics')
@@ -290,7 +298,7 @@
= render_if_exists 'layouts/nav/sidebar/project_packages_link'
- = render_if_exists 'layouts/nav/sidebar/project_analytics_link' # EE-specific
+ = render 'layouts/nav/sidebar/analytics_links', links: project_analytics_navbar_links(@project, current_user)
- if project_nav_tab? :wiki
- wiki_url = project_wiki_path(@project, :home)
@@ -410,11 +418,12 @@
= link_to project_network_path(@project, current_ref), title: _('Network'), class: 'shortcuts-network' do
= _('Graph')
- -# Shortcut to Repository > Charts (formerly, top-nav item "Graphs")
- - unless @project.empty_repo?
- %li.hidden
- = link_to charts_project_graph_path(@project, current_ref), title: _('Charts'), class: 'shortcuts-repository-charts' do
- = _('Charts')
+ - unless should_display_analytics_pages_in_sidebar
+ -# Shortcut to Repository > Charts (formerly, top-nav item "Graphs")
+ - unless @project.empty_repo?
+ %li.hidden
+ = link_to charts_project_graph_path(@project, current_ref), title: _('Charts'), class: 'shortcuts-repository-charts' do
+ = _('Charts')
-# Shortcut to Issues > New Issue
- if project_nav_tab?(:issues)
diff --git a/app/views/notify/_failed_builds.html.haml b/app/views/notify/_failed_builds.html.haml
index 7c563bb016c..1711c34a842 100644
--- a/app/views/notify/_failed_builds.html.haml
+++ b/app/views/notify/_failed_builds.html.haml
@@ -27,6 +27,6 @@
- if build.has_trace?
%td{ colspan: "2", style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; padding: 0 0 16px;" }
%pre{ style: "font-family: Monaco,'Lucida Console','Courier New',Courier,monospace; background-color: #fafafa; border-radius: 4px; overflow: hidden; white-space: pre-wrap; word-break: break-all; font-size:13px; line-height: 1.4; padding: 16px 8px; color: #333333; margin: 0;" }
- = build.trace.html(last_lines: 10).html_safe
+ = build.trace.html(last_lines: 30).html_safe
- else
%td{ colspan: "2" }
diff --git a/app/views/notify/autodevops_disabled_email.text.erb b/app/views/notify/autodevops_disabled_email.text.erb
index bf863952478..91092060e74 100644
--- a/app/views/notify/autodevops_disabled_email.text.erb
+++ b/app/views/notify/autodevops_disabled_email.text.erb
@@ -15,6 +15,6 @@ had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>.
Stage: <%= build.stage %>
Name: <%= build.name %>
<% if build.has_trace? -%>
- Trace: <%= build.trace.raw(last_lines: 10) %>
+ Trace: <%= build.trace.raw(last_lines: 30) %>
<% end -%>
<% end -%>
diff --git a/app/views/notify/links/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/notify/links/projects/generic_commit_statuses/_generic_commit_status.html.haml
new file mode 100644
index 00000000000..b6563b185b3
--- /dev/null
+++ b/app/views/notify/links/projects/generic_commit_statuses/_generic_commit_status.html.haml
@@ -0,0 +1 @@
+= build.name
diff --git a/app/views/notify/links/projects/generic_commit_statuses/_generic_commit_status.text.erb b/app/views/notify/links/projects/generic_commit_statuses/_generic_commit_status.text.erb
new file mode 100644
index 00000000000..af8924bad57
--- /dev/null
+++ b/app/views/notify/links/projects/generic_commit_statuses/_generic_commit_status.text.erb
@@ -0,0 +1 @@
+Job #<%= build.id %>
diff --git a/app/views/notify/note_project_snippet_email.html.haml b/app/views/notify/note_project_snippet_email.html.haml
deleted file mode 100644
index 5e69f01a486..00000000000
--- a/app/views/notify/note_project_snippet_email.html.haml
+++ /dev/null
@@ -1 +0,0 @@
-= render 'note_email'
diff --git a/app/views/notify/note_project_snippet_email.text.erb b/app/views/notify/note_project_snippet_email.text.erb
deleted file mode 100644
index 413d9e6e9ac..00000000000
--- a/app/views/notify/note_project_snippet_email.text.erb
+++ /dev/null
@@ -1 +0,0 @@
-<%= render 'note_email' %>
diff --git a/app/views/notify/note_personal_snippet_email.html.haml b/app/views/notify/note_snippet_email.html.haml
index 5e69f01a486..5e69f01a486 100644
--- a/app/views/notify/note_personal_snippet_email.html.haml
+++ b/app/views/notify/note_snippet_email.html.haml
diff --git a/app/views/notify/note_personal_snippet_email.text.erb b/app/views/notify/note_snippet_email.text.erb
index 413d9e6e9ac..413d9e6e9ac 100644
--- a/app/views/notify/note_personal_snippet_email.text.erb
+++ b/app/views/notify/note_snippet_email.text.erb
diff --git a/app/views/notify/pipeline_failed_email.text.erb b/app/views/notify/pipeline_failed_email.text.erb
index 9cd479ef1e6..41b26842dbc 100644
--- a/app/views/notify/pipeline_failed_email.text.erb
+++ b/app/views/notify/pipeline_failed_email.text.erb
@@ -35,7 +35,7 @@ had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>.
Stage: <%= build.stage %>
Name: <%= build.name %>
<% if build.has_trace? -%>
-Trace: <%= build.trace.raw(last_lines: 10) %>
+Trace: <%= build.trace.raw(last_lines: 30) %>
<% end -%>
<% end -%>
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index 93acd6f550b..12d42ce9892 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -69,6 +69,15 @@
= f.check_box :show_whitespace_in_diffs, class: 'form-check-input'
= f.label :show_whitespace_in_diffs, class: 'form-check-label' do
= s_('Preferences|Show whitespace changes in diffs')
+ .form-group
+ = f.label :tab_width, s_('Preferences|Tab width'), class: 'label-bold'
+ = f.number_field :tab_width,
+ class: 'form-control',
+ min: Gitlab::TabWidth::MIN,
+ max: Gitlab::TabWidth::MAX,
+ required: true
+ .form-text.text-muted
+ = s_('Preferences|Must be a number between %{min} and %{max}') % { min: Gitlab::TabWidth::MIN, max: Gitlab::TabWidth::MAX }
.col-sm-12
%hr
diff --git a/app/views/profiles/preferences/update.js.erb b/app/views/profiles/preferences/update.js.erb
index 8966dd3fd86..8397acbf1b3 100644
--- a/app/views/profiles/preferences/update.js.erb
+++ b/app/views/profiles/preferences/update.js.erb
@@ -12,5 +12,9 @@ if ('<%= current_user.layout %>' === 'fluid') {
// Re-enable the "Save" button
$('input[type=submit]').enable()
-// Show the notice flash message
-new Flash('<%= flash.discard(:notice) %>', 'notice')
+// Show flash messages
+<% if flash.notice %>
+ new Flash('<%= flash.discard(:notice) %>', 'notice')
+<% elsif flash.alert %>
+ new Flash('<%= flash.discard(:alert) %>', 'alert')
+<% end %>
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index daedc52f298..d9887cb470a 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -10,7 +10,7 @@
= project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s64', width: 64, height: 64)
.d-flex.flex-column.flex-wrap.align-items-baseline
.d-inline-flex.align-items-baseline
- %h1.home-panel-title.prepend-top-8.append-bottom-5.qa-project-name
+ %h1.home-panel-title.prepend-top-8.append-bottom-5{ data: { qa_selector: 'project_name_content' } }
= @project.name
%span.visibility-icon.text-secondary.prepend-left-4.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
= visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'})
@@ -49,13 +49,6 @@
= render 'projects/buttons/star'
= render 'projects/buttons/fork'
- - if can?(current_user, :download_code, @project)
- .project-clone-holder.d-inline-flex.d-md-none.btn-block
- = render "shared/mobile_clone_panel"
-
- .project-clone-holder.d-none.d-md-inline-flex
- = render "projects/buttons/clone"
-
- if can?(current_user, :download_code, @project)
%nav.project-stats
.nav-links.quick-links
@@ -77,7 +70,7 @@
- source = visible_fork_source(@project)
- if source
#{ s_('ForkedFromProjectPath|Forked from') }
- = link_to source.full_name, project_path(source)
+ = link_to source.full_name, project_path(source), data: { qa_selector: 'forked_from_link' }
- else
= s_('ForkedFromProjectPath|Forked from an inaccessible project')
diff --git a/app/views/projects/_zen.html.haml b/app/views/projects/_zen.html.haml
index c502b392384..744aef3cad4 100644
--- a/app/views/projects/_zen.html.haml
+++ b/app/views/projects/_zen.html.haml
@@ -2,6 +2,7 @@
- current_text ||= nil
- supports_autocomplete = local_assigns.fetch(:supports_autocomplete, true)
- supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false)
+- qa_selector = local_assigns.fetch(:qa_selector, '')
.zen-backdrop
- classes << ' js-gfm-input js-autosize markdown-area'
- if defined?(f) && f
@@ -10,7 +11,8 @@
placeholder: placeholder,
dir: 'auto',
data: { supports_quick_actions: supports_quick_actions,
- supports_autocomplete: supports_autocomplete }
+ supports_autocomplete: supports_autocomplete,
+ qa_selector: qa_selector }
- else
= text_area_tag attr, current_text, class: classes, placeholder: placeholder
%a.zen-control.zen-control-leave.js-zen-leave{ href: "#" }
diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml
index cf273aab108..91d1fc06a41 100644
--- a/app/views/projects/blob/_blob.html.haml
+++ b/app/views/projects/blob/_blob.html.haml
@@ -9,6 +9,8 @@
= render "projects/blob/auxiliary_viewer", blob: blob
#blob-content-holder.blob-content-holder
+ - if native_code_navigation_enabled?(@project)
+ #js-code-navigation{ data: { commit_id: blob.commit_id, blob_path: blob.path, project_path: @project.full_path } }
%article.file-holder
= render 'projects/blob/header', blob: blob
= render 'projects/blob/content', blob: blob
diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml
index 77245114772..76a9d3df5d7 100644
--- a/app/views/projects/blob/_header.html.haml
+++ b/app/views/projects/blob/_header.html.haml
@@ -2,18 +2,16 @@
.js-file-title.file-title-flex-parent
= render 'projects/blob/header_content', blob: blob
- .file-actions
+ .file-actions<
= render 'projects/blob/viewer_switcher', blob: blob unless blame
-
- .btn-group{ role: "group" }<
- = edit_blob_button
- = ide_edit_button
- .btn-group{ role: "group" }<
+ = edit_blob_button
+ = ide_edit_button
+ .btn-group.ml-2{ role: "group" }>
= render_if_exists 'projects/blob/header_file_locks_link'
- if current_user
= replace_blob_link
= delete_blob_link
- .btn-group{ role: "group" }<
+ .btn-group.ml-2{ role: "group" }
= copy_blob_source_button(blob) unless blame
= open_raw_blob_button(blob)
= download_blob_button(blob)
diff --git a/app/views/projects/blob/_viewer_switcher.html.haml b/app/views/projects/blob/_viewer_switcher.html.haml
index 6a521069418..5e0d70b2ca9 100644
--- a/app/views/projects/blob/_viewer_switcher.html.haml
+++ b/app/views/projects/blob/_viewer_switcher.html.haml
@@ -2,7 +2,7 @@
- simple_viewer = blob.simple_viewer
- rich_viewer = blob.rich_viewer
- .btn-group.js-blob-viewer-switcher{ role: "group" }
+ .btn-group.js-blob-viewer-switcher.ml-2{ role: "group" }>
- simple_label = "Display #{simple_viewer.switcher_title}"
%button.btn.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => simple_label, title: simple_label, data: { viewer: 'simple', container: 'body' } }>
= icon(simple_viewer.switcher_icon)
diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml
index ed22573b23e..b12be8a91d6 100644
--- a/app/views/projects/buttons/_clone.html.haml
+++ b/app/views/projects/buttons/_clone.html.haml
@@ -1,11 +1,12 @@
- project = project || @project
+- dropdown_class = local_assigns.fetch(:dropdown_class, '')
-.git-clone-holder.js-git-clone-holder.input-group
- %a#clone-dropdown.input-group-text.btn.btn-primary.btn-xs.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } }
+.git-clone-holder.js-git-clone-holder
+ %a#clone-dropdown.btn.btn-primary.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } }
%span.append-right-4.js-clone-dropdown-label
= _('Clone')
- = sprite_icon("arrow-down", css_class: "icon")
- %ul.p-3.dropdown-menu.dropdown-menu-right.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown.qa-clone-options
+ = sprite_icon("chevron-down", css_class: "icon")
+ %ul.p-3.dropdown-menu.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown.qa-clone-options{ class: dropdown_class }
- if ssh_enabled?
%li
%label.label-bold
diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml
index e8aff58b505..cae8bbf8c01 100644
--- a/app/views/projects/buttons/_download.html.haml
+++ b/app/views/projects/buttons/_download.html.haml
@@ -6,7 +6,7 @@
%button.btn.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download'), 'data-display' => 'static', data: { qa_selector: 'download_source_code_button' } }
= sprite_icon('download')
%span.sr-only= _('Select Archive Format')
- = sprite_icon("arrow-down")
+ = sprite_icon("chevron-down")
.dropdown-menu.dropdown-menu-right{ role: 'menu' }
%section
%h5.m-0.dropdown-bold-header= _('Download source code')
diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml
index f1a7528065a..33465953086 100644
--- a/app/views/projects/buttons/_dropdown.html.haml
+++ b/app/views/projects/buttons/_dropdown.html.haml
@@ -1,5 +1,5 @@
- can_create_issue = show_new_issue_link?(@project)
-- can_create_project_snippet = can?(current_user, :create_project_snippet, @project)
+- can_create_project_snippet = can?(current_user, :create_snippet, @project)
- can_push_code = can?(current_user, :push_code, @project)
- create_mr_from_new_fork = can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project)
- merge_project = merge_request_source_project_for_project(@project)
diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml
index f4560404c03..18bdbd42d0d 100644
--- a/app/views/projects/ci/builds/_build.html.haml
+++ b/app/views/projects/ci/builds/_build.html.haml
@@ -46,7 +46,7 @@
- if job.try(:trigger_request)
%span.badge.badge-info= _('triggered')
- if job.try(:allow_failure)
- %span.badge.badge-danger= _('allowed to fail')
+ %span.badge.badge-warning= _('allowed to fail')
- if job.schedulable?
%span.badge.badge-info= s_('DelayedJobs|delayed')
- elsif job.action?
diff --git a/app/views/projects/commit/_signature.html.haml b/app/views/projects/commit/_signature.html.haml
index 145bc629380..aa7c90bad66 100644
--- a/app/views/projects/commit/_signature.html.haml
+++ b/app/views/projects/commit/_signature.html.haml
@@ -1,2 +1,3 @@
- if signature
- = render partial: "projects/commit/#{signature.verification_status}_signature_badge", locals: { signature: signature }
+ - uri = "projects/commit/#{"x509/" if signature.instance_of?(X509CommitSignature)}"
+ = render partial: "#{uri}#{signature.verification_status}_signature_badge", locals: { signature: signature }
diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml
index cbd998c60ef..8ecaa1329fd 100644
--- a/app/views/projects/commit/_signature_badge.html.haml
+++ b/app/views/projects/commit/_signature_badge.html.haml
@@ -17,12 +17,18 @@
- content = capture do
- if show_user
.clearfix
- = render partial: 'projects/commit/signature_badge_user', locals: { signature: signature }
+ - uri_signature_badge_user = "projects/commit/#{"x509/" if signature.instance_of?(X509CommitSignature)}signature_badge_user"
+ = render partial: "#{uri_signature_badge_user}", locals: { signature: signature }
- = _('GPG Key ID:')
- %span.monospace= signature.gpg_key_primary_keyid
+ - if signature.instance_of?(X509CommitSignature)
+ = render partial: "projects/commit/x509/certificate_details", locals: { signature: signature }
- = link_to(_('Learn more about signing commits'), help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link')
+ = link_to(_('Learn more about x509 signed commits'), help_page_path('user/project/repository/x509_signed_commits/index.md'), class: 'gpg-popover-help-link')
+ - else
+ = _('GPG Key ID:')
+ %span.monospace= signature.gpg_key_primary_keyid
-%button{ tabindex: 0, class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } }
+ = link_to(_('Learn more about signing commits'), help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link')
+
+%a{ role: 'button', tabindex: 0, class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } }
= label
diff --git a/app/views/projects/commit/x509/_certificate_details.html.haml b/app/views/projects/commit/x509/_certificate_details.html.haml
new file mode 100644
index 00000000000..2357c6d803b
--- /dev/null
+++ b/app/views/projects/commit/x509/_certificate_details.html.haml
@@ -0,0 +1,17 @@
+.gpg-popover-certificate-details
+ %strong= _('Certificate Subject')
+ %ul
+ - signature.x509_certificate.subject.split(",").each do |i|
+ - if i.start_with?("CN", "O")
+ %li= i
+ %li= _('Subject Key Identifier:')
+ %li.unstyled= signature.x509_certificate.subject_key_identifier.gsub(":", " ")
+
+.gpg-popover-certificate-details
+ %strong= _('Certificate Issuer')
+ %ul
+ - signature.x509_certificate.x509_issuer.subject.split(",").each do |i|
+ - if i.start_with?("CN", "OU", "O")
+ %li= i
+ %li= _('Subject Key Identifier:')
+ %li.unstyled= signature.x509_certificate.x509_issuer.subject_key_identifier.gsub(":", " ")
diff --git a/app/views/projects/commit/x509/_signature_badge_user.html.haml b/app/views/projects/commit/x509/_signature_badge_user.html.haml
new file mode 100644
index 00000000000..b64ccba2a18
--- /dev/null
+++ b/app/views/projects/commit/x509/_signature_badge_user.html.haml
@@ -0,0 +1,19 @@
+- user = signature.commit.committer
+- user_email = signature.x509_certificate.email
+
+- if user
+ = link_to user_path(user), class: 'gpg-popover-user-link' do
+ %div
+ = user_avatar_without_link(user: user, size: 32)
+
+ %div
+ %strong= user.name
+ %div= user.to_reference
+
+- else
+ = mail_to user_email do
+ %div
+ = user_avatar_without_link(user_email: user_email, size: 32)
+
+ %div
+ %strong= user_email
diff --git a/app/views/projects/commit/x509/_unverified_signature_badge.html.haml b/app/views/projects/commit/x509/_unverified_signature_badge.html.haml
new file mode 100644
index 00000000000..680cc32c7e6
--- /dev/null
+++ b/app/views/projects/commit/x509/_unverified_signature_badge.html.haml
@@ -0,0 +1,6 @@
+- title = capture do
+ = _('This commit was signed with an <strong>unverified</strong> signature.').html_safe
+
+- locals = { signature: signature, title: title, label: _('Unverified'), css_class: 'invalid', icon: 'status_notfound_borderless', show_user: true }
+
+= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/x509/_verified_signature_badge.html.haml b/app/views/projects/commit/x509/_verified_signature_badge.html.haml
new file mode 100644
index 00000000000..4964b1b8ee7
--- /dev/null
+++ b/app/views/projects/commit/x509/_verified_signature_badge.html.haml
@@ -0,0 +1,6 @@
+- title = capture do
+ = _('This commit was signed with a <strong>verified</strong> signature and the committer email is verified to belong to the same user.').html_safe
+
+- locals = { signature: signature, title: title, label: _('Verified'), css_class: 'valid', icon: 'status_success_borderless', show_user: true }
+
+= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml
index c8c96297672..f5a4889b4bb 100644
--- a/app/views/projects/compare/_form.html.haml
+++ b/app/views/projects/compare/_form.html.haml
@@ -10,7 +10,7 @@
= hidden_field_tag :to, params[:to]
= button_tag type: 'button', title: params[:to], class: "btn form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do
.dropdown-toggle-text.str-truncated.monospace.float-left= params[:to] || _("Select branch/tag")
- = sprite_icon('arrow-down', size: 16, css_class: 'float-right')
+ = sprite_icon('chevron-down', size: 16, css_class: 'float-right')
= render 'shared/ref_dropdown'
.compare-ellipsis.inline ...
.form-group.dropdown.compare-form-group.from.js-compare-from-dropdown
@@ -21,7 +21,7 @@
= hidden_field_tag :from, params[:from]
= button_tag type: 'button', title: params[:from], class: "btn form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do
.dropdown-toggle-text.str-truncated.monospace.float-left= params[:from] || _("Select branch/tag")
- = sprite_icon('arrow-down', size: 16, css_class: 'float-right')
+ = sprite_icon('chevron-down', size: 16, css_class: 'float-right')
= render 'shared/ref_dropdown'
&nbsp;
= button_tag s_("CompareBranches|Compare"), class: "btn btn-success commits-compare-btn"
diff --git a/app/views/projects/cycle_analytics/_overview.html.haml b/app/views/projects/cycle_analytics/_overview.html.haml
index ea94b637f89..2ca72b141be 100644
--- a/app/views/projects/cycle_analytics/_overview.html.haml
+++ b/app/views/projects/cycle_analytics/_overview.html.haml
@@ -4,12 +4,12 @@
.col-md-10.offset-md-1
.row.overview-details
.col-md-6.overview-text
- %h4 Introducing Cycle Analytics
+ %h4 Introducing Value Stream Analytics
%p
- 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.
+ Value Stream Analytics (VSA) gives an overview of how much time it takes to go from idea to production in your project.
+ To set up VSA, 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/analytics/cycle_analytics.md'), target: '_blank' } Read more
+ %a.btn{ href: help_page_path('user/analytics/value_stream_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 8bbe4e66c50..b0d9dfb0d37 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -1,11 +1,11 @@
-- page_title "Cycle Analytics"
+- page_title "Value Stream Analytics"
#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/analytics/cycle_analytics.md'),
+ "documentation-link": help_page_path('user/analytics/value_stream_analytics.md'),
"v-on:dismiss-overview-dialog" => "dismissOverviewDialog()" }
- = icon("spinner spin", "v-show" => "isLoading")
+ %gl-loading-icon{ "v-show" => "isLoading", "size" => "lg" }
.wrapper{ "v-show" => "!isLoading && !hasError" }
.card
.card-header
@@ -57,8 +57,7 @@
%ul
%stage-nav-item{ "v-for" => "stage in state.stages", ":key" => '`ca-stage-title-${stage.title}`', '@select' => 'selectStage(stage)', ":title" => "stage.title", ":is-user-allowed" => "stage.isUserAllowed", ":value" => "stage.value", ":is-active" => "stage.active" }
.section.stage-events
- %template{ "v-if" => "isLoadingStage" }
- = icon("spinner spin")
+ %gl-loading-icon{ "v-show" => "isLoadingStage", "size" => "lg" }
%template{ "v-if" => "currentStage && !currentStage.isUserAllowed" }
= render partial: "no_access"
%template{ "v-else" => true }
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index a9b6b397968..9e06358beba 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -11,9 +11,14 @@
- if @project.can_current_user_push_code?
%p.append-bottom-0
- = _('You can create files directly in GitLab using one of the following options.')
+ = _('You can get started by cloning the repository or start adding files to it with one of the following options.')
.project-buttons.qa-quick-actions
+ .project-clone-holder.d-block.d-md-none.mt-2.mr-2
+ = render "shared/mobile_clone_panel"
+
+ .project-clone-holder.d-none.d-md-inline-block.mt-2.mr-2.float-left
+ = render "projects/buttons/clone"
= render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons
- if can?(current_user, :push_code, @project)
diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml
index 93a43b5d1ea..b38449b3ab9 100644
--- a/app/views/projects/graphs/charts.html.haml
+++ b/app/views/projects/graphs/charts.html.haml
@@ -7,20 +7,7 @@
%p
= _("Measured in bytes of code. Excludes generated and vendored code.")
- .row
- .col-md-4
- %ul.bordered-list
- - @languages.each do |language|
- %li
- %span{ style: "color: #{language[:color]}" }
- = icon('circle')
- &nbsp;
- = language[:label]
- .float-right
- = language[:value]
- \%
- .col-md-8
- %canvas#languages-chart{ height: 400 }
+ #js-languages-chart{ data: { chart_data: @languages.to_json.html_safe } }
.repo-charts
.sub-header-block.border-top
@@ -60,27 +47,18 @@
%p.slead
= _("Commits per day of month")
%div
- %canvas#month-chart
+ #js-month-chart{ data: { chart_data: @commits_per_month.to_json.html_safe } }
.row
.col-md-6
.col-md-6
%p.slead
= _("Commits per weekday")
%div
- %canvas#weekday-chart
+ #js-weekday-chart{ data: { chart_data: @commits_per_week_days.to_json.html_safe } }
.row
.col-md-6
.col-md-6
%p.slead
= _("Commits per day hour (UTC)")
%div
- %canvas#hour-chart
-
--# haml-lint:disable InlineJavaScript
-%script#projectChartData{ type: "application/json" }
- - projectChartData = {};
- - projectChartData['hour'] = @commits_per_time
- - projectChartData['weekDays'] = @commits_per_week_days
- - projectChartData['month'] = @commits_per_month
- - projectChartData['languages'] = @languages
- = projectChartData.to_json.html_safe
+ #js-hour-chart{ data: { chart_data: @commits_per_time.to_json.html_safe } }
diff --git a/app/views/projects/hook_logs/_index.html.haml b/app/views/projects/hook_logs/_index.html.haml
index ada986dd969..f3cea6bea68 100644
--- a/app/views/projects/hook_logs/_index.html.haml
+++ b/app/views/projects/hook_logs/_index.html.haml
@@ -1,4 +1,4 @@
-.row.prepend-top-default.append-bottom-default
+.row.prepend-top-32.append-bottom-default
.col-lg-3
%h4.prepend-top-0
Recent Deliveries
diff --git a/app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml b/app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml
index aff3fb82fa6..3ca82adccf1 100644
--- a/app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml
+++ b/app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml
@@ -8,6 +8,6 @@
%button.btn.btn-sm{ "@click" => "cancelDiscardConfirmation(file)" } Cancel
.editor-wrap{ ":class" => "classObject" }
.loading
- %i.fa.fa-spinner.fa-spin
+ .spinner.spinner-md
.editor
%pre{ "style" => "height: 350px" }
diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml
index f48390aa046..d933675eac5 100644
--- a/app/views/projects/merge_requests/conflicts/show.html.haml
+++ b/app/views/projects/merge_requests/conflicts/show.html.haml
@@ -11,7 +11,7 @@
#conflicts{ "v-cloak" => "true", data: { conflicts_path: conflicts_project_merge_request_path(@merge_request.project, @merge_request, format: :json),
resolve_conflicts_path: resolve_conflicts_project_merge_request_path(@merge_request.project, @merge_request) } }
.loading{ "v-if" => "isLoading" }
- %i.fa.fa-spinner.fa-spin
+ .spinner.spinner-md
.nothing-here-block{ "v-if" => "hasError" }
{{conflictsData.errorMessage}}
diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml
index c6615b26bc0..99537ba8152 100644
--- a/app/views/projects/merge_requests/creations/_new_compare.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml
@@ -30,7 +30,8 @@
= dropdown_content
= dropdown_loading
.card-footer
- .text-center= icon('spinner spin', class: 'js-source-loading')
+ .text-center
+ .js-source-loading.mt-1.spinner.spinner-sm
%ul.list-unstyled.mr_source_commit
.col-lg-6
@@ -58,7 +59,8 @@
= dropdown_content
= dropdown_loading
.card-footer
- .text-center= icon('spinner spin', class: "js-target-loading")
+ .text-center
+ .js-target-loading.mt-1.spinner.spinner-sm
%ul.list-unstyled.mr_target_commit
- if @merge_request.errors.any?
diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml
index 15c83f92474..0fb4d9ae70f 100644
--- a/app/views/projects/merge_requests/creations/_new_submit.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml
@@ -47,4 +47,5 @@
= render 'projects/merge_requests/pipelines', endpoint: url_for(safe_params.merge(action: 'pipelines', format: :json)), disable_initialization: true
.mr-loading-status
- = spinner
+ .loading.hide
+ .spinner.spinner-md
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 310cd355d22..d65c874f245 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -88,7 +88,8 @@
show_whitespace_default: @show_whitespace_default.to_s }
.mr-loading-status
- = spinner
+ .loading.hide
+ .spinner.spinner-md
= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees
diff --git a/app/views/projects/network/show.json.erb b/app/views/projects/network/show.json.erb
index a0e82e891ff..a146d137c55 100644
--- a/app/views/projects/network/show.json.erb
+++ b/app/views/projects/network/show.json.erb
@@ -1,4 +1,4 @@
-<% self.formats = ["html"] %>
+<% self.formats = [:html] %>
<%= raw(
{
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index ce6ae765de9..85902d51ab0 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -69,4 +69,11 @@
.icon-container
= sprite_icon("git-merge")
%span.related-merge-requests
- = @pipeline.all_related_merge_request_text
+ %span.js-truncated-mr-list
+ = @pipeline.all_related_merge_request_text(limit: 1)
+ - if @pipeline.has_many_merge_requests?
+ = link_to("#", class: "js-toggle-mr-list") do
+ %span.text-expander
+ = sprite_icon('ellipsis_h', size: 12)
+ %span.js-full-mr-list.hide
+ = @pipeline.all_related_merge_request_text
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index 4d8cba5168d..cdd75d43a78 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -18,7 +18,7 @@
%li.js-tests-tab-link
= link_to test_report_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-tests', action: 'test_report', toggle: 'tab' }, class: 'test-tab' do
= s_('TestReports|Tests')
- %span.badge.badge-pill= pipeline.test_reports.total_count
+ %span.badge.badge-pill.js-test-report-badge-counter
= render_if_exists "projects/pipelines/tabs_holder", pipeline: @pipeline, project: @project
.tab-content
diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml
index c9a50b97fea..7496ca97d56 100644
--- a/app/views/projects/pipelines/charts.html.haml
+++ b/app/views/projects/pipelines/charts.html.haml
@@ -1,6 +1,7 @@
- page_title _('CI / CD Charts')
-#charts.ci-charts
- = render 'projects/pipelines/charts/overall'
- %hr
- = render 'projects/pipelines/charts/pipelines'
+#js-project-pipelines-charts-app{ data: { counts: @counts, success_ratio: success_ratio(@counts),
+ times_chart: { labels: @charts[:pipeline_times].labels, values: @charts[:pipeline_times].pipeline_times },
+ last_week_chart: { labels: @charts[:week].labels, totals: @charts[:week].total, success: @charts[:week].success },
+ last_month_chart: { labels: @charts[:month].labels, totals: @charts[:month].total, success: @charts[:month].success },
+ last_year_chart: { labels: @charts[:year].labels, totals: @charts[:year].total, success: @charts[:year].success } } }
diff --git a/app/views/projects/pipelines/charts/_overall.haml b/app/views/projects/pipelines/charts/_overall.haml
deleted file mode 100644
index 651f9217455..00000000000
--- a/app/views/projects/pipelines/charts/_overall.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-%h4.mt-4.mb-4= s_("PipelineCharts|Overall statistics")
-.row
- .col-md-6
- = render 'projects/pipelines/charts/pipeline_statistics'
- .col-md-6
- = render 'projects/pipelines/charts/pipeline_times'
diff --git a/app/views/projects/pipelines/charts/_pipeline_statistics.haml b/app/views/projects/pipelines/charts/_pipeline_statistics.haml
deleted file mode 100644
index b323e290ed4..00000000000
--- a/app/views/projects/pipelines/charts/_pipeline_statistics.haml
+++ /dev/null
@@ -1,14 +0,0 @@
-%ul
- %li
- = s_("PipelineCharts|Total:")
- %strong= n_("1 pipeline", "%d pipelines", @counts[:total]) % @counts[:total]
- %li
- = s_("PipelineCharts|Successful:")
- %strong= n_("1 pipeline", "%d pipelines", @counts[:success]) % @counts[:success]
- %li
- = s_("PipelineCharts|Failed:")
- %strong= n_("1 pipeline", "%d pipelines", @counts[:failed]) % @counts[:failed]
- %li
- = s_("PipelineCharts|Success ratio:")
- %strong
- #{success_ratio(@counts)}%
diff --git a/app/views/projects/pipelines/charts/_pipeline_times.haml b/app/views/projects/pipelines/charts/_pipeline_times.haml
deleted file mode 100644
index c0ac79ed5f8..00000000000
--- a/app/views/projects/pipelines/charts/_pipeline_times.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-%p.light
- = _("Commit duration in minutes for last 30 commits")
-
-%div
- %canvas#build_timesChart{ height: 200 }
-
--# haml-lint:disable InlineJavaScript
-%script#pipelinesTimesChartsData{ type: "application/json" }= { :labels => @charts[:pipeline_times].labels, :values => @charts[:pipeline_times].pipeline_times }.to_json.html_safe
diff --git a/app/views/projects/pipelines/charts/_pipelines.haml b/app/views/projects/pipelines/charts/_pipelines.haml
deleted file mode 100644
index afff9e82e45..00000000000
--- a/app/views/projects/pipelines/charts/_pipelines.haml
+++ /dev/null
@@ -1,37 +0,0 @@
-%h4.mt-4.mb-4= _("Pipelines charts")
-%p
- &nbsp;
- %span.legend-success
- = icon("circle")
- = s_("Pipeline|success")
- &nbsp;
- %span.legend-all
- = icon("circle")
- = s_("Pipeline|all")
-
-.prepend-top-default
- %p.light
- = _("Pipelines for last week")
- (#{date_from_to(Date.today - 7.days, Date.today)})
- %div
- %canvas#weekChart{ height: 200 }
-
-.prepend-top-default
- %p.light
- = _("Pipelines for last month")
- (#{date_from_to(Date.today - 30.days, Date.today)})
- %div
- %canvas#monthChart{ height: 200 }
-
-.prepend-top-default
- %p.light
- = _("Pipelines for last year")
- %div
- %canvas#yearChart.padded{ height: 250 }
-
--# haml-lint:disable InlineJavaScript
-%script#pipelinesChartsData{ type: "application/json" }
- - chartData = []
- - [:week, :month, :year].each do |scope|
- - chartData.push({ 'scope' => scope, 'labels' => @charts[scope].labels, 'totalValues' => @charts[scope].total, 'successValues' => @charts[scope].success })
- = chartData.to_json.html_safe
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index f0b3ab24ea0..f39968eecef 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -21,4 +21,5 @@
= render "projects/pipelines/with_tabs", pipeline: @pipeline
.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json),
- test_report_endpoint: test_report_project_pipeline_path(@project, @pipeline, format: :json) } }
+ test_report_endpoint: test_report_project_pipeline_path(@project, @pipeline, format: :json),
+ test_reports_count_endpoint: test_reports_count_project_pipeline_path(@project, @pipeline, format: :json) } }
diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index b2e160e37bc..6ff7c27b1bc 100644
--- a/app/views/projects/registry/repositories/index.html.haml
+++ b/app/views/projects/registry/repositories/index.html.haml
@@ -3,12 +3,24 @@
%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 } }
+ - if Feature.enabled?(:vue_container_registry_explorer)
+ #js-container-registry{ data: { endpoint: project_container_registry_index_path(@project),
+ project_path: @project.full_path,
+ "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 } }
+ - else
+ #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/show.html.haml b/app/views/projects/releases/show.html.haml
new file mode 100644
index 00000000000..188262fb34c
--- /dev/null
+++ b/app/views/projects/releases/show.html.haml
@@ -0,0 +1,4 @@
+- add_to_breadcrumbs _("Releases"), project_releases_path(@project)
+- page_title @release.name
+
+#js-show-release-page{ data: { project_id: @project.id, tag_name: @release.tag } }
diff --git a/app/views/projects/services/alerts/_help.html.haml b/app/views/projects/services/alerts/_help.html.haml
new file mode 100644
index 00000000000..be910203125
--- /dev/null
+++ b/app/views/projects/services/alerts/_help.html.haml
@@ -0,0 +1,3 @@
+.js-alerts-service-settings{ data: { activated: @service.activated?.to_s,
+ form_path: project_service_path(@project, @service.to_param),
+ authorization_key: @service.token, url: @service.url, learn_more_url: 'https://docs.gitlab.com/ee/user/project/integrations/generic_alerts.html' } }
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index a65afeecc17..1358077f2b2 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -62,12 +62,12 @@
.settings-content
= render 'projects/triggers/index'
-- if Feature.enabled?(:registry_retention_policies_settings, @project)
+- if settings_container_registry_expiration_policy_available?(@project)
%section.settings.no-animate#js-registry-policies{ class: ('expanded' if expanded) }
.settings-header
%h4
= _("Container Registry tag expiration policy")
- = link_to icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'retention-and-expiration-policy'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'expiration-policy'), target: '_blank', rel: 'noopener noreferrer'
%button.btn.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
diff --git a/app/views/projects/settings/operations/_incidents.html.haml b/app/views/projects/settings/operations/_incidents.html.haml
new file mode 100644
index 00000000000..756d4042613
--- /dev/null
+++ b/app/views/projects/settings/operations/_incidents.html.haml
@@ -0,0 +1,32 @@
+- templates = []
+- setting = project_incident_management_setting
+- templates = setting.available_issue_templates.map { |t| [t.name, t.key] }
+
+%section.settings.no-animate.qa-incident-management-settings
+ .settings-header
+ %h4= _('Incidents')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = _('Expand')
+ %p
+ = _('Action to take when receiving an alert.')
+ = link_to help_page_path('user/project/integrations/prometheus', anchor: 'taking-action-on-an-alert-ultimate') do
+ = _('More information')
+ .settings-content
+ = form_for @project, url: project_settings_operations_path(@project), method: :patch do |f|
+ = form_errors(@project.incident_management_setting)
+ .form-group
+ = f.fields_for :incident_management_setting_attributes, setting do |form|
+ .form-group
+ = form.check_box :create_issue
+ = form.label :create_issue, _('Create an issue. Issues are created for each alert triggered.'), class: 'form-check-label'
+ .form-group.col-sm-8
+ = form.label :issue_template_key, class: 'label-bold' do
+ = _('Issue template (optional)')
+ = link_to icon('question-circle'), help_page_path('user/project/description_templates', anchor: 'creating-issue-templates'), target: '_blank', rel: 'noopener noreferrer'
+ .select-wrapper
+ = form.select :issue_template_key, templates, {include_blank: 'No template selected'}, class: "form-control select-control"
+ = icon('chevron-down')
+ .form-group
+ = form.check_box :send_email
+ = form.label :send_email, _('Send a separate email notification to Developers.'), class: 'form-check-label'
+ = f.submit _('Save changes'), class: 'btn btn-success'
diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml
index 3c955e5f558..30b914b5199 100644
--- a/app/views/projects/settings/operations/show.html.haml
+++ b/app/views/projects/settings/operations/show.html.haml
@@ -2,7 +2,7 @@
- page_title _('Operations Settings')
- breadcrumb_title _('Operations Settings')
-= render_if_exists 'projects/settings/operations/incidents'
+= render 'projects/settings/operations/incidents'
= render 'projects/settings/operations/error_tracking'
= render 'projects/settings/operations/external_dashboard'
= render 'projects/settings/operations/grafana_integration'
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index 8f13806e8cd..17bc10af58a 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -20,6 +20,7 @@
= render "archived_notice", project: @project
= render_if_exists "projects/marked_for_deletion_notice", project: @project
+ = render_if_exists "projects/ancestor_group_marked_for_deletion_notice", project: @project
- view_path = @project.default_view
diff --git a/app/views/projects/snippets/_actions.html.haml b/app/views/projects/snippets/_actions.html.haml
index 29bad50579c..41c9bac0102 100644
--- a/app/views/projects/snippets/_actions.html.haml
+++ b/app/views/projects/snippets/_actions.html.haml
@@ -1,33 +1,33 @@
- return unless current_user
.d-none.d-sm-block
- - if can?(current_user, :update_project_snippet, @snippet)
+ - if can?(current_user, :update_snippet, @snippet)
= link_to edit_project_snippet_path(@project, @snippet), class: "btn btn-grouped" do
= _('Edit')
- - if can?(current_user, :admin_project_snippet, @snippet)
+ - if can?(current_user, :admin_snippet, @snippet)
= link_to project_snippet_path(@project, @snippet), method: :delete, data: { confirm: _("Are you sure?") }, class: "btn btn-grouped btn-inverted btn-remove", title: _('Delete Snippet') do
= _('Delete')
- - if can?(current_user, :create_project_snippet, @project)
+ - if can?(current_user, :create_snippet, @project)
= link_to new_project_snippet_path(@project), class: 'btn btn-grouped btn-inverted btn-success', title: _("New snippet") do
= _('New snippet')
- if @snippet.submittable_as_spam_by?(current_user)
= link_to _('Submit as spam'), mark_as_spam_project_snippet_path(@project, @snippet), method: :post, class: 'btn btn-grouped btn-spam', title: _('Submit as spam')
-- if can?(current_user, :create_project_snippet, @project) || can?(current_user, :update_project_snippet, @snippet)
+- if can?(current_user, :create_snippet, @project) || can?(current_user, :update_snippet, @snippet)
.d-block.d-sm-none.dropdown
%button.btn.btn-default.btn-block.append-bottom-0.prepend-top-5{ data: { toggle: "dropdown" } }
= _('Options')
= icon('caret-down')
.dropdown-menu.dropdown-menu-full-width
%ul
- - if can?(current_user, :create_project_snippet, @project)
+ - if can?(current_user, :create_snippet, @project)
%li
= link_to new_project_snippet_path(@project), title: _("New snippet") do
= _('New snippet')
- - if can?(current_user, :admin_project_snippet, @snippet)
+ - if can?(current_user, :admin_snippet, @snippet)
%li
= link_to project_snippet_path(@project, @snippet), method: :delete, data: { confirm: _("Are you sure?") }, title: _('Delete Snippet') do
= _('Delete')
- - if can?(current_user, :update_project_snippet, @snippet)
+ - if can?(current_user, :update_snippet, @snippet)
%li
= link_to edit_project_snippet_path(@project, @snippet) do
= _('Edit')
diff --git a/app/views/projects/snippets/edit.html.haml b/app/views/projects/snippets/edit.html.haml
index 6dbd67df886..9f5af1cfe1e 100644
--- a/app/views/projects/snippets/edit.html.haml
+++ b/app/views/projects/snippets/edit.html.haml
@@ -1,6 +1,7 @@
- add_to_breadcrumbs _("Snippets"), project_snippets_path(@project)
- breadcrumb_title @snippet.to_reference
- page_title _("Edit"), "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets")
+- @content_class = "limit-container-width" unless fluid_layout
%h3.page-title
= _("Edit Snippet")
diff --git a/app/views/projects/snippets/index.html.haml b/app/views/projects/snippets/index.html.haml
index 0ce18d83d57..a505b34f46c 100644
--- a/app/views/projects/snippets/index.html.haml
+++ b/app/views/projects/snippets/index.html.haml
@@ -1,15 +1,16 @@
- page_title _("Snippets")
+- new_project_snippet_link = new_project_snippet_path(@project) if can?(current_user, :create_snippet, @project)
- if @snippets.exists?
- if current_user
.top-area
- include_private = @project.team.member?(current_user) || current_user.admin?
- = render partial: 'snippets/snippets_scope_menu', locals: { subject: @project, include_private: include_private }
+ = render partial: 'snippets/snippets_scope_menu', locals: { subject: @project, include_private: include_private, counts: @snippet_counts }
- - if can?(current_user, :create_project_snippet, @project)
+ - if new_project_snippet_link.present?
.nav-controls
- = link_to _("New snippet"), new_project_snippet_path(@project), class: "btn btn-success", title: _("New snippet")
+ = link_to _("New snippet"), new_project_snippet_link, class: "btn btn-success", title: _("New snippet")
= render 'shared/snippets/list'
- else
- = render 'shared/empty_states/snippets', button_path: new_namespace_project_snippet_path(@project.namespace, @project)
+ = render 'shared/empty_states/snippets', button_path: new_project_snippet_link
diff --git a/app/views/projects/snippets/new.html.haml b/app/views/projects/snippets/new.html.haml
index d64e3a49a81..d55a1160d48 100644
--- a/app/views/projects/snippets/new.html.haml
+++ b/app/views/projects/snippets/new.html.haml
@@ -1,6 +1,7 @@
- add_to_breadcrumbs _("Snippets"), project_snippets_path(@project)
- breadcrumb_title _("New")
- page_title _("New Snippet")
+- @content_class = "limit-container-width" unless fluid_layout
%h3.page-title
= _("New Snippet")
diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml
index 768e4422206..422a467574b 100644
--- a/app/views/projects/snippets/show.html.haml
+++ b/app/views/projects/snippets/show.html.haml
@@ -12,7 +12,7 @@
%article.file-holder.snippet-file-content
= render 'shared/snippets/blob'
- .row-content-block.top-block.content-component-block
- = render 'award_emoji/awards_block', awardable: @snippet, inline: true
+.row-content-block.top-block.content-component-block
+ = render 'award_emoji/awards_block', awardable: @snippet, inline: true
- #notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => true
+#notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => true
diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml
index cb459b031fc..c65420d537b 100644
--- a/app/views/projects/tree/_tree_content.html.haml
+++ b/app/views/projects/tree/_tree_content.html.haml
@@ -1,6 +1,6 @@
.tree-content-holder.js-tree-content{ data: tree_content_data(@logs_path, @project, @path) }
.table-holder.bordered-box
- %table.table#tree-slider{ class: "table_#{@hex_path} tree-table qa-file-tree" }
+ %table.table#tree-slider{ class: "table_#{@hex_path} tree-table" }
%thead
%tr
%th= s_('ProjectFileTree|Name')
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index 2d987744dfd..4d3c24aee6b 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -25,7 +25,7 @@
%li.breadcrumb-item
%button.btn.add-to-tree.qa-add-to-tree{ addtotree_toggle_attributes, type: 'button' }
= sprite_icon('plus', size: 16, css_class: 'float-left')
- = sprite_icon('arrow-down', size: 16, css_class: 'float-left')
+ = sprite_icon('chevron-down', size: 16, css_class: 'float-left')
- if on_top_of_branch?
.add-to-tree-dropdown
%ul.dropdown-menu
@@ -75,7 +75,7 @@
= link_to new_project_tag_path(@project) do
#{ _('New tag') }
-.tree-controls<
+.tree-controls{ class: ("gl-font-size-0" if vue_file_list_enabled?) }<
= render_if_exists 'projects/tree/lock_link'
- if vue_file_list_enabled?
#js-tree-history-link.d-inline-block{ data: { history_link: project_commits_path(@project, @ref) } }
@@ -84,20 +84,25 @@
= render 'projects/find_file_link'
- - if can_create_mr_from_fork
- - if can_collaborate || current_user&.already_forked?(@project)
- - if vue_file_list_enabled?
- #js-tree-web-ide-link.d-inline-block
- - else
- = 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)
+ - if vue_file_list_enabled?
+ #js-tree-web-ide-link.d-inline-block
- else
- = link_to '#modal-confirm-fork', class: 'btn btn-default qa-web-ide-button', data: { target: '#modal-confirm-fork', toggle: 'modal'} do
+ = link_to ide_edit_path(@project, @ref, @path), class: 'btn btn-default qa-web-ide-button' do
= _('Web IDE')
- = render 'shared/confirm_fork_modal', fork_path: ide_fork_and_edit_path(@project, @ref, @path)
+ - elsif can_create_mr_from_fork
+ = 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<
= render "projects/buttons/xcode_link"
= render 'projects/buttons/download', project: @project, ref: @ref
+
+ .project-clone-holder.d-block.d-md-none.mt-sm-2.mt-md-0>
+ = render "shared/mobile_clone_panel"
+
+ .project-clone-holder.d-none.d-md-inline-block>
+ = render "projects/buttons/clone", dropdown_class: 'dropdown-menu-right'
diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml
index a153f527ee0..438d390389c 100644
--- a/app/views/projects/wikis/_form.html.haml
+++ b/app/views/projects/wikis/_form.html.haml
@@ -17,13 +17,19 @@
= icon('lightbulb-o')
- if @page.persisted?
= s_("WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title.")
- = link_to icon('question-circle'), help_page_path('user/project/wiki/index', anchor: 'moving-a-wiki-page'), target: '_blank'
+ = link_to icon('question-circle'), help_page_path('user/project/wiki/index', anchor: 'moving-a-wiki-page'),
+ target: '_blank', rel: 'noopener noreferrer'
- else
= s_("WikiNewPageTip|Tip: You can specify the full path for the new file. We will automatically create any missing directories.")
+ = succeed '.' do
+ = link_to _('Learn more'), help_page_path('user/project/wiki/index', anchor: 'creating-a-new-wiki-page'),
+ target: '_blank', rel: 'noopener noreferrer'
.form-group.row
.col-sm-12= f.label :format, class: 'control-label-full-width'
.col-sm-12
- = f.select :format, options_for_select(ProjectWiki::MARKUPS, {selected: @page.format}), {}, class: 'form-control'
+ .select-wrapper
+ = f.select :format, options_for_select(ProjectWiki::MARKUPS, {selected: @page.format}), {}, class: 'form-control select-control'
+ = icon('chevron-down')
.form-group.row
.col-sm-12= f.label :content, class: 'control-label-full-width'
diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml
index 629a5a045b1..8ada8c875f7 100644
--- a/app/views/search/_results.html.haml
+++ b/app/views/search/_results.html.haml
@@ -32,8 +32,7 @@
.term
= render 'shared/projects/list', projects: @search_objects, pipeline_status: false
- else
- - locals = { projects: blob_projects(@search_objects) } if %w[blobs wiki_blobs].include?(@scope)
- = render partial: "search/results/#{@scope.singularize}", collection: @search_objects, locals: locals
+ = render partial: "search/results/#{@scope.singularize}", collection: @search_objects
- if @scope != 'projects'
= paginate_collection(@search_objects)
diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml
index 4fb72b26955..6e17a25c713 100644
--- a/app/views/search/results/_blob.html.haml
+++ b/app/views/search/results/_blob.html.haml
@@ -1,7 +1,5 @@
-- project = find_project_for_result_blob(projects, blob)
+- project = blob.project
- return unless project
-
-- blob = parse_search_result(blob)
- blob_link = project_blob_path(project, tree_join(blob.ref, blob.path))
= render partial: 'search/results/blob_data', locals: { blob: blob, project: project, path: blob.path, blob_link: blob_link }
diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml
index 0b114bf67ee..5126351b0bb 100644
--- a/app/views/search/results/_snippet_blob.html.haml
+++ b/app/views/search/results/_snippet_blob.html.haml
@@ -3,17 +3,22 @@
- snippet_chunks = snippet_blob[:snippet_chunks]
- snippet_path = gitlab_snippet_path(snippet)
-.search-result-row
- %span
- = snippet.title
+.search-result-row.snippet-row
+ = image_tag avatar_icon_for_user(snippet.author), class: "avatar s40 d-none d-sm-block", alt: ''
+ .title
+ = link_to gitlab_snippet_path(snippet) do
+ = snippet.title
+ .snippet-info
+ = snippet.to_reference
+ &middot;
+ authored
+ = time_ago_with_tooltip(snippet.created_at)
by
= link_to user_snippets_path(snippet.author) do
- = image_tag avatar_icon_for_user(snippet.author), class: "avatar avatar-inline s16", alt: ''
= snippet.author_name
- %span.light= time_ago_with_tooltip(snippet.created_at)
- %h4.snippet-title
- .file-holder
- .js-file-title.file-title
+
+ .file-holder.my-2
+ .js-file-title.file-title-flex-parent
= link_to snippet_path do
%i.fa.fa-file
%strong= snippet.file_name
diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml
index 9afed2bbecc..3040917dd6e 100644
--- a/app/views/search/results/_wiki_blob.html.haml
+++ b/app/views/search/results/_wiki_blob.html.haml
@@ -1,5 +1,4 @@
-- project = find_project_for_result_blob(projects, wiki_blob)
-- wiki_blob = parse_search_result(wiki_blob)
+- project = wiki_blob.project
- wiki_blob_link = project_wiki_path(project, wiki_blob.basename)
= render partial: 'search/results/blob_data', locals: { blob: wiki_blob, project: project, path: wiki_blob.path, blob_link: wiki_blob_link }
diff --git a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
index 3670e19c240..d378e6cb22c 100644
--- a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
+++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
@@ -10,4 +10,4 @@
- unless Gitlab.config.registry.enabled
%div
= icon('exclamation-triangle')
- = _('Container registry is not enabled on this GitLab instance. Ask an administrator to enable it in order for AutoDevOps to work.')
+ = _('Container registry is not enabled on this GitLab instance. Ask an administrator to enable it in order for Auto DevOps to work.')
diff --git a/app/views/shared/_broadcast_message.html.haml b/app/views/shared/_broadcast_message.html.haml
new file mode 100644
index 00000000000..c058b210688
--- /dev/null
+++ b/app/views/shared/_broadcast_message.html.haml
@@ -0,0 +1,8 @@
+%div{ class: "broadcast-#{message.broadcast_type}-message #{opts[:preview] && 'preview'} js-broadcast-notification-#{message.id} d-flex",
+ style: broadcast_message_style(message), dir: 'auto' }
+ %div
+ = sprite_icon('bullhorn', size: 16, css_class: 'vertical-align-text-top')
+ = render_broadcast_message(message)
+ - if message.notification? && opts[:preview].blank?
+ %button.js-dismiss-current-broadcast-notification.btn.btn-link.text-dark.pl-2.pr-2{ 'aria-label' => _('Close'), :type => 'button', data: { id: message.id } }
+ %i.fa.fa-times
diff --git a/app/views/shared/_check_recovery_settings.html.haml b/app/views/shared/_check_recovery_settings.html.haml
new file mode 100644
index 00000000000..e3de34a5ab9
--- /dev/null
+++ b/app/views/shared/_check_recovery_settings.html.haml
@@ -0,0 +1,6 @@
+.gl-alert.gl-alert-warning.js-recovery-settings-callout{ role: 'alert', data: { feature_id: "account_recovery_regular_check", dismiss_endpoint: user_callouts_path, defer_links: "true" } }
+ %button.js-close.gl-alert-dismiss.gl-cursor-pointer{ type: 'button', 'aria-label' => _('Dismiss') }
+ = sprite_icon('close', size: 16, css_class: 'gl-icon')
+ .gl-alert-body
+ - account_link_start = '<a class="deferred-link" href="%{url}">'.html_safe % { url: profile_account_path }
+ = _("Please ensure your account's %{account_link_start}recovery settings%{account_link_end} are up to date.").html_safe % { account_link_start: account_link_start, account_link_end: '</a>'.html_safe }
diff --git a/app/views/shared/_mobile_clone_panel.html.haml b/app/views/shared/_mobile_clone_panel.html.haml
index 2887acf7cd7..2854b115506 100644
--- a/app/views/shared/_mobile_clone_panel.html.haml
+++ b/app/views/shared/_mobile_clone_panel.html.haml
@@ -4,8 +4,8 @@
.btn-group.mobile-git-clone.js-mobile-git-clone.btn-block
= clipboard_button(button_text: default_clone_label, text: default_url_to_repo(project), hide_button_icon: true, class: "btn-primary flex-fill bold justify-content-center input-group-text clone-dropdown-btn js-clone-dropdown-label")
- %button.btn.btn-primary.dropdown-toggle.js-dropdown-toggle.flex-grow-0.d-flex-center{ type: "button", data: { toggle: "dropdown" } }
- = sprite_icon("arrow-down", css_class: "dropdown-btn-icon icon")
+ %button.btn.btn-primary.dropdown-toggle.js-dropdown-toggle.flex-grow-0.d-flex-center.w-auto.ml-0{ type: "button", data: { toggle: "dropdown" } }
+ = sprite_icon("chevron-down", css_class: "dropdown-btn-icon icon")
%ul.dropdown-menu.dropdown-menu-selectable.dropdown-menu-right.clone-options-dropdown{ data: { dropdown: true } }
- if ssh_enabled?
%li
diff --git a/app/views/shared/_ping_consent.html.haml b/app/views/shared/_ping_consent.html.haml
index f8eb2b2833b..ded9b55056a 100644
--- a/app/views/shared/_ping_consent.html.haml
+++ b/app/views/shared/_ping_consent.html.haml
@@ -1,6 +1,6 @@
- if session[:ask_for_usage_stats_consent]
.ping-consent-message.alert.alert-warning.flex-alert
- - settings_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="alert-link">'.html_safe % { url: admin_application_settings_path(anchor: 'js-usage-settings') }
+ - settings_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="alert-link">'.html_safe % { url: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings') }
- info_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer" class="alert-link">'.html_safe % { url: help_page_path('user/admin_area/settings/usage_statistics.md') }
.alert-message
= s_('To help improve GitLab, we would like to periodically collect usage information. This can be changed at any time in %{settings_link_start}Settings%{link_end}. %{info_link_start}More Information%{link_end}').html_safe % { settings_link_start: settings_link_start, info_link_start: info_link_start, link_end: '</a>'.html_safe }
diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml
index 1bf52feab11..4415c654ab9 100644
--- a/app/views/shared/_service_settings.html.haml
+++ b/app/views/shared/_service_settings.html.haml
@@ -12,7 +12,7 @@
.form-group.row
= form.label :active, "Active", class: "col-form-label col-sm-2"
.col-sm-10
- = form.check_box :active, disabled: disable_fields_service?(@service), data: { qa_selector: 'active_checkbox' }
+ = form.check_box :active, checked: @service.active || @service.new_record?, disabled: disable_fields_service?(@service)
- if @service.configurable_events.present?
.form-group.row
diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml
index a62c385d711..3db96db73ce 100644
--- a/app/views/shared/boards/components/_board.html.haml
+++ b/app/views/shared/boards/components/_board.html.haml
@@ -22,14 +22,14 @@
%span.board-title-main-text.block-truncated{ "v-if": "list.type !== \"label\"",
":title" => '((list.label && list.label.description) || list.title || "")',
data: { container: "body" },
- ":class": "{ 'has-tooltip': !['backlog', 'closed'].includes(list.type) }" }
+ ":class": "{ 'has-tooltip': !['backlog', 'closed'].includes(list.type), 'd-block': list.type === 'milestone' }" }
{{ list.title }}
%span.board-title-sub-text.prepend-left-5.has-tooltip{ "v-if": "list.type === \"assignee\"",
":title" => '(list.assignee && list.assignee.username || "")' }
@{{ list.assignee.username }}
- %span.has-tooltip.badge.color-label.title{ "v-if": "list.type === \"label\"",
+ %span.has-tooltip.badge.color-label.title.d-inline-block.mw-100.text-truncate.align-middle{ "v-if": "list.type === \"label\"",
":title" => '(list.label ? list.label.description : "")',
data: { container: "body", placement: "bottom" },
":style" => "{ backgroundColor: (list.label && list.label.color ? list.label.color : null), color: (list.label && list.label.textColor ? list.label.textColor : \"#2e2e2e\") }" }
diff --git a/app/views/shared/empty_states/_profile_tabs.html.haml b/app/views/shared/empty_states/_profile_tabs.html.haml
index 98a5a5953d0..38c9fe7179c 100644
--- a/app/views/shared/empty_states/_profile_tabs.html.haml
+++ b/app/views/shared/empty_states/_profile_tabs.html.haml
@@ -14,6 +14,7 @@
- if secondary_button_link.present?
= link_to secondary_button_label, secondary_button_link, class: 'btn btn-success btn-inverted'
- = link_to primary_button_label, primary_button_link, class: 'btn btn-success'
+ - if primary_button_link.present?
+ = link_to primary_button_label, primary_button_link, class: 'btn btn-success'
- else
%h5= visitor_empty_message
diff --git a/app/views/shared/empty_states/_snippets.html.haml b/app/views/shared/empty_states/_snippets.html.haml
index 889a470d6ec..efd9bceedc5 100644
--- a/app/views/shared/empty_states/_snippets.html.haml
+++ b/app/views/shared/empty_states/_snippets.html.haml
@@ -1,20 +1,19 @@
- button_path = local_assigns.fetch(:button_path, false)
-.row.empty-state
+.row.empty-state.mt-0
.col-12
.svg-content
= image_tag 'illustrations/snippets_empty.svg'
- .text-content
+ .text-content.text-center.pt-0
- if current_user
%h4
- = s_('SnippetsEmptyState|Snippets are small pieces of code or notes that you want to keep.')
- %p
- = s_('SnippetsEmptyState|They can be either public or private.')
- .text-center
+ = s_('SnippetsEmptyState|Code snippets')
+ %p.mb-0
+ = s_('SnippetsEmptyState|Store, share, and embed small pieces of code and text.')
+ .mt-2<
- if button_path
= link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn btn-success', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link'
- - unless current_page?(dashboard_snippets_path)
- = link_to s_('SnippetsEmptyState|Explore public snippets'), explore_snippets_path, class: 'btn btn-default', title: s_('SnippetsEmptyState|Explore public snippets')
+ = link_to s_('SnippetsEmptyState|Documentation'), help_page_path('user/snippets.md'), class: 'btn btn-default', title: s_('SnippetsEmptyState|Documentation')
- else
%h4.text-center= s_('SnippetsEmptyState|There are no snippets to show.')
diff --git a/app/views/shared/hook_logs/_status_label.html.haml b/app/views/shared/hook_logs/_status_label.html.haml
index 993880b7d6e..dfa5ecee448 100644
--- a/app/views/shared/hook_logs/_status_label.html.haml
+++ b/app/views/shared/hook_logs/_status_label.html.haml
@@ -1,3 +1,3 @@
- label_status = hook_log.success? ? 'badge-success' : 'badge-danger'
-%span{ class: "label #{label_status}" }
- = hook_log.response_status
+%span{ class: "badge #{label_status}" }
+ = hook_log.internal_error? ? _('Error') : hook_log.response_status
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index c3960ec5026..a27ceaff782 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -21,7 +21,7 @@
- if type != :boards_modal && type != :boards
= dropdown_tag(_('Recent searches'),
options: { wrapper_class: "filtered-search-history-dropdown-wrapper",
- toggle_class: "filtered-search-history-dropdown-toggle-button",
+ toggle_class: "btn filtered-search-history-dropdown-toggle-button",
dropdown_class: "filtered-search-history-dropdown",
content_class: "filtered-search-history-dropdown-content" }) do
.js-filtered-search-history-dropdown{ data: { full_path: search_history_storage_prefix } }
diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml
index 4aeeac87f3c..1d7d18d2ab6 100644
--- a/app/views/shared/members/_group.html.haml
+++ b/app/views/shared/members/_group.html.haml
@@ -31,7 +31,7 @@
= dropdown_title(_("Change permissions"))
.dropdown-content
%ul
- - Gitlab::Access.options.each do |role, role_id|
+ - Gitlab::Access.options_with_owner.each do |role, role_id|
%li
= link_to role, '#',
class: ("is-active" if group_link.group_access == role_id),
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index d5c1a1bee6d..d74030c566f 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -22,6 +22,8 @@
- if user == current_user
%span.badge.badge-success.prepend-left-5= _("It's you")
+ = render_if_exists 'shared/members/ee/license_badge', user: user, group: @group
+
- if user.blocked?
%label.badge.badge-danger
%strong= _("Blocked")
diff --git a/app/views/shared/milestones/_delete_button.html.haml b/app/views/shared/milestones/_delete_button.html.haml
index e236c24b088..e00a10398d3 100644
--- a/app/views/shared/milestones/_delete_button.html.haml
+++ b/app/views/shared/milestones/_delete_button.html.haml
@@ -9,6 +9,6 @@
milestone_merge_request_count: @milestone.merge_requests.count },
disabled: true }
= _('Delete')
- = icon('spin spinner', class: 'js-loading-icon hidden' )
+ .spinner.js-loading-icon.hidden
#delete-milestone-modal
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index fbbcc4f3e68..a6fb8e6d4fc 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -98,10 +98,6 @@
human_time_estimate: @milestone.human_total_issue_time_estimate,
human_time_spent: @milestone.human_total_issue_time_spent,
limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s } }
- // Fallback while content is loading
- .title.hide-collapsed
- = _('Time tracking')
- = icon('spinner spin')
= render_if_exists 'shared/milestones/weight', milestone: milestone
diff --git a/app/views/shared/milestones/_tab_loading.html.haml b/app/views/shared/milestones/_tab_loading.html.haml
index 68458c2d0aa..dfca6a184be 100644
--- a/app/views/shared/milestones/_tab_loading.html.haml
+++ b/app/views/shared/milestones/_tab_loading.html.haml
@@ -1,2 +1,2 @@
.text-center.prepend-top-default
- = icon('spin spinner 2x', 'aria-hidden': 'true', 'aria-label': 'Loading tab content')
+ .spinner.spinner-md
diff --git a/app/views/shared/notifications/_custom_notifications.html.haml b/app/views/shared/notifications/_custom_notifications.html.haml
index be574155436..0e1e3beeb1c 100644
--- a/app/views/shared/notifications/_custom_notifications.html.haml
+++ b/app/views/shared/notifications/_custom_notifications.html.haml
@@ -30,4 +30,4 @@
%label.form-check-label{ for: field_id }
%strong
= notification_event_name(event)
- = icon("spinner spin", class: "custom-notification-event-loading")
+ .fa.custom-notification-event-loading.spinner
diff --git a/app/views/shared/notifications/_new_button.html.haml b/app/views/shared/notifications/_new_button.html.haml
index 363053b5e35..566f08b94ce 100644
--- a/app/views/shared/notifications/_new_button.html.haml
+++ b/app/views/shared/notifications/_new_button.html.haml
@@ -20,13 +20,13 @@
= notification_setting_icon(notification_setting)
%span.js-notification-loading.fa.hidden
%button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" }, class: "#{btn_class}" }
- = sprite_icon("arrow-down", css_class: "icon mr-0")
+ = sprite_icon("chevron-down", css_class: "icon mr-0")
.sr-only Toggle dropdown
- else
%button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => button_title, data: { container: "body", placement: 'top', toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
= notification_setting_icon(notification_setting)
%span.js-notification-loading.fa.hidden
- = sprite_icon("arrow-down", css_class: "icon")
+ = sprite_icon("chevron-down", css_class: "icon")
= render "shared/notifications/notification_dropdown", notification_setting: notification_setting
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index 45e95685677..07a61b71b8e 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -12,9 +12,7 @@
- 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)
-- 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 }
@@ -62,11 +60,6 @@
.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 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.last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path
-
= render_if_exists 'shared/projects/archived', project: project
- if stars
= link_to project_starrers_path(project),
diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml
index 73401029da4..3c2c751c579 100644
--- a/app/views/shared/snippets/_form.html.haml
+++ b/app/views/shared/snippets/_form.html.haml
@@ -6,27 +6,37 @@
html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" } do |f|
= form_errors(@snippet)
- .form-group.row
- .col-sm-2.col-form-label
- = f.label :title
- .col-sm-10
- = f.text_field :title, class: 'form-control qa-snippet-title', required: true, autofocus: true
-
- = render 'shared/form_elements/description', model: @snippet, project: @project, form: f
-
- = render 'shared/old_visibility_level', f: f, visibility_level: @snippet.visibility_level, can_change_visibility_level: true, form_model: @snippet, with_label: false
-
- .file-editor
- .form-group.row
- .col-sm-2.col-form-label
- = f.label :file_name, "File"
- .col-sm-10
- .file-holder.snippet
- .js-file-title.file-title-flex-parent
- = f.text_field :file_name, placeholder: "Optionally name this file to add code highlighting, e.g. example.rb for Ruby.", class: 'form-control snippet-file-name qa-snippet-file-name'
- .file-content.code
- %pre#editor= @snippet.content
- = f.hidden_field :content, class: 'snippet-file-content'
+ .form-group
+ = f.label :title, class: 'label-bold'
+ = f.text_field :title, class: 'form-control qa-snippet-title', required: true, autofocus: true
+
+ .form-group.js-description-input
+ - description_placeholder = s_('Snippets|Optionally add a description about what your snippet does or how to use it...')
+ - is_expanded = @snippet.description && !@snippet.description.empty?
+ = f.label :description, s_("Snippets|Description (optional)"), class: 'label-bold'
+ .js-collapsible-input
+ .js-collapsed{ class: ('d-none' if is_expanded) }
+ = text_field_tag nil, nil, class: 'form-control', placeholder: description_placeholder, data: { qa_selector: 'description_placeholder' }
+ .js-expanded{ class: ('d-none' if !is_expanded) }
+ = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
+ = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder, qa_selector: 'description_field'
+ = render 'shared/notes/hints'
+
+ .form-group.file-editor
+ = f.label :file_name, s_('Snippets|File')
+ .file-holder.snippet
+ .js-file-title.file-title-flex-parent
+ = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control snippet-file-name qa-snippet-file-name'
+ .file-content.code
+ %pre#editor= @snippet.content
+ = f.hidden_field :content, class: 'snippet-file-content'
+
+ .form-group
+ .font-weight-bold
+ = _('Visibility level')
+ = link_to icon('question-circle'), help_page_path("public_access/public_access"), target: '_blank'
+ = render 'shared/visibility_level', f: f, visibility_level: @snippet.visibility_level, can_change_visibility_level: true, form_model: @snippet, with_label: false
+
- if params[:files]
- params[:files].each_with_index do |file, index|
= hidden_field_tag "files[]", file, id: "files_#{index}"
diff --git a/app/views/sherlock/queries/_general.html.haml b/app/views/sherlock/queries/_general.html.haml
index 52c7bc47ca7..1514ad55d71 100644
--- a/app/views/sherlock/queries/_general.html.haml
+++ b/app/views/sherlock/queries/_general.html.haml
@@ -27,7 +27,7 @@
.card-header
.float-right
%button.js-clipboard-trigger.btn.btn-sm{ title: t('sherlock.copy_to_clipboard'), type: :button }
- = sprite_icon('duplicate')
+ = sprite_icon('copy-to-clipboard')
%pre.hidden
= @query.formatted_query
%strong
@@ -42,7 +42,7 @@
.card-header
.float-right
%button.js-clipboard-trigger.btn.btn-sm{ title: t('sherlock.copy_to_clipboard'), type: :button }
- = sprite_icon('duplicate')
+ = sprite_icon('copy-to-clipboard')
%pre.hidden
= @query.explain
%strong
diff --git a/app/views/snippets/_actions.html.haml b/app/views/snippets/_actions.html.haml
index 5ee12a2f22a..979821a3846 100644
--- a/app/views/snippets/_actions.html.haml
+++ b/app/views/snippets/_actions.html.haml
@@ -1,13 +1,13 @@
- return unless current_user
.d-none.d-sm-block
- - if can?(current_user, :update_personal_snippet, @snippet)
+ - if can?(current_user, :update_snippet, @snippet)
= link_to edit_snippet_path(@snippet), class: "btn btn-grouped" do
= _("Edit")
- - if can?(current_user, :admin_personal_snippet, @snippet)
+ - if can?(current_user, :admin_snippet, @snippet)
= link_to gitlab_snippet_path(@snippet), method: :delete, data: { confirm: _("Are you sure?") }, class: "btn btn-grouped btn-inverted btn-remove", title: _('Delete Snippet') do
= _("Delete")
- - if can?(current_user, :create_personal_snippet)
+ - if can?(current_user, :create_snippet)
= link_to new_snippet_path, class: "btn btn-grouped btn-success btn-inverted", title: _("New snippet") do
= _("New snippet")
- if @snippet.submittable_as_spam_by?(current_user)
@@ -18,15 +18,15 @@
= icon('caret-down')
.dropdown-menu.dropdown-menu-full-width
%ul
- - if can?(current_user, :create_personal_snippet)
+ - if can?(current_user, :create_snippet)
%li
= link_to new_snippet_path, title: _("New snippet") do
= _("New snippet")
- - if can?(current_user, :admin_personal_snippet, @snippet)
+ - if can?(current_user, :admin_snippet, @snippet)
%li
= link_to gitlab_snippet_path(@snippet), method: :delete, data: { confirm: _("Are you sure?") }, title: _('Delete Snippet') do
= _("Delete")
- - if can?(current_user, :update_personal_snippet, @snippet)
+ - if can?(current_user, :update_snippet, @snippet)
%li
= link_to edit_snippet_path(@snippet) do
= _("Edit")
diff --git a/app/views/snippets/_snippets.html.haml b/app/views/snippets/_snippets.html.haml
index 69b19c0def9..1d22575803b 100644
--- a/app/views/snippets/_snippets.html.haml
+++ b/app/views/snippets/_snippets.html.haml
@@ -3,7 +3,7 @@
- current_user_empty_message_header = s_('UserProfile|You haven\'t created any snippets.')
- current_user_empty_message_description = s_('UserProfile|Snippets in GitLab can either be private, internal, or public.')
- primary_button_label = _('New snippet')
-- primary_button_link = new_snippet_path if can?(current_user, :create_personal_snippet)
+- primary_button_link = new_snippet_path if can?(current_user, :create_snippet)
- visitor_empty_message = s_('UserProfile|No snippets found.')
.snippets-list-holder
diff --git a/app/views/snippets/_snippets_scope_menu.html.haml b/app/views/snippets/_snippets_scope_menu.html.haml
index cb59b11ca2b..e9c9ca6e856 100644
--- a/app/views/snippets/_snippets_scope_menu.html.haml
+++ b/app/views/snippets/_snippets_scope_menu.html.haml
@@ -7,25 +7,25 @@
= _("All")
%span.badge.badge-pill
- if include_private
- = subject.snippets.count
+ = counts[:total]
- else
- = subject.snippets.public_and_internal_only.count
+ = counts[:are_public_or_internal]
- if include_private
%li{ class: active_when(params[:scope] == "are_private") }
= link_to subject_snippets_path(subject, scope: 'are_private') do
= _("Private")
%span.badge.badge-pill
- = subject.snippets.are_private.count
+ = counts[:are_private]
%li{ class: active_when(params[:scope] == "are_internal") }
= link_to subject_snippets_path(subject, scope: 'are_internal') do
= _("Internal")
%span.badge.badge-pill
- = subject.snippets.are_internal.count
+ = counts[:are_internal]
%li{ class: active_when(params[:scope] == "are_public") }
= link_to subject_snippets_path(subject, scope: 'are_public') do
= _("Public")
%span.badge.badge-pill
- = subject.snippets.are_public.count
+ = counts[:are_public]
diff --git a/app/views/snippets/edit.html.haml b/app/views/snippets/edit.html.haml
index f5ffb037152..66f5e8148e1 100644
--- a/app/views/snippets/edit.html.haml
+++ b/app/views/snippets/edit.html.haml
@@ -1,4 +1,5 @@
- page_title _("Edit"), "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets")
+- @content_class = "limit-container-width" unless fluid_layout
%h3.page-title
= _("Edit Snippet")
diff --git a/app/views/snippets/new.html.haml b/app/views/snippets/new.html.haml
index 9d462865471..acc0ce0fff3 100644
--- a/app/views/snippets/new.html.haml
+++ b/app/views/snippets/new.html.haml
@@ -1,6 +1,7 @@
- @hide_top_links = true
- @hide_breadcrumbs = true
- page_title _("New Snippet")
+- @content_class = "limit-container-width" unless fluid_layout
.page-title-holder.d-flex.align-items-center
%h1.page-title= _('New Snippet')
diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml
index 080c0ab6ece..30f760f2122 100644
--- a/app/views/snippets/show.html.haml
+++ b/app/views/snippets/show.html.haml
@@ -13,7 +13,7 @@
%article.file-holder.snippet-file-content
= render 'shared/snippets/blob'
- .row-content-block.top-block.content-component-block
- = render 'award_emoji/awards_block', awardable: @snippet, inline: true
+.row-content-block.top-block.content-component-block
+ = render 'award_emoji/awards_block', awardable: @snippet, inline: true
- #notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => false
+#notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => false
diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml
index b5bc1180290..7bd2d30a35c 100644
--- a/app/views/users/_overview.html.haml
+++ b/app/views/users/_overview.html.haml
@@ -3,7 +3,7 @@
.calendar-block.prepend-top-default.append-bottom-default
.user-calendar.d-none.d-sm-block{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: Time.zone.utc_offset } }
%h4.center.light
- = spinner nil, true
+ .spinner.spinner-md
.user-calendar-activities.d-none.d-sm-block
.row
.col-md-12.col-lg-6
@@ -16,7 +16,7 @@
= link_to s_('UserProfile|View all'), user_activity_path, class: "hide js-view-all"
.overview-content-list{ data: { href: user_path } }
.center.light.loading
- = spinner nil, true
+ .spinner.spinner-md
.col-md-12.col-lg-6
.projects-block
@@ -27,4 +27,4 @@
= link_to s_('UserProfile|View all'), user_projects_path, class: "hide js-view-all"
.overview-content-list{ data: { href: user_projects_path } }
.center.light.loading
- = spinner nil, true
+ .spinner.spinner-md
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index e10dad8aa8d..3c164588b13 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -130,7 +130,8 @@
%h4.prepend-top-20
= s_('UserProfile|Most Recent Activity')
.content_list{ data: { href: user_path } }
- = spinner
+ .loading
+ .spinner.spinner-md
- if profile_tab?(:groups)
#groups.tab-pane
@@ -152,8 +153,8 @@
#snippets.tab-pane
-# This tab is always loaded via AJAX
- .loading-status
- = spinner
+ .loading.hide
+ .spinner.spinner-md
- if profile_tabs.empty?
.row
diff --git a/app/workers/admin_email_worker.rb b/app/workers/admin_email_worker.rb
index be05d2a6752..a7cc4fb0d11 100644
--- a/app/workers/admin_email_worker.rb
+++ b/app/workers/admin_email_worker.rb
@@ -2,7 +2,10 @@
class AdminEmailWorker
include ApplicationWorker
+ # rubocop:disable Scalability/CronWorkerContext
+ # This worker does not perform work scoped to a context
include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
feature_category_not_owned!
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 62b37f52cce..f6daab73689 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -1,192 +1,1085 @@
+# This file is generated automatically by
+# bin/rake gitlab:sidekiq:all_queues_yml:generate
+#
+# Do not edit it manually!
---
-- auto_devops:auto_devops_disable
-
-- auto_merge:auto_merge_process
-
-- chaos:chaos_cpu_spin
-- chaos:chaos_db_spin
-- chaos:chaos_kill
-- chaos:chaos_leak_mem
-- chaos:chaos_sleep
-
-- cronjob:admin_email
-- cronjob:container_expiration_policy
-- cronjob:expire_build_artifacts
-- cronjob:gitlab_usage_ping
-- cronjob:import_export_project_cleanup
-- cronjob:pages_domain_verification_cron
-- cronjob:pages_domain_removal_cron
-- cronjob:pages_domain_ssl_renewal_cron
-- cronjob:personal_access_tokens_expiring
-- cronjob:pipeline_schedule
-- cronjob:prune_old_events
-- cronjob:remove_expired_group_links
-- cronjob:remove_expired_members
-- cronjob:remove_unreferenced_lfs_objects
-- cronjob:repository_archive_cache
-- cronjob:repository_check_dispatch
-- cronjob:requests_profiles
-- cronjob:stuck_ci_jobs
-- cronjob:stuck_import_jobs
-- cronjob:stuck_merge_jobs
-- cronjob:ci_archive_traces_cron
-- cronjob:trending_projects
-- cronjob:issue_due_scheduler
-- cronjob:prune_web_hook_logs
-- cronjob:schedule_migrate_external_diffs
-- cronjob:namespaces_prune_aggregation_schedules
-
-- gcp_cluster:cluster_install_app
-- gcp_cluster:cluster_patch_app
-- gcp_cluster:cluster_upgrade_app
-- gcp_cluster:cluster_provision
-- gcp_cluster:clusters_cleanup_app
-- gcp_cluster:clusters_cleanup_project_namespace
-- gcp_cluster:clusters_cleanup_service_account
-- gcp_cluster:cluster_wait_for_app_installation
-- gcp_cluster:wait_for_cluster_creation
-- gcp_cluster:cluster_wait_for_ingress_ip_address
-- gcp_cluster:cluster_configure
-- gcp_cluster:cluster_project_configure
-- gcp_cluster:clusters_applications_wait_for_uninstall_app
-- gcp_cluster:clusters_applications_uninstall
-- gcp_cluster:clusters_cleanup_app
-- gcp_cluster:clusters_cleanup_project_namespace
-- gcp_cluster:clusters_cleanup_service_account
-- gcp_cluster:clusters_applications_activate_service
-- gcp_cluster:clusters_applications_deactivate_service
-
-- github_import_advance_stage
-- github_importer:github_import_import_diff_note
-- github_importer:github_import_import_issue
-- github_importer:github_import_import_note
-- github_importer:github_import_import_lfs_object
-- github_importer:github_import_import_pull_request
-- github_importer:github_import_refresh_import_jid
-- github_importer:github_import_stage_finish_import
-- github_importer:github_import_stage_import_base_data
-- github_importer:github_import_stage_import_issues_and_diff_notes
-- github_importer:github_import_stage_import_notes
-- github_importer:github_import_stage_import_lfs_objects
-- github_importer:github_import_stage_import_pull_requests
-- github_importer:github_import_stage_import_repository
-
-- hashed_storage:hashed_storage_migrator
-- hashed_storage:hashed_storage_rollbacker
-- hashed_storage:hashed_storage_project_migrate
-- hashed_storage:hashed_storage_project_rollback
-
-- mail_scheduler:mail_scheduler_issue_due
-- mail_scheduler:mail_scheduler_notification_service
-
-- object_storage:object_storage_background_move
-- object_storage:object_storage_migrate_uploads
-
-- pipeline_cache:expire_job_cache
-- pipeline_cache:expire_pipeline_cache
-- pipeline_creation:create_pipeline
-- pipeline_creation:run_pipeline_schedule
-- pipeline_background:archive_trace
-- pipeline_background:ci_build_trace_chunk_flush
-- pipeline_default:build_coverage
-- pipeline_default:build_trace_sections
-- pipeline_default:pipeline_metrics
-- pipeline_default:pipeline_notification
-- pipeline_hooks:build_hooks
-- pipeline_hooks:pipeline_hooks
-- pipeline_processing:build_finished
-- pipeline_processing:ci_build_prepare
-- pipeline_processing:build_queue
-- pipeline_processing:build_success
-- pipeline_processing:pipeline_process
-- pipeline_processing:pipeline_success
-- pipeline_processing:pipeline_update
-- pipeline_processing:stage_update
-- pipeline_processing:update_head_pipeline_for_merge_request
-- pipeline_processing:ci_build_schedule
-- pipeline_processing:ci_resource_groups_assign_resource_from_resource_group
-
-- deployment:deployments_success
-- deployment:deployments_finished
-
-- repository_check:repository_check_clear
-- repository_check:repository_check_batch
-- repository_check:repository_check_single_repository
-
-- todos_destroyer:todos_destroyer_confidential_issue
-- todos_destroyer:todos_destroyer_entity_leave
-- todos_destroyer:todos_destroyer_group_private
-- todos_destroyer:todos_destroyer_project_private
-- todos_destroyer:todos_destroyer_private_features
-
-- update_namespace_statistics:namespaces_schedule_aggregation
-- update_namespace_statistics:namespaces_root_statistics
-
-- object_pool:object_pool_create
-- object_pool:object_pool_schedule_join
-- object_pool:object_pool_join
-- object_pool:object_pool_destroy
-
-- container_repository:delete_container_repository
-- container_repository:cleanup_container_repository
-
-- notifications:new_release
-
-- default
-- mailers # ActionMailer::DeliveryJob.queue_name
-
-- authorized_projects
-- background_migration
-- chat_notification
-- create_gpg_signature
-- delete_merged_branches
-- delete_user
-- email_receiver
-- emails_on_push
-- expire_build_instance_artifacts
-- git_garbage_collect
-- gitlab_shell
-- group_destroy
-- invalid_gpg_signature_update
-- irker
-- merge
-- migrate_external_diffs
-- namespaceless_project_destroy
-- new_issue
-- new_merge_request
-- new_note
-- pages
-- pages_domain_verification
-- pages_domain_ssl_renewal
-- file_hook
-- post_receive
-- process_commit
-- project_cache
-- project_destroy
-- project_export
-- project_service
-- propagate_service_template
-- reactive_caching
-- rebase
-- remote_mirror_notification
-- repository_fork
-- repository_import
-- repository_remove_remote
-- system_hook_push
-- update_external_pull_requests
-- update_merge_requests
-- update_project_statistics
-- upload_checksum
-- web_hook
-- repository_update_remote_mirror
-- create_note_diff_file
-- delete_diff_files
-- detect_repository_languages
-- repository_cleanup
-- delete_stored_files
-- import_issues_csv
-- project_daily_statistics
-- create_evidence
-- group_export
-- self_monitoring_project_create
-- self_monitoring_project_delete
+- :name: auto_devops:auto_devops_disable
+ :feature_category: :auto_devops
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 2
+- :name: auto_merge:auto_merge_process
+ :feature_category: :continuous_delivery
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :cpu
+ :weight: 3
+- :name: chaos:chaos_cpu_spin
+ :feature_category: :chaos_engineering
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 2
+- :name: chaos:chaos_db_spin
+ :feature_category: :chaos_engineering
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 2
+- :name: chaos:chaos_kill
+ :feature_category: :chaos_engineering
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 2
+- :name: chaos:chaos_leak_mem
+ :feature_category: :chaos_engineering
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 2
+- :name: chaos:chaos_sleep
+ :feature_category: :chaos_engineering
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 2
+- :name: container_repository:cleanup_container_repository
+ :feature_category: :container_registry
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: container_repository:delete_container_repository
+ :feature_category: :container_registry
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: cronjob:admin_email
+ :feature_category: :not_owned
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: cronjob:ci_archive_traces_cron
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: cronjob:container_expiration_policy
+ :feature_category: :container_registry
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: cronjob:environments_auto_stop_cron
+ :feature_category: :continuous_delivery
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: cronjob:expire_build_artifacts
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: cronjob:gitlab_usage_ping
+ :feature_category: :not_owned
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: cronjob:import_export_project_cleanup
+ :feature_category: :importers
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: cronjob:issue_due_scheduler
+ :feature_category: :issue_tracking
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: cronjob:namespaces_prune_aggregation_schedules
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :cpu
+ :weight: 1
+- :name: cronjob:pages_domain_removal_cron
+ :feature_category: :pages
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :cpu
+ :weight: 1
+- :name: cronjob:pages_domain_ssl_renewal_cron
+ :feature_category: :pages
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: cronjob:pages_domain_verification_cron
+ :feature_category: :pages
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: cronjob:personal_access_tokens_expiring
+ :feature_category: :authentication_and_authorization
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: cronjob:pipeline_schedule
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :cpu
+ :weight: 1
+- :name: cronjob:prune_old_events
+ :feature_category: :not_owned
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: cronjob:prune_web_hook_logs
+ :feature_category: :integrations
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: cronjob:remove_expired_group_links
+ :feature_category: :authentication_and_authorization
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: cronjob:remove_expired_members
+ :feature_category: :authentication_and_authorization
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :cpu
+ :weight: 1
+- :name: cronjob:remove_unreferenced_lfs_objects
+ :feature_category: :git_lfs
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: cronjob:repository_archive_cache
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: cronjob:repository_check_dispatch
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: cronjob:requests_profiles
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: cronjob:schedule_migrate_external_diffs
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: cronjob:stuck_ci_jobs
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :cpu
+ :weight: 1
+- :name: cronjob:stuck_import_jobs
+ :feature_category: :importers
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :cpu
+ :weight: 1
+- :name: cronjob:stuck_merge_jobs
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: cronjob:trending_projects
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: deployment:deployments_finished
+ :feature_category: :continuous_delivery
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :cpu
+ :weight: 3
+- :name: deployment:deployments_forward_deployment
+ :feature_category: :continuous_delivery
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 3
+- :name: deployment:deployments_success
+ :feature_category: :continuous_delivery
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :cpu
+ :weight: 3
+- :name: gcp_cluster:cluster_configure
+ :feature_category: :kubernetes_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: gcp_cluster:cluster_configure_istio
+ :feature_category: :kubernetes_management
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: gcp_cluster:cluster_install_app
+ :feature_category: :kubernetes_management
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: gcp_cluster:cluster_patch_app
+ :feature_category: :kubernetes_management
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: gcp_cluster:cluster_project_configure
+ :feature_category: :kubernetes_management
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: gcp_cluster:cluster_provision
+ :feature_category: :kubernetes_management
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: gcp_cluster:cluster_upgrade_app
+ :feature_category: :kubernetes_management
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: gcp_cluster:cluster_wait_for_app_installation
+ :feature_category: :kubernetes_management
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :cpu
+ :weight: 1
+- :name: gcp_cluster:cluster_wait_for_ingress_ip_address
+ :feature_category: :kubernetes_management
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: gcp_cluster:clusters_applications_activate_service
+ :feature_category: :kubernetes_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: gcp_cluster:clusters_applications_deactivate_service
+ :feature_category: :kubernetes_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: gcp_cluster:clusters_applications_uninstall
+ :feature_category: :kubernetes_management
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: gcp_cluster:clusters_applications_wait_for_uninstall_app
+ :feature_category: :kubernetes_management
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :cpu
+ :weight: 1
+- :name: gcp_cluster:clusters_cleanup_app
+ :feature_category: :kubernetes_management
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: gcp_cluster:clusters_cleanup_project_namespace
+ :feature_category: :kubernetes_management
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: gcp_cluster:clusters_cleanup_service_account
+ :feature_category: :kubernetes_management
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: gcp_cluster:wait_for_cluster_creation
+ :feature_category: :kubernetes_management
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: github_importer:github_import_import_diff_note
+ :feature_category: :importers
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: github_importer:github_import_import_issue
+ :feature_category: :importers
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: github_importer:github_import_import_lfs_object
+ :feature_category: :importers
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: github_importer:github_import_import_note
+ :feature_category: :importers
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: github_importer:github_import_import_pull_request
+ :feature_category: :importers
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: github_importer:github_import_refresh_import_jid
+ :feature_category: :importers
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: github_importer:github_import_stage_finish_import
+ :feature_category: :importers
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: github_importer:github_import_stage_import_base_data
+ :feature_category: :importers
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: github_importer:github_import_stage_import_issues_and_diff_notes
+ :feature_category: :importers
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: github_importer:github_import_stage_import_lfs_objects
+ :feature_category: :importers
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: github_importer:github_import_stage_import_notes
+ :feature_category: :importers
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: github_importer:github_import_stage_import_pull_requests
+ :feature_category: :importers
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: github_importer:github_import_stage_import_repository
+ :feature_category: :importers
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: hashed_storage:hashed_storage_migrator
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: hashed_storage:hashed_storage_project_migrate
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: hashed_storage:hashed_storage_project_rollback
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: hashed_storage:hashed_storage_rollbacker
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: incident_management:incident_management_process_alert
+ :feature_category: :incident_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 2
+- :name: mail_scheduler:mail_scheduler_issue_due
+ :feature_category: :issue_tracking
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 2
+- :name: mail_scheduler:mail_scheduler_notification_service
+ :feature_category: :issue_tracking
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :cpu
+ :weight: 2
+- :name: notifications:new_release
+ :feature_category: :release_orchestration
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 2
+- :name: object_pool:object_pool_create
+ :feature_category: :gitaly
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: object_pool:object_pool_destroy
+ :feature_category: :gitaly
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: object_pool:object_pool_join
+ :feature_category: :gitaly
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :cpu
+ :weight: 1
+- :name: object_pool:object_pool_schedule_join
+ :feature_category: :gitaly
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: object_storage:object_storage_background_move
+ :feature_category: :not_owned
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: object_storage:object_storage_migrate_uploads
+ :feature_category: :not_owned
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: pipeline_background:archive_trace
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: pipeline_background:ci_build_trace_chunk_flush
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: pipeline_cache:expire_job_cache
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :unknown
+ :weight: 3
+- :name: pipeline_cache:expire_pipeline_cache
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :cpu
+ :weight: 3
+- :name: pipeline_creation:create_pipeline
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :cpu
+ :weight: 4
+- :name: pipeline_creation:run_pipeline_schedule
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 4
+- :name: pipeline_default:build_coverage
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 3
+- :name: pipeline_default:build_trace_sections
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 3
+- :name: pipeline_default:ci_create_cross_project_pipeline
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :cpu
+ :weight: 3
+- :name: pipeline_default:ci_pipeline_bridge_status
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :cpu
+ :weight: 3
+- :name: pipeline_default:pipeline_metrics
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :unknown
+ :weight: 3
+- :name: pipeline_default:pipeline_notification
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :cpu
+ :weight: 3
+- :name: pipeline_hooks:build_hooks
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :unknown
+ :weight: 2
+- :name: pipeline_hooks:pipeline_hooks
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :cpu
+ :weight: 2
+- :name: pipeline_processing:build_finished
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :cpu
+ :weight: 5
+- :name: pipeline_processing:build_queue
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :cpu
+ :weight: 5
+- :name: pipeline_processing:build_success
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :unknown
+ :weight: 5
+- :name: pipeline_processing:ci_build_prepare
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 5
+- :name: pipeline_processing:ci_build_schedule
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :cpu
+ :weight: 5
+- :name: pipeline_processing:ci_resource_groups_assign_resource_from_resource_group
+ :feature_category: :continuous_delivery
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 5
+- :name: pipeline_processing:pipeline_process
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :unknown
+ :weight: 5
+- :name: pipeline_processing:pipeline_success
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :unknown
+ :weight: 5
+- :name: pipeline_processing:pipeline_update
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :unknown
+ :weight: 5
+- :name: pipeline_processing:stage_update
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :unknown
+ :weight: 5
+- :name: pipeline_processing:update_head_pipeline_for_merge_request
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :cpu
+ :weight: 5
+- :name: repository_check:repository_check_batch
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: repository_check:repository_check_clear
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: repository_check:repository_check_single_repository
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: todos_destroyer:todos_destroyer_confidential_issue
+ :feature_category: :issue_tracking
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: todos_destroyer:todos_destroyer_entity_leave
+ :feature_category: :issue_tracking
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: todos_destroyer:todos_destroyer_group_private
+ :feature_category: :issue_tracking
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: todos_destroyer:todos_destroyer_private_features
+ :feature_category: :issue_tracking
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: todos_destroyer:todos_destroyer_project_private
+ :feature_category: :issue_tracking
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: update_namespace_statistics:namespaces_root_statistics
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: update_namespace_statistics:namespaces_schedule_aggregation
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: authorized_projects
+ :feature_category: :authentication_and_authorization
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :unknown
+ :weight: 2
+- :name: background_migration
+ :feature_category: :not_owned
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: chat_notification
+ :feature_category: :chatops
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :unknown
+ :weight: 2
+- :name: create_commit_signature
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 2
+- :name: create_evidence
+ :feature_category: :release_governance
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 2
+- :name: create_note_diff_file
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: default
+ :feature_category:
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary:
+ :weight: 1
+- :name: delete_diff_files
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: delete_merged_branches
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: delete_stored_files
+ :feature_category: :not_owned
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: delete_user
+ :feature_category: :authentication_and_authorization
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: detect_repository_languages
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: email_receiver
+ :feature_category: :issue_tracking
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :unknown
+ :weight: 2
+- :name: emails_on_push
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :cpu
+ :weight: 2
+- :name: error_tracking_issue_link
+ :feature_category: :error_tracking
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: expire_build_instance_artifacts
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: file_hook
+ :feature_category: :integrations
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: git_garbage_collect
+ :feature_category: :gitaly
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: github_import_advance_stage
+ :feature_category: :importers
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: gitlab_shell
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :unknown
+ :weight: 2
+- :name: group_destroy
+ :feature_category: :subgroups
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: group_export
+ :feature_category: :importers
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: group_import
+ :feature_category: :importers
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: import_issues_csv
+ :feature_category: :issue_tracking
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :cpu
+ :weight: 2
+- :name: invalid_gpg_signature_update
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 2
+- :name: irker
+ :feature_category: :integrations
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: mailers
+ :feature_category:
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary:
+ :weight: 2
+- :name: merge
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :unknown
+ :weight: 5
+- :name: merge_request_mergeability_check
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: migrate_external_diffs
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: namespaceless_project_destroy
+ :feature_category: :authentication_and_authorization
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: new_issue
+ :feature_category: :issue_tracking
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :cpu
+ :weight: 2
+- :name: new_merge_request
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :cpu
+ :weight: 2
+- :name: new_note
+ :feature_category: :issue_tracking
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :cpu
+ :weight: 2
+- :name: pages
+ :feature_category: :pages
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: pages_domain_ssl_renewal
+ :feature_category: :pages
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: pages_domain_verification
+ :feature_category: :pages
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: phabricator_import_import_tasks
+ :feature_category: :importers
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: post_receive
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :cpu
+ :weight: 5
+- :name: process_commit
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :unknown
+ :weight: 3
+- :name: project_cache
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: project_daily_statistics
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: project_destroy
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: project_export
+ :feature_category: :importers
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :memory
+ :weight: 1
+- :name: project_service
+ :feature_category: :integrations
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: propagate_service_template
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: reactive_caching
+ :feature_category: :not_owned
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :cpu
+ :weight: 1
+- :name: rebase
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 2
+- :name: remote_mirror_notification
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 2
+- :name: repository_cleanup
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: repository_fork
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: repository_import
+ :feature_category: :importers
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: repository_remove_remote
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: repository_update_remote_mirror
+ :feature_category: :source_code_management
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: self_monitoring_project_create
+ :feature_category: :metrics
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 2
+- :name: self_monitoring_project_delete
+ :feature_category: :metrics
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 2
+- :name: system_hook_push
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: update_external_pull_requests
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 3
+- :name: update_merge_requests
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive: true
+ :resource_boundary: :cpu
+ :weight: 3
+- :name: update_project_statistics
+ :feature_category: :source_code_management
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: upload_checksum
+ :feature_category: :geo_replication
+ :has_external_dependencies:
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
+- :name: web_hook
+ :feature_category: :integrations
+ :has_external_dependencies: true
+ :latency_sensitive:
+ :resource_boundary: :unknown
+ :weight: 1
diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb
index 9492cfe217c..1ab2fd6023f 100644
--- a/app/workers/authorized_projects_worker.rb
+++ b/app/workers/authorized_projects_worker.rb
@@ -6,6 +6,7 @@ class AuthorizedProjectsWorker
feature_category :authentication_and_authorization
latency_sensitive_worker!
+ weight 2
# 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
diff --git a/app/workers/auto_merge_process_worker.rb b/app/workers/auto_merge_process_worker.rb
index e4dccb891ce..1681fac3363 100644
--- a/app/workers/auto_merge_process_worker.rb
+++ b/app/workers/auto_merge_process_worker.rb
@@ -5,6 +5,7 @@ class AutoMergeProcessWorker
queue_namespace :auto_merge
feature_category :continuous_delivery
+ worker_resource_boundary :cpu
def perform(merge_request_id)
MergeRequest.find_by_id(merge_request_id).try do |merge_request|
diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb
index e61f37ddce1..77ce0923307 100644
--- a/app/workers/build_finished_worker.rb
+++ b/app/workers/build_finished_worker.rb
@@ -32,7 +32,7 @@ class BuildFinishedWorker
# We execute these async as these are independent operations.
BuildHooksWorker.perform_async(build.id)
ArchiveTraceWorker.perform_async(build.id)
- ExpirePipelineCacheWorker.perform_async(build.pipeline_id)
+ ExpirePipelineCacheWorker.perform_async(build.pipeline_id) if build.pipeline.cacheable?
ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat?
end
end
diff --git a/app/workers/chat_notification_worker.rb b/app/workers/chat_notification_worker.rb
index 6162dcf9d38..f23c787559f 100644
--- a/app/workers/chat_notification_worker.rb
+++ b/app/workers/chat_notification_worker.rb
@@ -8,6 +8,8 @@ class ChatNotificationWorker
sidekiq_options retry: false
feature_category :chatops
latency_sensitive_worker!
+ weight 2
+
# TODO: break this into multiple jobs
# as the `responder` uses external dependencies
# See https://gitlab.com/gitlab-com/gl-infra/scalability/issues/34
diff --git a/app/workers/ci/archive_traces_cron_worker.rb b/app/workers/ci/archive_traces_cron_worker.rb
index 74f389175b9..c73c7ba2dd8 100644
--- a/app/workers/ci/archive_traces_cron_worker.rb
+++ b/app/workers/ci/archive_traces_cron_worker.rb
@@ -3,7 +3,7 @@
module Ci
class ArchiveTracesCronWorker
include ApplicationWorker
- include CronjobQueue
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
feature_category :continuous_integration
diff --git a/app/workers/ci/create_cross_project_pipeline_worker.rb b/app/workers/ci/create_cross_project_pipeline_worker.rb
new file mode 100644
index 00000000000..91e9317713e
--- /dev/null
+++ b/app/workers/ci/create_cross_project_pipeline_worker.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Ci
+ class CreateCrossProjectPipelineWorker
+ include ::ApplicationWorker
+ include ::PipelineQueue
+
+ worker_resource_boundary :cpu
+
+ def perform(bridge_id)
+ ::Ci::Bridge.find_by_id(bridge_id).try do |bridge|
+ ::Ci::CreateCrossProjectPipelineService
+ .new(bridge.project, bridge.user)
+ .execute(bridge)
+ end
+ end
+ end
+end
diff --git a/app/workers/ci/pipeline_bridge_status_worker.rb b/app/workers/ci/pipeline_bridge_status_worker.rb
new file mode 100644
index 00000000000..f196573deaa
--- /dev/null
+++ b/app/workers/ci/pipeline_bridge_status_worker.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Ci
+ class PipelineBridgeStatusWorker
+ include ::ApplicationWorker
+ include ::PipelineQueue
+
+ latency_sensitive_worker!
+ worker_resource_boundary :cpu
+
+ def perform(pipeline_id)
+ ::Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline|
+ ::Ci::PipelineBridgeStatusService
+ .new(pipeline.project, pipeline.user)
+ .execute(pipeline)
+ end
+ end
+ end
+end
diff --git a/app/workers/cleanup_container_repository_worker.rb b/app/workers/cleanup_container_repository_worker.rb
index 83fb3e58d29..83397a1dda2 100644
--- a/app/workers/cleanup_container_repository_worker.rb
+++ b/app/workers/cleanup_container_repository_worker.rb
@@ -11,6 +11,7 @@ class CleanupContainerRepositoryWorker
def perform(current_user_id, container_repository_id, params)
@current_user = User.find_by_id(current_user_id)
@container_repository = ContainerRepository.find_by_id(container_repository_id)
+ @params = params
return unless valid?
@@ -22,9 +23,15 @@ class CleanupContainerRepositoryWorker
private
def valid?
+ return true if run_by_container_expiration_policy?
+
current_user && container_repository && project
end
+ def run_by_container_expiration_policy?
+ @params['container_expiration_policy'] && container_repository && project
+ end
+
def project
container_repository&.project
end
diff --git a/app/workers/cluster_configure_istio_worker.rb b/app/workers/cluster_configure_istio_worker.rb
new file mode 100644
index 00000000000..dfdd408f286
--- /dev/null
+++ b/app/workers/cluster_configure_istio_worker.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class ClusterConfigureIstioWorker
+ include ApplicationWorker
+ include ClusterQueue
+
+ worker_has_external_dependencies!
+
+ def perform(cluster_id)
+ Clusters::Cluster.find_by_id(cluster_id).try do |cluster|
+ Clusters::Kubernetes::ConfigureIstioIngressService.new(cluster: cluster).execute
+ end
+ end
+end
diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb
index 62748808ff1..733156ab758 100644
--- a/app/workers/concerns/application_worker.rb
+++ b/app/workers/concerns/application_worker.rb
@@ -9,6 +9,7 @@ module ApplicationWorker
include Sidekiq::Worker # rubocop:disable Cop/IncludeSidekiqWorker
include WorkerAttributes
+ include WorkerContext
included do
set_queue
diff --git a/app/workers/concerns/cronjob_queue.rb b/app/workers/concerns/cronjob_queue.rb
index 0683b229381..25ee4539cab 100644
--- a/app/workers/concerns/cronjob_queue.rb
+++ b/app/workers/concerns/cronjob_queue.rb
@@ -8,5 +8,6 @@ module CronjobQueue
included do
queue_namespace :cronjob
sidekiq_options retry: false
+ worker_context project: nil, namespace: nil, user: nil
end
end
diff --git a/app/workers/concerns/security_scans_queue.rb b/app/workers/concerns/security_scans_queue.rb
new file mode 100644
index 00000000000..f731317bb37
--- /dev/null
+++ b/app/workers/concerns/security_scans_queue.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+##
+# Concern for setting Sidekiq settings for the various Secure product queues
+#
+module SecurityScansQueue
+ extend ActiveSupport::Concern
+
+ included do
+ queue_namespace :security_scans
+ feature_category :static_application_security_testing
+ end
+end
diff --git a/app/workers/concerns/self_monitoring_project_worker.rb b/app/workers/concerns/self_monitoring_project_worker.rb
index 44dd6866fad..1796e2441f2 100644
--- a/app/workers/concerns/self_monitoring_project_worker.rb
+++ b/app/workers/concerns/self_monitoring_project_worker.rb
@@ -9,6 +9,7 @@ module SelfMonitoringProjectWorker
# Other Functionality. Metrics seems to be the closest feature_category for
# this worker.
feature_category :metrics
+ weight 2
end
LEASE_TIMEOUT = 15.minutes.to_i
diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb
index 506215ca9ed..babdb46bb85 100644
--- a/app/workers/concerns/worker_attributes.rb
+++ b/app/workers/concerns/worker_attributes.rb
@@ -7,6 +7,25 @@ module WorkerAttributes
# `worker_resource_boundary` attribute
VALID_RESOURCE_BOUNDARIES = [:memory, :cpu, :unknown].freeze
+ NAMESPACE_WEIGHTS = {
+ auto_devops: 2,
+ auto_merge: 3,
+ chaos: 2,
+ deployment: 3,
+ mail_scheduler: 2,
+ notifications: 2,
+ pipeline_cache: 3,
+ pipeline_creation: 4,
+ pipeline_default: 3,
+ pipeline_hooks: 2,
+ pipeline_processing: 5,
+
+ # EE-specific
+ epics: 2,
+ incident_management: 2,
+ security_scans: 2
+ }.stringify_keys.freeze
+
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
@@ -70,6 +89,16 @@ module WorkerAttributes
worker_attributes[:resource_boundary] || :unknown
end
+ def weight(value)
+ worker_attributes[:weight] = value
+ end
+
+ def get_weight
+ worker_attributes[:weight] ||
+ NAMESPACE_WEIGHTS[queue_namespace] ||
+ 1
+ end
+
protected
# Returns a worker attribute declared on this class or its parent class.
diff --git a/app/workers/concerns/worker_context.rb b/app/workers/concerns/worker_context.rb
new file mode 100644
index 00000000000..f2ff3ecfb6b
--- /dev/null
+++ b/app/workers/concerns/worker_context.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+module WorkerContext
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def worker_context(attributes)
+ @worker_context = Gitlab::ApplicationContext.new(attributes)
+ end
+
+ def get_worker_context
+ @worker_context || superclass_context
+ end
+
+ def bulk_perform_async_with_contexts(objects, arguments_proc:, context_proc:)
+ with_batch_contexts(objects, arguments_proc, context_proc) do |arguments|
+ bulk_perform_async(arguments)
+ end
+ end
+
+ def bulk_perform_in_with_contexts(delay, objects, arguments_proc:, context_proc:)
+ with_batch_contexts(objects, arguments_proc, context_proc) do |arguments|
+ bulk_perform_in(delay, arguments)
+ end
+ end
+
+ def context_for_arguments(args)
+ batch_context&.context_for(args)
+ end
+
+ private
+
+ BATCH_CONTEXT_KEY = "#{name}_batch_context"
+
+ def batch_context
+ Thread.current[BATCH_CONTEXT_KEY]
+ end
+
+ def batch_context=(value)
+ Thread.current[BATCH_CONTEXT_KEY] = value
+ end
+
+ def with_batch_contexts(objects, arguments_proc, context_proc)
+ self.batch_context = Gitlab::BatchWorkerContext.new(
+ objects,
+ arguments_proc: arguments_proc,
+ context_proc: context_proc
+ )
+
+ yield(batch_context.arguments)
+ ensure
+ self.batch_context = nil
+ end
+
+ def superclass_context
+ return unless superclass.include?(WorkerContext)
+
+ superclass.get_worker_context
+ end
+ end
+
+ def with_context(context, &block)
+ Gitlab::ApplicationContext.new(context).use { yield(**context) }
+ end
+end
diff --git a/app/workers/container_expiration_policy_worker.rb b/app/workers/container_expiration_policy_worker.rb
index 595208230f6..e07a6546e2d 100644
--- a/app/workers/container_expiration_policy_worker.rb
+++ b/app/workers/container_expiration_policy_worker.rb
@@ -8,9 +8,11 @@ class ContainerExpirationPolicyWorker
def perform
ContainerExpirationPolicy.runnable_schedules.preloaded.find_each do |container_expiration_policy|
- ContainerExpirationPolicyService.new(
- container_expiration_policy.project, container_expiration_policy.project.owner
- ).execute(container_expiration_policy)
+ with_context(project: container_expiration_policy.project,
+ user: container_expiration_policy.project.owner) do |project:, user:|
+ ContainerExpirationPolicyService.new(project, user)
+ .execute(container_expiration_policy)
+ end
end
end
end
diff --git a/app/workers/create_gpg_signature_worker.rb b/app/workers/create_commit_signature_worker.rb
index fc36a2adccd..027fea3e402 100644
--- a/app/workers/create_gpg_signature_worker.rb
+++ b/app/workers/create_commit_signature_worker.rb
@@ -1,9 +1,10 @@
# frozen_string_literal: true
-class CreateGpgSignatureWorker
+class CreateCommitSignatureWorker
include ApplicationWorker
feature_category :source_code_management
+ weight 2
# rubocop: disable CodeReuse/ActiveRecord
def perform(commit_shas, project_id)
@@ -22,7 +23,12 @@ class CreateGpgSignatureWorker
# This calculates and caches the signature in the database
commits.each do |commit|
- Gitlab::Gpg::Commit.new(commit).signature
+ case commit.signature_type
+ when :PGP
+ Gitlab::Gpg::Commit.new(commit).signature
+ when :X509
+ Gitlab::X509::Commit.new(commit).signature
+ end
rescue => e
Rails.logger.error("Failed to create signature for commit #{commit.id}. Error: #{e.message}") # rubocop:disable Gitlab/RailsLogger
end
diff --git a/app/workers/create_evidence_worker.rb b/app/workers/create_evidence_worker.rb
index 027dbd2f101..e6fbf59d702 100644
--- a/app/workers/create_evidence_worker.rb
+++ b/app/workers/create_evidence_worker.rb
@@ -4,6 +4,7 @@ class CreateEvidenceWorker
include ApplicationWorker
feature_category :release_governance
+ weight 2
def perform(release_id)
release = Release.find_by_id(release_id)
diff --git a/app/workers/deployments/forward_deployment_worker.rb b/app/workers/deployments/forward_deployment_worker.rb
new file mode 100644
index 00000000000..a25b8ca0478
--- /dev/null
+++ b/app/workers/deployments/forward_deployment_worker.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Deployments
+ class ForwardDeploymentWorker
+ include ApplicationWorker
+
+ queue_namespace :deployment
+ feature_category :continuous_delivery
+
+ def perform(deployment_id)
+ Deployments::OlderDeploymentsDropService.new(deployment_id).execute
+ end
+ end
+end
diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb
index b56bf4ed833..c2b1e642604 100644
--- a/app/workers/email_receiver_worker.rb
+++ b/app/workers/email_receiver_worker.rb
@@ -5,6 +5,7 @@ class EmailReceiverWorker
feature_category :issue_tracking
latency_sensitive_worker!
+ weight 2
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 f523f5953e1..be66e2b1188 100644
--- a/app/workers/emails_on_push_worker.rb
+++ b/app/workers/emails_on_push_worker.rb
@@ -8,6 +8,7 @@ class EmailsOnPushWorker
feature_category :source_code_management
latency_sensitive_worker!
worker_resource_boundary :cpu
+ weight 2
def perform(project_id, recipients, push_data, options = {})
options.symbolize_keys!
diff --git a/app/workers/environments/auto_stop_cron_worker.rb b/app/workers/environments/auto_stop_cron_worker.rb
new file mode 100644
index 00000000000..fdc9490453c
--- /dev/null
+++ b/app/workers/environments/auto_stop_cron_worker.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Environments
+ class AutoStopCronWorker
+ include ApplicationWorker
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ feature_category :continuous_delivery
+
+ def perform
+ return unless Feature.enabled?(:auto_stop_environments, default_enabled: true)
+
+ AutoStopService.new.execute
+ end
+ end
+end
diff --git a/app/workers/error_tracking_issue_link_worker.rb b/app/workers/error_tracking_issue_link_worker.rb
new file mode 100644
index 00000000000..b306ecc154b
--- /dev/null
+++ b/app/workers/error_tracking_issue_link_worker.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+# Creates a link in Sentry between a Sentry issue and a GitLab issue.
+# If the link already exists, no changes will occur.
+# If a link to a different GitLab issue exists, a new link
+# will still be created, but will not be visible in Sentry
+# until the prior link is deleted.
+class ErrorTrackingIssueLinkWorker
+ include ApplicationWorker
+ include ExclusiveLeaseGuard
+ include Gitlab::Utils::StrongMemoize
+
+ feature_category :error_tracking
+ worker_has_external_dependencies!
+
+ LEASE_TIMEOUT = 15.minutes
+
+ attr_reader :issue
+
+ def perform(issue_id)
+ @issue = Issue.find_by_id(issue_id)
+
+ return unless valid?
+
+ try_obtain_lease do
+ logger.info("Linking Sentry issue #{sentry_issue_id} to GitLab issue #{issue.id}")
+
+ sentry_client.create_issue_link(integration_id, sentry_issue_id, issue)
+ rescue Sentry::Client::Error
+ logger.info("Failed to link Sentry issue #{sentry_issue_id} to GitLab issue #{issue.id}")
+ end
+ end
+
+ private
+
+ def valid?
+ issue && error_tracking && sentry_issue_id
+ end
+
+ def error_tracking
+ strong_memoize(:error_tracking) do
+ issue.project.error_tracking_setting
+ end
+ end
+
+ def sentry_issue_id
+ strong_memoize(:sentry_issue_id) do
+ issue.sentry_issue.sentry_issue_identifier
+ end
+ end
+
+ def sentry_client
+ error_tracking.sentry_client
+ end
+
+ def integration_id
+ strong_memoize(:integration_id) do
+ repo&.integration_id
+ end
+ end
+
+ def repo
+ sentry_client
+ .repos(organization_slug)
+ .find { |repo| repo.project_id == issue.project_id && repo.status == 'active' }
+ end
+
+ def organization_slug
+ error_tracking.organization_slug
+ end
+
+ def project_url
+ ::Gitlab::Routing.url_helpers.project_url(issue.project)
+ end
+
+ def lease_key
+ "link_sentry_issue_#{sentry_issue_id}_gitlab_#{issue.id}"
+ end
+
+ def lease_timeout
+ LEASE_TIMEOUT
+ end
+end
diff --git a/app/workers/expire_build_artifacts_worker.rb b/app/workers/expire_build_artifacts_worker.rb
index 383fd30e098..07f516a3390 100644
--- a/app/workers/expire_build_artifacts_worker.rb
+++ b/app/workers/expire_build_artifacts_worker.rb
@@ -2,7 +2,10 @@
class ExpireBuildArtifactsWorker
include ApplicationWorker
+ # rubocop:disable Scalability/CronWorkerContext
+ # This worker does not perform work scoped to a context
include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
feature_category :continuous_integration
diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb
index ab57c59ffda..1d204e0a19e 100644
--- a/app/workers/expire_pipeline_cache_worker.rb
+++ b/app/workers/expire_pipeline_cache_worker.rb
@@ -11,7 +11,7 @@ class ExpirePipelineCacheWorker
# rubocop: disable CodeReuse/ActiveRecord
def perform(pipeline_id)
pipeline = Ci::Pipeline.find_by(id: pipeline_id)
- return unless pipeline
+ return unless pipeline&.cacheable?
Ci::ExpirePipelineCacheService.new.execute(pipeline)
end
diff --git a/app/workers/gitlab/phabricator_import/base_worker.rb b/app/workers/gitlab/phabricator_import/base_worker.rb
new file mode 100644
index 00000000000..faae71d4627
--- /dev/null
+++ b/app/workers/gitlab/phabricator_import/base_worker.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+# All workers within a Phabricator import should inherit from this worker and
+# implement the `#import` method. The jobs should then be scheduled using the
+# `.schedule` class method instead of `.perform_async`
+#
+# Doing this makes sure that only one job of that type is running at the same time
+# for a certain project. This will avoid deadlocks. When a job is already running
+# we'll wait for it for 10 times 5 seconds to restart. If the running job hasn't
+# finished, by then, we'll retry in 30 seconds.
+#
+# It also makes sure that we keep the import state of the project up to date:
+# - It keeps track of the jobs so we know how many jobs are running for the
+# project
+# - It refreshes the import jid, so it doesn't get cleaned up by the
+# `StuckImportJobsWorker`
+# - It marks the import as failed if a job failed to many times
+# - It marks the import as finished when all remaining jobs are done
+module Gitlab
+ module PhabricatorImport
+ class BaseWorker
+ include WorkerAttributes
+ include Gitlab::ExclusiveLeaseHelpers
+
+ feature_category :importers
+
+ class << self
+ def schedule(project_id, *args)
+ perform_async(project_id, *args)
+ add_job(project_id)
+ end
+
+ def add_job(project_id)
+ worker_state(project_id).add_job
+ end
+
+ def remove_job(project_id)
+ worker_state(project_id).remove_job
+ end
+
+ def worker_state(project_id)
+ Gitlab::PhabricatorImport::WorkerState.new(project_id)
+ end
+ end
+
+ def perform(project_id, *args)
+ in_lock("#{self.class.name.underscore}/#{project_id}/#{args}", ttl: 2.hours, sleep_sec: 5.seconds) do
+ project = Project.find_by_id(project_id)
+ next unless project
+
+ # Bail if the import job already failed
+ next unless project.import_state&.in_progress?
+
+ project.import_state.refresh_jid_expiration
+
+ import(project, *args)
+
+ # If this is the last running job, finish the import
+ project.after_import if self.class.worker_state(project_id).running_count < 2
+
+ self.class.remove_job(project_id)
+ end
+ rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError
+ # Reschedule a job if there was already a running one
+ # Running them at the same time could cause a deadlock updating the same
+ # resource
+ self.class.perform_in(30.seconds, project_id, *args)
+ end
+
+ private
+
+ def import(project, *args)
+ importer_class.new(project, *args).execute
+ end
+
+ def importer_class
+ raise NotImplementedError, "Implement `#{__method__}` on #{self.class}"
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/phabricator_import/import_tasks_worker.rb b/app/workers/gitlab/phabricator_import/import_tasks_worker.rb
new file mode 100644
index 00000000000..b5d9e80797b
--- /dev/null
+++ b/app/workers/gitlab/phabricator_import/import_tasks_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+module Gitlab
+ module PhabricatorImport
+ class ImportTasksWorker < BaseWorker
+ include ApplicationWorker
+ include ProjectImportOptions # This marks the project as failed after too many tries
+
+ def importer_class
+ Gitlab::PhabricatorImport::Issues::Importer
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab_shell_worker.rb b/app/workers/gitlab_shell_worker.rb
index 57e64570c09..bd2225e6d7c 100644
--- a/app/workers/gitlab_shell_worker.rb
+++ b/app/workers/gitlab_shell_worker.rb
@@ -6,6 +6,7 @@ class GitlabShellWorker
feature_category :source_code_management
latency_sensitive_worker!
+ weight 2
def perform(action, *arg)
Gitlab::GitalyClient::NamespaceService.allow do
diff --git a/app/workers/gitlab_usage_ping_worker.rb b/app/workers/gitlab_usage_ping_worker.rb
index ad8302a844a..bf0dc0fdd59 100644
--- a/app/workers/gitlab_usage_ping_worker.rb
+++ b/app/workers/gitlab_usage_ping_worker.rb
@@ -4,7 +4,10 @@ class GitlabUsagePingWorker
LEASE_TIMEOUT = 86400
include ApplicationWorker
+ # rubocop:disable Scalability/CronWorkerContext
+ # This worker does not perform work scoped to a context
include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
feature_category_not_owned!
diff --git a/app/workers/group_export_worker.rb b/app/workers/group_export_worker.rb
index 51dbdc95661..a2d34e8c8bf 100644
--- a/app/workers/group_export_worker.rb
+++ b/app/workers/group_export_worker.rb
@@ -4,11 +4,11 @@ class GroupExportWorker
include ApplicationWorker
include ExceptionBacktrace
- feature_category :source_code_management
+ feature_category :importers
def perform(current_user_id, group_id, params = {})
current_user = User.find(current_user_id)
- group = Group.find(group_id)
+ group = Group.find(group_id)
::Groups::ImportExport::ExportService.new(group: group, user: current_user, params: params).execute
end
diff --git a/app/workers/group_import_worker.rb b/app/workers/group_import_worker.rb
new file mode 100644
index 00000000000..f283eab5814
--- /dev/null
+++ b/app/workers/group_import_worker.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class GroupImportWorker
+ include ApplicationWorker
+ include ExceptionBacktrace
+
+ feature_category :importers
+
+ def perform(user_id, group_id)
+ current_user = User.find(user_id)
+ group = Group.find(group_id)
+
+ ::Groups::ImportExport::ImportService.new(group: group, user: current_user).execute
+ end
+end
diff --git a/app/workers/import_export_project_cleanup_worker.rb b/app/workers/import_export_project_cleanup_worker.rb
index 07c29d40b54..ae236fa1fcd 100644
--- a/app/workers/import_export_project_cleanup_worker.rb
+++ b/app/workers/import_export_project_cleanup_worker.rb
@@ -2,7 +2,10 @@
class ImportExportProjectCleanupWorker
include ApplicationWorker
+ # rubocop:disable Scalability/CronWorkerContext
+ # This worker does not perform work scoped to a context
include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
feature_category :importers
diff --git a/app/workers/import_issues_csv_worker.rb b/app/workers/import_issues_csv_worker.rb
index d2733dc5f56..7c5584146ca 100644
--- a/app/workers/import_issues_csv_worker.rb
+++ b/app/workers/import_issues_csv_worker.rb
@@ -5,6 +5,7 @@ class ImportIssuesCsvWorker
feature_category :issue_tracking
worker_resource_boundary :cpu
+ weight 2
sidekiq_retries_exhausted do |job|
Upload.find(job['args'][2]).destroy
diff --git a/app/workers/incident_management/process_alert_worker.rb b/app/workers/incident_management/process_alert_worker.rb
new file mode 100644
index 00000000000..f3d5bc5c66b
--- /dev/null
+++ b/app/workers/incident_management/process_alert_worker.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module IncidentManagement
+ class ProcessAlertWorker
+ include ApplicationWorker
+
+ queue_namespace :incident_management
+ feature_category :incident_management
+
+ def perform(project_id, alert)
+ project = find_project(project_id)
+ return unless project
+
+ create_issue(project, alert)
+ end
+
+ private
+
+ def find_project(project_id)
+ Project.find_by_id(project_id)
+ end
+
+ def create_issue(project, alert)
+ IncidentManagement::CreateIssueService
+ .new(project, alert)
+ .execute
+ end
+ end
+end
diff --git a/app/workers/invalid_gpg_signature_update_worker.rb b/app/workers/invalid_gpg_signature_update_worker.rb
index 573efdf9fb1..e1c2eefbf0f 100644
--- a/app/workers/invalid_gpg_signature_update_worker.rb
+++ b/app/workers/invalid_gpg_signature_update_worker.rb
@@ -4,6 +4,7 @@ class InvalidGpgSignatureUpdateWorker
include ApplicationWorker
feature_category :source_code_management
+ weight 2
# rubocop: disable CodeReuse/ActiveRecord
def perform(gpg_key_id)
diff --git a/app/workers/issue_due_scheduler_worker.rb b/app/workers/issue_due_scheduler_worker.rb
index d4d47659ef0..59027907284 100644
--- a/app/workers/issue_due_scheduler_worker.rb
+++ b/app/workers/issue_due_scheduler_worker.rb
@@ -2,7 +2,7 @@
class IssueDueSchedulerWorker
include ApplicationWorker
- include CronjobQueue
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
feature_category :issue_tracking
@@ -10,7 +10,7 @@ class IssueDueSchedulerWorker
def perform
project_ids = Issue.opened.due_tomorrow.group(:project_id).pluck(:project_id).map { |id| [id] }
- MailScheduler::IssueDueWorker.bulk_perform_async(project_ids)
+ MailScheduler::IssueDueWorker.bulk_perform_async(project_ids) # rubocop:disable Scalability/BulkPerformWithContext
end
# rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/workers/mail_scheduler/notification_service_worker.rb b/app/workers/mail_scheduler/notification_service_worker.rb
index 4130ce25878..ec659e39b24 100644
--- a/app/workers/mail_scheduler/notification_service_worker.rb
+++ b/app/workers/mail_scheduler/notification_service_worker.rb
@@ -26,49 +26,26 @@ module MailScheduler
end
def self.perform_async(*args)
- super(*Arguments.serialize(args))
+ super(*ActiveJob::Arguments.serialize(args))
end
private
- # If an argument is in the ActiveJob::Arguments::TYPE_WHITELIST list,
+ # This is copied over from https://github.com/rails/rails/blob/v6.0.1/activejob/lib/active_job/arguments.rb#L50
+ # because it is declared as a private constant
+ PERMITTED_TYPES = [NilClass, String, Integer, Float, BigDecimal, TrueClass, FalseClass].freeze
+
+ private_constant :PERMITTED_TYPES
+
+ # If an argument is in the PERMITTED_TYPES list,
# it means the argument cannot be deserialized.
# Which means there's something wrong with our code.
def check_arguments!(args)
args.each do |arg|
- if arg.class.in?(ActiveJob::Arguments::TYPE_WHITELIST)
+ if arg.class.in?(PERMITTED_TYPES)
raise(ArgumentError, "Argument `#{arg}` cannot be deserialized because of its type")
end
end
end
-
- # Permit ActionController::Parameters for serializable Hash
- #
- # Port of
- # https://github.com/rails/rails/commit/945fdd76925c9f615bf016717c4c8db2b2955357#diff-fc90ec41ef75be8b2259526fe1a8b663
- module Arguments
- include ActiveJob::Arguments
- extend self
-
- private
-
- def serialize_argument(argument)
- case argument
- when -> (arg) { arg.respond_to?(:permitted?) }
- serialize_hash(argument.to_h).tap do |result|
- result[WITH_INDIFFERENT_ACCESS_KEY] = serialize_argument(true)
- end
- else
- super
- end
- end
- end
-
- # Make sure we remove this patch starting with Rails 6.0.
- if Rails.version.start_with?('6.0')
- raise <<~MSG
- Please remove the patch `Arguments` module and use `ActiveJob::Arguments` again.
- MSG
- end
end
end
diff --git a/app/workers/merge_request_mergeability_check_worker.rb b/app/workers/merge_request_mergeability_check_worker.rb
new file mode 100644
index 00000000000..ed35284b66c
--- /dev/null
+++ b/app/workers/merge_request_mergeability_check_worker.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class MergeRequestMergeabilityCheckWorker
+ include ApplicationWorker
+
+ feature_category :source_code_management
+
+ def perform(merge_request_id)
+ merge_request = MergeRequest.find_by_id(merge_request_id)
+
+ unless merge_request
+ logger.error("Failed to find merge request with ID: #{merge_request_id}")
+ return
+ end
+
+ result =
+ ::MergeRequests::MergeabilityCheckService
+ .new(merge_request)
+ .execute(recheck: false, retry_lease: false)
+
+ logger.error("Failed to check mergeability of merge request (#{merge_request_id}): #{result.message}") if result.error?
+ end
+end
diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb
index ed88c57e8d4..48bc205113f 100644
--- a/app/workers/merge_worker.rb
+++ b/app/workers/merge_worker.rb
@@ -5,6 +5,7 @@ class MergeWorker
feature_category :source_code_management
latency_sensitive_worker!
+ weight 5
def perform(merge_request_id, current_user_id, params)
params = params.with_indifferent_access
diff --git a/app/workers/namespaces/prune_aggregation_schedules_worker.rb b/app/workers/namespaces/prune_aggregation_schedules_worker.rb
index 9a5f533fe9a..aeb5aa37a10 100644
--- a/app/workers/namespaces/prune_aggregation_schedules_worker.rb
+++ b/app/workers/namespaces/prune_aggregation_schedules_worker.rb
@@ -3,7 +3,7 @@
module Namespaces
class PruneAggregationSchedulesWorker
include ApplicationWorker
- include CronjobQueue
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
feature_category :source_code_management
worker_resource_boundary :cpu
diff --git a/app/workers/new_issue_worker.rb b/app/workers/new_issue_worker.rb
index af9ca332d3c..d696165b447 100644
--- a/app/workers/new_issue_worker.rb
+++ b/app/workers/new_issue_worker.rb
@@ -7,6 +7,7 @@ class NewIssueWorker
feature_category :issue_tracking
latency_sensitive_worker!
worker_resource_boundary :cpu
+ weight 2
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 aa3f85c157b..e31ddae1f13 100644
--- a/app/workers/new_merge_request_worker.rb
+++ b/app/workers/new_merge_request_worker.rb
@@ -7,6 +7,7 @@ class NewMergeRequestWorker
feature_category :source_code_management
latency_sensitive_worker!
worker_resource_boundary :cpu
+ weight 2
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 2a5988a7e32..b446e376007 100644
--- a/app/workers/new_note_worker.rb
+++ b/app/workers/new_note_worker.rb
@@ -6,6 +6,7 @@ class NewNoteWorker
feature_category :issue_tracking
latency_sensitive_worker!
worker_resource_boundary :cpu
+ weight 2
# Keep extra parameter to preserve backwards compatibility with
# old `NewNoteWorker` jobs (can remove later)
diff --git a/app/workers/new_release_worker.rb b/app/workers/new_release_worker.rb
index a3a882f9343..edfdb2d7aff 100644
--- a/app/workers/new_release_worker.rb
+++ b/app/workers/new_release_worker.rb
@@ -5,6 +5,7 @@ class NewReleaseWorker
queue_namespace :notifications
feature_category :release_orchestration
+ weight 2
def perform(release_id)
release = Release.preloaded.find_by_id(release_id)
diff --git a/app/workers/pages_domain_removal_cron_worker.rb b/app/workers/pages_domain_removal_cron_worker.rb
index 07ecde55922..1c96dd6ad8c 100644
--- a/app/workers/pages_domain_removal_cron_worker.rb
+++ b/app/workers/pages_domain_removal_cron_worker.rb
@@ -8,8 +8,8 @@ class PagesDomainRemovalCronWorker
worker_resource_boundary :cpu
def perform
- PagesDomain.for_removal.find_each do |domain|
- domain.destroy!
+ PagesDomain.for_removal.with_logging_info.find_each do |domain|
+ with_context(project: domain.project) { domain.destroy! }
rescue => e
Gitlab::ErrorTracking.track_exception(e)
end
diff --git a/app/workers/pages_domain_ssl_renewal_cron_worker.rb b/app/workers/pages_domain_ssl_renewal_cron_worker.rb
index f7a243e9b3b..c1201b935d1 100644
--- a/app/workers/pages_domain_ssl_renewal_cron_worker.rb
+++ b/app/workers/pages_domain_ssl_renewal_cron_worker.rb
@@ -9,8 +9,10 @@ class PagesDomainSslRenewalCronWorker
def perform
return unless ::Gitlab::LetsEncrypt.enabled?
- PagesDomain.need_auto_ssl_renewal.find_each do |domain|
- PagesDomainSslRenewalWorker.perform_async(domain.id)
+ PagesDomain.need_auto_ssl_renewal.with_logging_info.find_each do |domain|
+ with_context(project: domain.project) do
+ PagesDomainSslRenewalWorker.perform_async(domain.id)
+ end
end
end
end
diff --git a/app/workers/pages_domain_verification_cron_worker.rb b/app/workers/pages_domain_verification_cron_worker.rb
index bb3a7fede9a..b06aa65a8e5 100644
--- a/app/workers/pages_domain_verification_cron_worker.rb
+++ b/app/workers/pages_domain_verification_cron_worker.rb
@@ -9,8 +9,10 @@ class PagesDomainVerificationCronWorker
def perform
return if Gitlab::Database.read_only?
- PagesDomain.needs_verification.find_each do |domain|
- PagesDomainVerificationWorker.perform_async(domain.id)
+ PagesDomain.needs_verification.with_logging_info.find_each do |domain|
+ with_context(project: domain.project) do
+ PagesDomainVerificationWorker.perform_async(domain.id)
+ end
end
end
end
diff --git a/app/workers/personal_access_tokens/expiring_worker.rb b/app/workers/personal_access_tokens/expiring_worker.rb
index f28109c4583..84f7ce9d5d7 100644
--- a/app/workers/personal_access_tokens/expiring_worker.rb
+++ b/app/workers/personal_access_tokens/expiring_worker.rb
@@ -12,11 +12,13 @@ module PersonalAccessTokens
limit_date = PersonalAccessToken::DAYS_TO_EXPIRE.days.from_now.to_date
User.with_expiring_and_not_notified_personal_access_tokens(limit_date).find_each do |user|
- notification_service.access_token_about_to_expire(user)
+ with_context(user: user) do
+ notification_service.access_token_about_to_expire(user)
- Rails.logger.info "#{self.class}: Notifying User #{user.id} about expiring tokens" # rubocop:disable Gitlab/RailsLogger
+ Rails.logger.info "#{self.class}: Notifying User #{user.id} about expiring tokens" # rubocop:disable Gitlab/RailsLogger
- user.personal_access_tokens.expiring_and_not_notified(limit_date).update_all(expire_notification_delivered: true)
+ user.personal_access_tokens.expiring_and_not_notified(limit_date).update_all(expire_notification_delivered: true)
+ end
end
end
end
diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb
index 19c3c5fcc2f..8b326b9dbb6 100644
--- a/app/workers/pipeline_schedule_worker.rb
+++ b/app/workers/pipeline_schedule_worker.rb
@@ -10,7 +10,9 @@ class PipelineScheduleWorker
def perform
Ci::PipelineSchedule.runnable_schedules.preloaded.find_in_batches do |schedules|
schedules.each do |schedule|
- Ci::PipelineScheduleService.new(schedule.project, schedule.owner).execute(schedule)
+ with_context(project: schedule.project, user: schedule.owner) do
+ Ci::PipelineScheduleService.new(schedule.project, schedule.owner).execute(schedule)
+ end
end
end
end
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index 334a98a0017..d5038f1152b 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -6,6 +6,7 @@ class PostReceive
feature_category :source_code_management
latency_sensitive_worker!
worker_resource_boundary :cpu
+ weight 5
def perform(gl_repository, identifier, changes, push_options = {})
project, repo_type = Gitlab::GlRepository.parse(gl_repository)
diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb
index 36af51d859e..ca2896946c9 100644
--- a/app/workers/process_commit_worker.rb
+++ b/app/workers/process_commit_worker.rb
@@ -12,6 +12,7 @@ class ProcessCommitWorker
feature_category :source_code_management
latency_sensitive_worker!
+ weight 3
# project_id - The ID of the project this commit belongs to.
# user_id - The ID of the user that pushed the commit.
diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb
index 11f3fed82cd..4d2cc3cd32d 100644
--- a/app/workers/project_export_worker.rb
+++ b/app/workers/project_export_worker.rb
@@ -5,7 +5,7 @@ class ProjectExportWorker
include ExceptionBacktrace
sidekiq_options retry: 3
- feature_category :source_code_management
+ feature_category :importers
worker_resource_boundary :memory
def perform(current_user_id, project_id, after_export_strategy = {}, params = {})
diff --git a/app/workers/prune_old_events_worker.rb b/app/workers/prune_old_events_worker.rb
index f421e8dbf59..835c51ec846 100644
--- a/app/workers/prune_old_events_worker.rb
+++ b/app/workers/prune_old_events_worker.rb
@@ -2,22 +2,19 @@
class PruneOldEventsWorker
include ApplicationWorker
+ # rubocop:disable Scalability/CronWorkerContext
+ # This worker does not perform work scoped to a context
include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
feature_category_not_owned!
- # rubocop: disable CodeReuse/ActiveRecord
+ DELETE_LIMIT = 10_000
+
def perform
# 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 < ?',
- (3.years + 1.day).ago)
- .select(:id)
- .limit(10_000))
- .delete_all
+ cutoff_date = (3.years + 1.day).ago
+
+ Event.unscoped.created_before(cutoff_date).delete_with_limit(DELETE_LIMIT)
end
- # rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/workers/prune_web_hook_logs_worker.rb b/app/workers/prune_web_hook_logs_worker.rb
index 8e48b45fc34..dd4f16a69da 100644
--- a/app/workers/prune_web_hook_logs_worker.rb
+++ b/app/workers/prune_web_hook_logs_worker.rb
@@ -4,27 +4,19 @@
# table.
class PruneWebHookLogsWorker
include ApplicationWorker
+ # rubocop:disable Scalability/CronWorkerContext
+ # This worker does not perform work scoped to a context
include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
feature_category :integrations
# The maximum number of rows to remove in a single job.
DELETE_LIMIT = 50_000
- # rubocop: disable CodeReuse/ActiveRecord
def perform
- # MySQL doesn't allow "DELETE FROM ... WHERE id IN ( ... )" if the inner
- # query refers to the same table. To work around this we wrap the IN body in
- # another sub query.
- WebHookLog
- .where(
- 'id IN (SELECT id FROM (?) ids_to_remove)',
- WebHookLog
- .select(:id)
- .where('created_at < ?', 90.days.ago.beginning_of_day)
- .limit(DELETE_LIMIT)
- )
- .delete_all
+ cutoff_date = 90.days.ago.beginning_of_day
+
+ WebHookLog.created_before(cutoff_date).delete_with_limit(DELETE_LIMIT)
end
- # rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/workers/reactive_caching_worker.rb b/app/workers/reactive_caching_worker.rb
index f3a83e0e8d4..6f82ad83137 100644
--- a/app/workers/reactive_caching_worker.rb
+++ b/app/workers/reactive_caching_worker.rb
@@ -25,5 +25,7 @@ class ReactiveCachingWorker
.reactive_cache_worker_finder
.call(id, *args)
.try(:exclusively_update_reactive_cache!, *args)
+ rescue ReactiveCaching::ExceededReactiveCacheLimit => e
+ Gitlab::ErrorTracking.track_exception(e)
end
end
diff --git a/app/workers/rebase_worker.rb b/app/workers/rebase_worker.rb
index fd182125c07..ddf5c31a1c2 100644
--- a/app/workers/rebase_worker.rb
+++ b/app/workers/rebase_worker.rb
@@ -6,6 +6,7 @@ class RebaseWorker
include ApplicationWorker
feature_category :source_code_management
+ weight 2
def perform(merge_request_id, current_user_id, skip_ci = false)
current_user = User.find(current_user_id)
diff --git a/app/workers/remote_mirror_notification_worker.rb b/app/workers/remote_mirror_notification_worker.rb
index 8bc19230caf..706131d4e4b 100644
--- a/app/workers/remote_mirror_notification_worker.rb
+++ b/app/workers/remote_mirror_notification_worker.rb
@@ -4,6 +4,7 @@ class RemoteMirrorNotificationWorker
include ApplicationWorker
feature_category :source_code_management
+ weight 2
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 a43e6fd11d5..db35dfb3ca8 100644
--- a/app/workers/remove_expired_group_links_worker.rb
+++ b/app/workers/remove_expired_group_links_worker.rb
@@ -2,7 +2,7 @@
class RemoveExpiredGroupLinksWorker
include ApplicationWorker
- include CronjobQueue
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
feature_category :authentication_and_authorization
diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb
index bf209fcec9f..278adee98e9 100644
--- a/app/workers/remove_expired_members_worker.rb
+++ b/app/workers/remove_expired_members_worker.rb
@@ -2,7 +2,7 @@
class RemoveExpiredMembersWorker
include ApplicationWorker
- include CronjobQueue
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
feature_category :authentication_and_authorization
worker_resource_boundary :cpu
diff --git a/app/workers/remove_unreferenced_lfs_objects_worker.rb b/app/workers/remove_unreferenced_lfs_objects_worker.rb
index 7f2c23f4685..5e3998f3915 100644
--- a/app/workers/remove_unreferenced_lfs_objects_worker.rb
+++ b/app/workers/remove_unreferenced_lfs_objects_worker.rb
@@ -2,9 +2,12 @@
class RemoveUnreferencedLfsObjectsWorker
include ApplicationWorker
+ # rubocop:disable Scalability/CronWorkerContext
+ # This worker does not perform work scoped to a context
include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
- feature_category :source_code_management
+ feature_category :git_lfs
def perform
LfsObject.destroy_unreferenced
diff --git a/app/workers/repository_archive_cache_worker.rb b/app/workers/repository_archive_cache_worker.rb
index ebc83c1b17a..76e08a80c15 100644
--- a/app/workers/repository_archive_cache_worker.rb
+++ b/app/workers/repository_archive_cache_worker.rb
@@ -2,7 +2,10 @@
class RepositoryArchiveCacheWorker
include ApplicationWorker
+ # rubocop:disable Scalability/CronWorkerContext
+ # This worker does not perform work scoped to a context
include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
feature_category :source_code_management
diff --git a/app/workers/repository_check/dispatch_worker.rb b/app/workers/repository_check/dispatch_worker.rb
index d2bd5f9b967..f68be8832eb 100644
--- a/app/workers/repository_check/dispatch_worker.rb
+++ b/app/workers/repository_check/dispatch_worker.rb
@@ -3,7 +3,10 @@
module RepositoryCheck
class DispatchWorker
include ApplicationWorker
+ # rubocop:disable Scalability/CronWorkerContext
+ # This worker does not perform work scoped to a context
include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
include ::EachShardWorker
include ExclusiveLeaseGuard
diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb
index 0adf745c7ac..ba141f808a7 100644
--- a/app/workers/repository_fork_worker.rb
+++ b/app/workers/repository_fork_worker.rb
@@ -29,7 +29,15 @@ class RepositoryForkWorker
result = gitlab_shell.fork_repository(source_project, target_project)
- raise "Unable to fork project #{target_project.id} for repository #{source_project.disk_path} -> #{target_project.disk_path}" unless result
+ if result
+ link_lfs_objects(source_project, target_project)
+ else
+ raise_fork_failure(
+ source_project,
+ target_project,
+ 'Failed to create fork repository'
+ )
+ end
target_project.after_import
end
@@ -40,4 +48,20 @@ class RepositoryForkWorker
Rails.logger.info("Project #{project.full_path} was in inconsistent state (#{project.import_status}) while forking.") # rubocop:disable Gitlab/RailsLogger
false
end
+
+ def link_lfs_objects(source_project, target_project)
+ Projects::LfsPointers::LfsLinkService
+ .new(target_project)
+ .execute(source_project.lfs_objects_oids)
+ rescue Projects::LfsPointers::LfsLinkService::TooManyOidsError
+ raise_fork_failure(
+ source_project,
+ target_project,
+ 'Source project has too many LFS objects'
+ )
+ end
+
+ def raise_fork_failure(source_project, target_project, reason)
+ raise "Unable to fork project #{target_project.id} for repository #{source_project.disk_path} -> #{target_project.disk_path}: #{reason}"
+ end
end
diff --git a/app/workers/requests_profiles_worker.rb b/app/workers/requests_profiles_worker.rb
index 6ab020afb10..b711cb99082 100644
--- a/app/workers/requests_profiles_worker.rb
+++ b/app/workers/requests_profiles_worker.rb
@@ -2,7 +2,10 @@
class RequestsProfilesWorker
include ApplicationWorker
+ # rubocop:disable Scalability/CronWorkerContext
+ # This worker does not perform work scoped to a context
include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
feature_category :source_code_management
diff --git a/app/workers/schedule_migrate_external_diffs_worker.rb b/app/workers/schedule_migrate_external_diffs_worker.rb
index 8abb5922b54..0e3c62cf282 100644
--- a/app/workers/schedule_migrate_external_diffs_worker.rb
+++ b/app/workers/schedule_migrate_external_diffs_worker.rb
@@ -2,7 +2,12 @@
class ScheduleMigrateExternalDiffsWorker
include ApplicationWorker
+ # rubocop:disable Scalability/CronWorkerContext:
+ # This schedules the `MigrateExternalDiffsWorker`
+ # issue for adding context: https://gitlab.com/gitlab-org/gitlab/issues/202100
include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext:
+
include Gitlab::ExclusiveLeaseHelpers
feature_category :source_code_management
diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb
index d08cea9e494..6e4ffa36854 100644
--- a/app/workers/stuck_ci_jobs_worker.rb
+++ b/app/workers/stuck_ci_jobs_worker.rb
@@ -56,13 +56,13 @@ class StuckCiJobsWorker
loop do
jobs = Ci::Build.where(status: status)
.where(condition, timeout.ago)
- .includes(:tags, :runner, project: :namespace)
+ .includes(:tags, :runner, project: [:namespace, :route])
.limit(100)
.to_a
break if jobs.empty?
jobs.each do |job|
- yield(job)
+ with_context(project: job.project) { yield(job) }
end
end
end
diff --git a/app/workers/stuck_import_jobs_worker.rb b/app/workers/stuck_import_jobs_worker.rb
index d9a9a613ca9..c9675417aa4 100644
--- a/app/workers/stuck_import_jobs_worker.rb
+++ b/app/workers/stuck_import_jobs_worker.rb
@@ -2,7 +2,11 @@
class StuckImportJobsWorker
include ApplicationWorker
+ # rubocop:disable Scalability/CronWorkerContext
+ # This worker updates several import states inline and does not schedule
+ # other jobs. So no context needed
include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
feature_category :importers
worker_resource_boundary :cpu
diff --git a/app/workers/stuck_merge_jobs_worker.rb b/app/workers/stuck_merge_jobs_worker.rb
index 024863ab530..9214ae038a8 100644
--- a/app/workers/stuck_merge_jobs_worker.rb
+++ b/app/workers/stuck_merge_jobs_worker.rb
@@ -2,7 +2,7 @@
class StuckMergeJobsWorker
include ApplicationWorker
- include CronjobQueue
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
feature_category :source_code_management
diff --git a/app/workers/trending_projects_worker.rb b/app/workers/trending_projects_worker.rb
index 4c8ee1ee425..208d8b3b9b5 100644
--- a/app/workers/trending_projects_worker.rb
+++ b/app/workers/trending_projects_worker.rb
@@ -2,7 +2,11 @@
class TrendingProjectsWorker
include ApplicationWorker
+ # rubocop:disable Scalability/CronWorkerContext
+ # This worker does not perform work scoped to a context
include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
feature_category :source_code_management
diff --git a/app/workers/update_external_pull_requests_worker.rb b/app/workers/update_external_pull_requests_worker.rb
index 8b0952528fa..e363b33f1b9 100644
--- a/app/workers/update_external_pull_requests_worker.rb
+++ b/app/workers/update_external_pull_requests_worker.rb
@@ -4,6 +4,7 @@ class UpdateExternalPullRequestsWorker
include ApplicationWorker
feature_category :source_code_management
+ weight 3
def perform(project_id, user_id, ref)
project = Project.find_by_id(project_id)
diff --git a/app/workers/update_merge_requests_worker.rb b/app/workers/update_merge_requests_worker.rb
index acb95353983..ec9739e8a11 100644
--- a/app/workers/update_merge_requests_worker.rb
+++ b/app/workers/update_merge_requests_worker.rb
@@ -6,6 +6,7 @@ class UpdateMergeRequestsWorker
feature_category :source_code_management
latency_sensitive_worker!
worker_resource_boundary :cpu
+ weight 3
LOG_TIME_THRESHOLD = 90 # seconds