summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/api.js29
-rw-r--r--app/assets/javascripts/autosave.js16
-rw-r--r--app/assets/javascripts/badges/components/badge.vue7
-rw-r--r--app/assets/javascripts/badges/components/badge_form.vue20
-rw-r--r--app/assets/javascripts/badges/components/badge_list_row.vue11
-rw-r--r--app/assets/javascripts/badges/empty_badge.js1
-rw-r--r--app/assets/javascripts/badges/store/actions.js10
-rw-r--r--app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/image.js2
-rw-r--r--app/assets/javascripts/blob/file_template_mediator.js2
-rw-r--r--app/assets/javascripts/blob/openapi/index.js19
-rw-r--r--app/assets/javascripts/blob/openapi_viewer.js3
-rw-r--r--app/assets/javascripts/blob/viewer/index.js3
-rw-r--r--app/assets/javascripts/boards/components/board.js26
-rw-r--r--app/assets/javascripts/boards/components/board_blank_state.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue5
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue3
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.vue2
-rw-r--r--app/assets/javascripts/boards/components/issue_count.vue36
-rw-r--r--app/assets/javascripts/boards/components/issue_due_date.vue4
-rw-r--r--app/assets/javascripts/boards/components/modal/index.vue2
-rw-r--r--app/assets/javascripts/boards/components/project_select.vue4
-rw-r--r--app/assets/javascripts/boards/constants.js2
-rw-r--r--app/assets/javascripts/boards/index.js45
-rw-r--r--app/assets/javascripts/boards/mixins/is_wip_limits.js7
-rw-r--r--app/assets/javascripts/boards/models/list.js31
-rw-r--r--app/assets/javascripts/boards/services/board_service.js98
-rw-r--r--app/assets/javascripts/boards/stores/state.js1
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js24
-rw-r--r--app/assets/javascripts/clusters/components/crossplane_provider_stack.vue10
-rw-r--r--app/assets/javascripts/clusters/components/knative_domain_editor.vue2
-rw-r--r--app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue168
-rw-r--r--app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue2
-rw-r--r--app/assets/javascripts/commit/image_file.js49
-rw-r--r--app/assets/javascripts/commons/polyfills.js1
-rw-r--r--app/assets/javascripts/confidential_merge_request/components/project_form_group.vue30
-rw-r--r--app/assets/javascripts/confirm_danger_modal.js57
-rw-r--r--app/assets/javascripts/contributors/components/contributors.vue2
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue24
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue15
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue4
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/index.js22
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js148
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/actions.js13
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js4
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/index.js30
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js3
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/state.js2
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_dropdown_mixin.js2
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue2
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue2
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue2
-rw-r--r--app/assets/javascripts/create_cluster/init_create_cluster.js20
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js1
-rw-r--r--app/assets/javascripts/cycle_analytics/components/banner.vue2
-rw-r--r--app/assets/javascripts/deploy_keys/components/app.vue4
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue2
-rw-r--r--app/assets/javascripts/diffs/components/app.vue90
-rw-r--r--app/assets/javascripts/diffs/components/diff_expansion_cell.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_gutter_avatars.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_gutter_content.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue2
-rw-r--r--app/assets/javascripts/diffs/constants.js1
-rw-r--r--app/assets/javascripts/diffs/index.js4
-rw-r--r--app/assets/javascripts/diffs/store/actions.js76
-rw-r--r--app/assets/javascripts/diffs/store/modules/diff_state.js2
-rw-r--r--app/assets/javascripts/diffs/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js41
-rw-r--r--app/assets/javascripts/diffs/store/utils.js7
-rw-r--r--app/assets/javascripts/emoji/no_emoji_validator.js2
-rw-r--r--app/assets/javascripts/environments/components/container.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue82
-rw-r--r--app/assets/javascripts/environments/components/environment_monitoring.vue2
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue6
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue54
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_view.vue12
-rw-r--r--app/assets/javascripts/environments/index.js2
-rw-r--r--app/assets/javascripts/environments/stores/environments_store.js2
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details.vue78
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue292
-rw-r--r--app/assets/javascripts/error_tracking/components/stacktrace.vue2
-rw-r--r--app/assets/javascripts/error_tracking/components/stacktrace_entry.vue63
-rw-r--r--app/assets/javascripts/error_tracking/details.js5
-rw-r--r--app/assets/javascripts/error_tracking/services/index.js4
-rw-r--r--app/assets/javascripts/error_tracking/store/details/getters.js5
-rw-r--r--app/assets/javascripts/error_tracking/store/index.js2
-rw-r--r--app/assets/javascripts/error_tracking/store/list/actions.js49
-rw-r--r--app/assets/javascripts/error_tracking/store/list/getters.js4
-rw-r--r--app/assets/javascripts/error_tracking/store/list/mutation_types.js9
-rw-r--r--app/assets/javascripts/error_tracking/store/list/mutations.js51
-rw-r--r--app/assets/javascripts/error_tracking/store/list/state.js7
-rw-r--r--app/assets/javascripts/error_tracking/utils.js13
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue10
-rw-r--r--app/assets/javascripts/filtered_search/.eslintrc.yml3
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js2
-rw-r--r--app/assets/javascripts/filtered_search/visual_token_value.js2
-rw-r--r--app/assets/javascripts/frequent_items/components/app.vue2
-rw-r--r--app/assets/javascripts/frequent_items/store/mutations.js2
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js68
-rw-r--r--app/assets/javascripts/gl_dropdown.js1661
-rw-r--r--app/assets/javascripts/grafana_integration/components/grafana_integration.vue2
-rw-r--r--app/assets/javascripts/groups/components/app.vue2
-rw-r--r--app/assets/javascripts/groups/components/item_stats.vue7
-rw-r--r--app/assets/javascripts/groups/mixins/is_project_pending_removal.js7
-rw-r--r--app/assets/javascripts/groups/store/groups_store.js1
-rw-r--r--app/assets/javascripts/helpers/monitor_helper.js1
-rw-r--r--app/assets/javascripts/ide/.eslintrc.yml2
-rw-r--r--app/assets/javascripts/ide/components/branches/search_list.vue2
-rw-r--r--app/assets/javascripts/ide/components/file_templates/dropdown.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide_status_bar.vue6
-rw-r--r--app/assets/javascripts/ide/components/ide_tree_list.vue2
-rw-r--r--app/assets/javascripts/ide/components/merge_requests/list.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue2
-rw-r--r--app/assets/javascripts/ide/components/preview/navigator.vue2
-rw-r--r--app/assets/javascripts/ide/components/repo_tab.vue2
-rw-r--r--app/assets/javascripts/ide/lib/files.js3
-rw-r--r--app/assets/javascripts/ide/services/index.js3
-rw-r--r--app/assets/javascripts/ide/stores/actions.js14
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js4
-rw-r--r--app/assets/javascripts/ide/stores/getters.js25
-rw-r--r--app/assets/javascripts/ide/stores/utils.js3
-rw-r--r--app/assets/javascripts/image_diff/.eslintrc.yml3
-rw-r--r--app/assets/javascripts/issuable_form.js54
-rw-r--r--app/assets/javascripts/issuable_suggestions/components/item.vue2
-rw-r--r--app/assets/javascripts/jobs/components/sidebar.vue6
-rw-r--r--app/assets/javascripts/jobs/components/trigger_block.vue2
-rw-r--r--app/assets/javascripts/jobs/store/utils.js2
-rw-r--r--app/assets/javascripts/lib/utils/axios_utils.js4
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js22
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js162
-rw-r--r--app/assets/javascripts/lib/utils/http_status.js1
-rw-r--r--app/assets/javascripts/lib/utils/logoutput_behaviours.js47
-rw-r--r--app/assets/javascripts/lib/utils/suppress_ajax_errors_during_navigation.js4
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js38
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js2
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js104
-rw-r--r--app/assets/javascripts/main.js1
-rw-r--r--app/assets/javascripts/monitoring/components/charts/anomaly.vue13
-rw-r--r--app/assets/javascripts/monitoring/components/charts/column.vue12
-rw-r--r--app/assets/javascripts/monitoring/components/charts/heatmap.vue10
-rw-r--r--app/assets/javascripts/monitoring/components/charts/single_stat.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue42
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue152
-rw-r--r--app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/embed.vue30
-rw-r--r--app/assets/javascripts/monitoring/components/empty_state.vue6
-rw-r--r--app/assets/javascripts/monitoring/components/graph_group.vue30
-rw-r--r--app/assets/javascripts/monitoring/components/group_empty_state.vue105
-rw-r--r--app/assets/javascripts/monitoring/components/panel_type.vue29
-rw-r--r--app/assets/javascripts/monitoring/constants.js47
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle.js2
-rw-r--r--app/assets/javascripts/monitoring/monitoring_tracking_helper.js10
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js85
-rw-r--r--app/assets/javascripts/monitoring/stores/getters.js62
-rw-r--r--app/assets/javascripts/monitoring/stores/index.js2
-rw-r--r--app/assets/javascripts/monitoring/stores/mutation_types.js8
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js143
-rw-r--r--app/assets/javascripts/monitoring/stores/state.js5
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js89
-rw-r--r--app/assets/javascripts/monitoring/utils.js26
-rw-r--r--app/assets/javascripts/mr_popover/components/mr_popover.vue2
-rw-r--r--app/assets/javascripts/mr_tabs_popover/components/popover.vue64
-rw-r--r--app/assets/javascripts/mr_tabs_popover/index.js12
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue73
-rw-r--r--app/assets/javascripts/notes.js163
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue17
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue2
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue8
-rw-r--r--app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue2
-rw-r--r--app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue14
-rw-r--r--app/assets/javascripts/notes/components/discussion_locked_widget.vue13
-rw-r--r--app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_awards_list.vue14
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue2
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue29
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue2
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue11
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js24
-rw-r--r--app/assets/javascripts/notes/mixins/issuable_state.js6
-rw-r--r--app/assets/javascripts/notes/stores/actions.js45
-rw-r--r--app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue2
-rw-r--r--app/assets/javascripts/pages/dashboard/merge_requests/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/issues/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/merge_requests/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/new/group_path_validator.js2
-rw-r--r--app/assets/javascripts/pages/groups/registry/repositories/index.js2
-rw-r--r--app/assets/javascripts/pages/instance_statistics/dev_ops_score/index.js (renamed from app/assets/javascripts/pages/instance_statistics/conversational_development_index/index.js)0
-rw-r--r--app/assets/javascripts/pages/profiles/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/issues/form.js2
-rw-r--r--app/assets/javascripts/pages/projects/issues/index/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/index/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js7
-rw-r--r--app/assets/javascripts/pages/projects/pages_domains/show/index.js (renamed from app/assets/javascripts/pages/projects/pages_domains/edit/index.js)0
-rw-r--r--app/assets/javascripts/pages/projects/project.js24
-rw-r--r--app/assets/javascripts/pages/projects/registry/repositories/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/snippets/show/index.js15
-rw-r--r--app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue2
-rw-r--r--app/assets/javascripts/pages/sessions/new/username_validator.js2
-rw-r--r--app/assets/javascripts/pages/snippets/show/index.js15
-rw-r--r--app/assets/javascripts/pages/users/index.js2
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue17
-rw-r--r--app/assets/javascripts/performance_bar/components/request_selector.vue14
-rw-r--r--app/assets/javascripts/performance_bar/components/request_warning.vue2
-rw-r--r--app/assets/javascripts/performance_bar/index.js46
-rw-r--r--app/assets/javascripts/performance_bar/stores/performance_bar_store.js29
-rw-r--r--app/assets/javascripts/persistent_user_callout.js8
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue7
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/time_ago.vue6
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/utils.js2
-rw-r--r--app/assets/javascripts/project_find_file.js5
-rw-r--r--app/assets/javascripts/projects/project_new.js20
-rw-r--r--app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue2
-rw-r--r--app/assets/javascripts/registry/list/components/app.vue (renamed from app/assets/javascripts/registry/components/app.vue)2
-rw-r--r--app/assets/javascripts/registry/list/components/collapsible_container.vue (renamed from app/assets/javascripts/registry/components/collapsible_container.vue)9
-rw-r--r--app/assets/javascripts/registry/list/components/group_empty_state.vue (renamed from app/assets/javascripts/registry/components/group_empty_state.vue)0
-rw-r--r--app/assets/javascripts/registry/list/components/project_empty_state.vue (renamed from app/assets/javascripts/registry/components/project_empty_state.vue)0
-rw-r--r--app/assets/javascripts/registry/list/components/table_registry.vue (renamed from app/assets/javascripts/registry/components/table_registry.vue)9
-rw-r--r--app/assets/javascripts/registry/list/constants.js (renamed from app/assets/javascripts/registry/constants.js)2
-rw-r--r--app/assets/javascripts/registry/list/index.js (renamed from app/assets/javascripts/registry/index.js)2
-rw-r--r--app/assets/javascripts/registry/list/stores/actions.js (renamed from app/assets/javascripts/registry/stores/actions.js)0
-rw-r--r--app/assets/javascripts/registry/list/stores/getters.js (renamed from app/assets/javascripts/registry/stores/getters.js)0
-rw-r--r--app/assets/javascripts/registry/list/stores/index.js (renamed from app/assets/javascripts/registry/stores/index.js)0
-rw-r--r--app/assets/javascripts/registry/list/stores/mutation_types.js (renamed from app/assets/javascripts/registry/stores/mutation_types.js)0
-rw-r--r--app/assets/javascripts/registry/list/stores/mutations.js (renamed from app/assets/javascripts/registry/stores/mutations.js)0
-rw-r--r--app/assets/javascripts/registry/list/stores/state.js (renamed from app/assets/javascripts/registry/stores/state.js)0
-rw-r--r--app/assets/javascripts/registry/settings/components/registry_settings_app.vue43
-rw-r--r--app/assets/javascripts/registry/settings/registry_settings_bundle.js24
-rw-r--r--app/assets/javascripts/registry/settings/stores/actions.js6
-rw-r--r--app/assets/javascripts/registry/settings/stores/index.js16
-rw-r--r--app/assets/javascripts/registry/settings/stores/mutation_types.js4
-rw-r--r--app/assets/javascripts/registry/settings/stores/mutations.js8
-rw-r--r--app/assets/javascripts/registry/settings/stores/state.js10
-rw-r--r--app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue2
-rw-r--r--app/assets/javascripts/releases/list/components/app.vue20
-rw-r--r--app/assets/javascripts/releases/list/components/evidence_block.vue76
-rw-r--r--app/assets/javascripts/releases/list/components/release_block.vue62
-rw-r--r--app/assets/javascripts/releases/list/components/release_block_footer.vue4
-rw-r--r--app/assets/javascripts/releases/list/components/release_block_milestone_info.vue136
-rw-r--r--app/assets/javascripts/releases/list/constants.js7
-rw-r--r--app/assets/javascripts/releases/list/store/actions.js13
-rw-r--r--app/assets/javascripts/releases/list/store/mutations.js6
-rw-r--r--app/assets/javascripts/releases/list/store/state.js1
-rw-r--r--app/assets/javascripts/reports/components/report_item.vue2
-rw-r--r--app/assets/javascripts/reports/components/summary_row.vue2
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue2
-rw-r--r--app/assets/javascripts/repository/components/preview/index.vue12
-rw-r--r--app/assets/javascripts/repository/index.js8
-rw-r--r--app/assets/javascripts/repository/log_tree.js10
-rw-r--r--app/assets/javascripts/repository/utils/commit.js3
-rw-r--r--app/assets/javascripts/repository/utils/dom.js9
-rw-r--r--app/assets/javascripts/serverless/components/area.vue2
-rw-r--r--app/assets/javascripts/serverless/components/functions.vue17
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue5
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_title.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/todo.vue13
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js2
-rw-r--r--app/assets/javascripts/snippet/snippet_embed.js27
-rw-r--r--app/assets/javascripts/snippets/components/app.vue50
-rw-r--r--app/assets/javascripts/snippets/components/snippet_header.vue241
-rw-r--r--app/assets/javascripts/snippets/fragments/author.fragment.graphql8
-rw-r--r--app/assets/javascripts/snippets/fragments/project.fragment.graphql6
-rw-r--r--app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql13
-rw-r--r--app/assets/javascripts/snippets/index.js34
-rw-r--r--app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql5
-rw-r--r--app/assets/javascripts/snippets/queries/projectPermissions.query.graphql7
-rw-r--r--app/assets/javascripts/snippets/queries/snippet.query.graphql15
-rw-r--r--app/assets/javascripts/snippets/queries/userPermissions.query.graphql7
-rw-r--r--app/assets/javascripts/tracking.js27
-rw-r--r--app/assets/javascripts/user_popovers.js1
-rw-r--r--app/assets/javascripts/users_select.js138
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment.vue245
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue108
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue98
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_stop_button.vue83
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue99
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue (renamed from app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue)18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/loading.vue29
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue24
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue25
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue51
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue77
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js11
-rw-r--r--app/assets/javascripts/vue_shared/components/bar_chart.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/expand_button.vue18
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_countdown.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/memory_graph.vue129
-rw-r--r--app/assets/javascripts/vue_shared/components/modal_copy_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue138
-rw-r--r--app/assets/javascripts/vue_shared/components/project_avatar/image.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/split_button.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue2
-rw-r--r--app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js6
-rw-r--r--app/assets/javascripts/vue_shared/mixins/timeago.js2
-rw-r--r--app/assets/stylesheets/components/release_block_milestone_info.scss6
-rw-r--r--app/assets/stylesheets/components/toast.scss52
-rw-r--r--app/assets/stylesheets/framework/common.scss8
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss2
-rw-r--r--app/assets/stylesheets/framework/files.scss9
-rw-r--r--app/assets/stylesheets/framework/filters.scss2
-rw-r--r--app/assets/stylesheets/framework/header.scss5
-rw-r--r--app/assets/stylesheets/framework/lists.scss5
-rw-r--r--app/assets/stylesheets/framework/memory_graph.scss18
-rw-r--r--app/assets/stylesheets/framework/selects.scss2
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss4
-rw-r--r--app/assets/stylesheets/framework/typography.scss6
-rw-r--r--app/assets/stylesheets/framework/variables.scss13
-rw-r--r--app/assets/stylesheets/framework/wells.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss14
-rw-r--r--app/assets/stylesheets/pages/boards.scss4
-rw-r--r--app/assets/stylesheets/pages/dev_ops_score.scss (renamed from app/assets/stylesheets/pages/convdev_index.scss)36
-rw-r--r--app/assets/stylesheets/pages/diff.scss2
-rw-r--r--app/assets/stylesheets/pages/editor.scss3
-rw-r--r--app/assets/stylesheets/pages/environments.scss1
-rw-r--r--app/assets/stylesheets/pages/error_details.scss6
-rw-r--r--app/assets/stylesheets/pages/error_tracking_list.scss5
-rw-r--r--app/assets/stylesheets/pages/groups.scss1
-rw-r--r--app/assets/stylesheets/pages/issuable.scss2
-rw-r--r--app/assets/stylesheets/pages/issues.scss49
-rw-r--r--app/assets/stylesheets/pages/issues/issue_count_badge.scss1
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss5
-rw-r--r--app/assets/stylesheets/pages/profile.scss141
-rw-r--r--app/assets/stylesheets/pages/projects.scss6
-rw-r--r--app/assets/stylesheets/pages/prometheus.scss1
-rw-r--r--app/assets/stylesheets/pages/tree.scss6
-rw-r--r--app/assets/stylesheets/utilities.scss20
-rw-r--r--app/controllers/admin/broadcast_messages_controller.rb2
-rw-r--r--app/controllers/admin/identities_controller.rb6
-rw-r--r--app/controllers/admin/jobs_controller.rb20
-rw-r--r--app/controllers/admin/sessions_controller.rb29
-rw-r--r--app/controllers/application_controller.rb40
-rw-r--r--app/controllers/autocomplete_controller.rb16
-rw-r--r--app/controllers/boards/lists_controller.rb2
-rw-r--r--app/controllers/clusters/clusters_controller.rb59
-rw-r--r--app/controllers/concerns/boards_actions.rb5
-rw-r--r--app/controllers/concerns/confirm_email_warning.rb7
-rw-r--r--app/controllers/concerns/cycle_analytics_params.rb3
-rw-r--r--app/controllers/concerns/enforces_admin_authentication.rb1
-rw-r--r--app/controllers/concerns/initializes_current_user_mode.rb13
-rw-r--r--app/controllers/concerns/issuable_actions.rb12
-rw-r--r--app/controllers/concerns/membership_actions.rb11
-rw-r--r--app/controllers/concerns/notes_actions.rb6
-rw-r--r--app/controllers/concerns/service_params.rb1
-rw-r--r--app/controllers/concerns/sessionless_authentication.rb4
-rw-r--r--app/controllers/concerns/sourcegraph_gon.rb2
-rw-r--r--app/controllers/concerns/uploads_actions.rb38
-rw-r--r--app/controllers/groups/group_members_controller.rb9
-rw-r--r--app/controllers/groups_controller.rb1
-rw-r--r--app/controllers/instance_statistics/conversational_development_index_controller.rb9
-rw-r--r--app/controllers/instance_statistics/dev_ops_score_controller.rb9
-rw-r--r--app/controllers/oauth/applications_controller.rb1
-rw-r--r--app/controllers/oauth/authorizations_controller.rb2
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb25
-rw-r--r--app/controllers/projects/branches_controller.rb10
-rw-r--r--app/controllers/projects/ci/lints_controller.rb10
-rw-r--r--app/controllers/projects/environments/prometheus_api_controller.rb36
-rw-r--r--app/controllers/projects/environments/sample_metrics_controller.rb13
-rw-r--r--app/controllers/projects/environments_controller.rb34
-rw-r--r--app/controllers/projects/error_tracking_controller.rb15
-rw-r--r--app/controllers/projects/hook_logs_controller.rb10
-rw-r--r--app/controllers/projects/issues_controller.rb7
-rw-r--r--app/controllers/projects/jobs_controller.rb33
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb29
-rw-r--r--app/controllers/projects/merge_requests_controller.rb19
-rw-r--r--app/controllers/projects/pages_controller.rb3
-rw-r--r--app/controllers/projects/pages_domains_controller.rb10
-rw-r--r--app/controllers/projects/pipeline_schedules_controller.rb12
-rw-r--r--app/controllers/projects/pipelines_controller.rb1
-rw-r--r--app/controllers/projects/project_members_controller.rb2
-rw-r--r--app/controllers/projects/raw_controller.rb20
-rw-r--r--app/controllers/projects/releases_controller.rb12
-rw-r--r--app/controllers/projects/service_hook_logs_controller.rb20
-rw-r--r--app/controllers/projects/services_controller.rb7
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb10
-rw-r--r--app/controllers/projects_controller.rb18
-rw-r--r--app/controllers/snippets_controller.rb10
-rw-r--r--app/controllers/uploads_controller.rb1
-rw-r--r--app/finders/clusters/knative_serving_namespace_finder.rb17
-rw-r--r--app/finders/clusters/knative_version_role_binding_finder.rb17
-rw-r--r--app/finders/deployments_finder.rb70
-rw-r--r--app/finders/group_members_finder.rb10
-rw-r--r--app/finders/group_projects_finder.rb14
-rw-r--r--app/finders/groups_finder.rb2
-rw-r--r--app/finders/issuable_finder.rb27
-rw-r--r--app/finders/issues_finder.rb2
-rw-r--r--app/finders/jobs_finder.rb51
-rw-r--r--app/finders/keys_finder.rb84
-rw-r--r--app/finders/members_finder.rb20
-rw-r--r--app/finders/merge_request_target_project_finder.rb20
-rw-r--r--app/finders/merge_requests_finder.rb1
-rw-r--r--app/finders/personal_access_tokens_finder.rb16
-rw-r--r--app/finders/pipelines_finder.rb10
-rw-r--r--app/finders/projects_finder.rb26
-rw-r--r--app/finders/snippets_finder.rb53
-rw-r--r--app/finders/user_finder.rb6
-rw-r--r--app/graphql/mutations/issues/base.rb34
-rw-r--r--app/graphql/mutations/issues/set_confidential.rb27
-rw-r--r--app/graphql/mutations/issues/set_due_date.rb27
-rw-r--r--app/graphql/mutations/snippets/base.rb30
-rw-r--r--app/graphql/mutations/snippets/create.rb77
-rw-r--r--app/graphql/mutations/snippets/destroy.rb33
-rw-r--r--app/graphql/mutations/snippets/mark_as_spam.rb39
-rw-r--r--app/graphql/mutations/snippets/update.rb54
-rw-r--r--app/graphql/mutations/todos/base.rb6
-rw-r--r--app/graphql/mutations/todos/mark_all_done.rb35
-rw-r--r--app/graphql/mutations/todos/mark_done.rb7
-rw-r--r--app/graphql/mutations/todos/restore.rb36
-rw-r--r--app/graphql/resolvers/base_resolver.rb10
-rw-r--r--app/graphql/resolvers/concerns/resolves_snippets.rb57
-rw-r--r--app/graphql/resolvers/echo_resolver.rb4
-rw-r--r--app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb28
-rw-r--r--app/graphql/resolvers/issues_resolver.rb14
-rw-r--r--app/graphql/resolvers/projects/snippets_resolver.rb23
-rw-r--r--app/graphql/resolvers/snippets_resolver.rb45
-rw-r--r--app/graphql/resolvers/todo_resolver.rb50
-rw-r--r--app/graphql/resolvers/users/snippets_resolver.rb21
-rw-r--r--app/graphql/types/diff_refs_type.rb9
-rw-r--r--app/graphql/types/error_tracking/sentry_detailed_error_type.rb93
-rw-r--r--app/graphql/types/error_tracking/sentry_error_frequency_type.rb18
-rw-r--r--app/graphql/types/error_tracking/sentry_error_status_enum.rb15
-rw-r--r--app/graphql/types/issuable_sort_enum.rb2
-rw-r--r--app/graphql/types/issue_sort_enum.rb4
-rw-r--r--app/graphql/types/issue_state_enum.rb3
-rw-r--r--app/graphql/types/label_type.rb2
-rw-r--r--app/graphql/types/merge_request_state_enum.rb3
-rw-r--r--app/graphql/types/merge_request_type.rb2
-rw-r--r--app/graphql/types/mutation_type.rb8
-rw-r--r--app/graphql/types/notes/diff_position_type.rb24
-rw-r--r--app/graphql/types/notes/discussion_type.rb12
-rw-r--r--app/graphql/types/notes/note_type.rb32
-rw-r--r--app/graphql/types/notes/noteable_type.rb2
-rw-r--r--app/graphql/types/permission_types/project.rb8
-rw-r--r--app/graphql/types/permission_types/snippet.rb16
-rw-r--r--app/graphql/types/permission_types/user.rb15
-rw-r--r--app/graphql/types/project_type.rb14
-rw-r--r--app/graphql/types/query_type.rb10
-rw-r--r--app/graphql/types/root_storage_statistics_type.rb2
-rw-r--r--app/graphql/types/snippet_type.rb69
-rw-r--r--app/graphql/types/snippets/type_enum.rb10
-rw-r--r--app/graphql/types/snippets/visibility_scopes_enum.rb11
-rw-r--r--app/graphql/types/user_type.rb8
-rw-r--r--app/graphql/types/visibility_levels_enum.rb9
-rw-r--r--app/helpers/application_helper.rb19
-rw-r--r--app/helpers/application_settings_helper.rb4
-rw-r--r--app/helpers/award_emoji_helper.rb2
-rw-r--r--app/helpers/blob_helper.rb21
-rw-r--r--app/helpers/broadcast_messages_helper.rb6
-rw-r--r--app/helpers/clusters_helper.rb6
-rw-r--r--app/helpers/container_expiration_policies_helper.rb21
-rw-r--r--app/helpers/dev_ops_score_helper.rb (renamed from app/helpers/conversational_development_index_helper.rb)2
-rw-r--r--app/helpers/diff_helper.rb18
-rw-r--r--app/helpers/emails_helper.rb26
-rw-r--r--app/helpers/environments_helper.rb1
-rw-r--r--app/helpers/git_helper.rb7
-rw-r--r--app/helpers/gitlab_routing_helper.rb91
-rw-r--r--app/helpers/hooks_helper.rb38
-rw-r--r--app/helpers/icons_helper.rb10
-rw-r--r--app/helpers/issuables_helper.rb33
-rw-r--r--app/helpers/issues_helper.rb2
-rw-r--r--app/helpers/labels_helper.rb4
-rw-r--r--app/helpers/merge_requests_helper.rb20
-rw-r--r--app/helpers/nav_helper.rb2
-rw-r--r--app/helpers/notes_helper.rb6
-rw-r--r--app/helpers/projects/error_tracking_helper.rb1
-rw-r--r--app/helpers/projects_helper.rb10
-rw-r--r--app/helpers/search_helper.rb7
-rw-r--r--app/helpers/services_helper.rb20
-rw-r--r--app/helpers/snippets_helper.rb43
-rw-r--r--app/helpers/todos_helper.rb2
-rw-r--r--app/helpers/tree_helper.rb4
-rw-r--r--app/helpers/user_callouts_helper.rb5
-rw-r--r--app/helpers/users_helper.rb10
-rw-r--r--app/mailers/emails/notes.rb2
-rw-r--r--app/mailers/emails/profile.rb14
-rw-r--r--app/models/active_session.rb51
-rw-r--r--app/models/application_setting.rb14
-rw-r--r--app/models/application_setting_implementation.rb9
-rw-r--r--app/models/badge.rb2
-rw-r--r--app/models/blob.rb2
-rw-r--r--app/models/blob_viewer/open_api.rb15
-rw-r--r--app/models/broadcast_message.rb82
-rw-r--r--app/models/ci/build.rb65
-rw-r--r--app/models/ci/build_need.rb1
-rw-r--r--app/models/ci/build_trace_section.rb3
-rw-r--r--app/models/ci/legacy_stage.rb1
-rw-r--r--app/models/ci/persistent_ref.rb20
-rw-r--r--app/models/ci/pipeline.rb102
-rw-r--r--app/models/ci/pipeline_enums.rb16
-rw-r--r--app/models/ci/runner.rb3
-rw-r--r--app/models/clusters/applications/elastic_stack.rb2
-rw-r--r--app/models/clusters/applications/knative.rb24
-rw-r--r--app/models/clusters/applications/prometheus.rb29
-rw-r--r--app/models/clusters/applications/runner.rb2
-rw-r--r--app/models/clusters/cluster.rb70
-rw-r--r--app/models/clusters/concerns/application_core.rb2
-rw-r--r--app/models/clusters/platforms/kubernetes.rb15
-rw-r--r--app/models/clusters/providers/aws.rb4
-rw-r--r--app/models/commit.rb23
-rw-r--r--app/models/commit_status.rb12
-rw-r--r--app/models/commit_user_mention.rb5
-rw-r--r--app/models/compare.rb1
-rw-r--r--app/models/concerns/acts_as_paginated_diff.rb11
-rw-r--r--app/models/concerns/analytics/cycle_analytics/stage.rb1
-rw-r--r--app/models/concerns/blob_active_model.rb19
-rw-r--r--app/models/concerns/ci/contextable.rb54
-rw-r--r--app/models/concerns/ci/metadatable.rb8
-rw-r--r--app/models/concerns/ci/pipeline_delegator.rb2
-rw-r--r--app/models/concerns/ci/processable.rb2
-rw-r--r--app/models/concerns/diff_positionable_note.rb2
-rw-r--r--app/models/concerns/expirable.rb4
-rw-r--r--app/models/concerns/group_descendant.rb6
-rw-r--r--app/models/concerns/ignorable_columns.rb45
-rw-r--r--app/models/concerns/issuable.rb35
-rw-r--r--app/models/concerns/issuable_states.rb23
-rw-r--r--app/models/concerns/mentionable.rb80
-rw-r--r--app/models/concerns/milestoneish.rb2
-rw-r--r--app/models/concerns/prometheus_adapter.rb8
-rw-r--r--app/models/concerns/reactive_caching.rb5
-rw-r--r--app/models/concerns/safe_url.rb15
-rw-r--r--app/models/concerns/sha256_attribute.rb49
-rw-r--r--app/models/concerns/storage/legacy_namespace.rb6
-rw-r--r--app/models/concerns/update_project_statistics.rb28
-rw-r--r--app/models/container_expiration_policy.rb41
-rw-r--r--app/models/dashboard_group_milestone.rb4
-rw-r--r--app/models/dashboard_milestone.rb4
-rw-r--r--app/models/deploy_key.rb13
-rw-r--r--app/models/deployment.rb25
-rw-r--r--app/models/dev_ops_score/card.rb (renamed from app/models/conversational_development_index/card.rb)2
-rw-r--r--app/models/dev_ops_score/idea_to_production_step.rb (renamed from app/models/conversational_development_index/idea_to_production_step.rb)2
-rw-r--r--app/models/dev_ops_score/metric.rb (renamed from app/models/conversational_development_index/metric.rb)2
-rw-r--r--app/models/diff_note.rb10
-rw-r--r--app/models/discussion.rb4
-rw-r--r--app/models/environment.rb19
-rw-r--r--app/models/environment_status.rb24
-rw-r--r--app/models/epic.rb4
-rw-r--r--app/models/error_tracking/project_error_tracking_setting.rb7
-rw-r--r--app/models/group_milestone.rb4
-rw-r--r--app/models/hooks/project_hook.rb5
-rw-r--r--app/models/hooks/service_hook.rb2
-rw-r--r--app/models/hooks/system_hook.rb8
-rw-r--r--app/models/hooks/web_hook.rb4
-rw-r--r--app/models/hooks/web_hook_log.rb11
-rw-r--r--app/models/import_failure.rb7
-rw-r--r--app/models/issue.rb15
-rw-r--r--app/models/issue/metrics.rb6
-rw-r--r--app/models/issue_user_mention.rb6
-rw-r--r--app/models/key.rb13
-rw-r--r--app/models/list.rb2
-rw-r--r--app/models/merge_request.rb85
-rw-r--r--app/models/merge_request/pipelines.rb86
-rw-r--r--app/models/merge_request_diff.rb63
-rw-r--r--app/models/merge_request_user_mention.rb6
-rw-r--r--app/models/milestone.rb12
-rw-r--r--app/models/milestone_release.rb3
-rw-r--r--app/models/namespace.rb4
-rw-r--r--app/models/note.rb20
-rw-r--r--app/models/pages_domain.rb5
-rw-r--r--app/models/personal_access_token.rb6
-rw-r--r--app/models/project.rb135
-rw-r--r--app/models/project_auto_devops.rb1
-rw-r--r--app/models/project_ci_cd_setting.rb5
-rw-r--r--app/models/project_feature.rb2
-rw-r--r--app/models/project_import_state.rb16
-rw-r--r--app/models/project_services/asana_service.rb2
-rw-r--r--app/models/project_services/jira_service.rb26
-rw-r--r--app/models/project_services/prometheus_service.rb26
-rw-r--r--app/models/project_services/unify_circuit_service.rb60
-rw-r--r--app/models/project_snippet.rb1
-rw-r--r--app/models/project_wiki.rb2
-rw-r--r--app/models/readme_blob.rb2
-rw-r--r--app/models/release.rb8
-rw-r--r--app/models/remote_mirror.rb9
-rw-r--r--app/models/repository.rb40
-rw-r--r--app/models/sentry_issue.rb10
-rw-r--r--app/models/serverless/domain_cluster.rb14
-rw-r--r--app/models/service.rb9
-rw-r--r--app/models/snippet.rb44
-rw-r--r--app/models/snippet_user_mention.rb6
-rw-r--r--app/models/timelog.rb12
-rw-r--r--app/models/upload.rb21
-rw-r--r--app/models/uploads/local.rb3
-rw-r--r--app/models/user.rb26
-rw-r--r--app/models/user_callout_enums.rb3
-rw-r--r--app/models/user_mention.rb23
-rw-r--r--app/models/wiki_page.rb4
-rw-r--r--app/policies/base_policy.rb1
-rw-r--r--app/policies/blob_policy.rb7
-rw-r--r--app/policies/deploy_key_policy.rb5
-rw-r--r--app/policies/error_tracking/detailed_error_policy.rb7
-rw-r--r--app/policies/global_policy.rb3
-rw-r--r--app/policies/personal_snippet_policy.rb7
-rw-r--r--app/policies/project_policy.rb3
-rw-r--r--app/policies/project_snippet_policy.rb7
-rw-r--r--app/policies/user_policy.rb5
-rw-r--r--app/policies/wiki_page_policy.rb7
-rw-r--r--app/presenters/ci/legacy_stage_presenter.rb32
-rw-r--r--app/presenters/ci/pipeline_presenter.rb4
-rw-r--r--app/presenters/clusterable_presenter.rb12
-rw-r--r--app/presenters/clusters/cluster_presenter.rb16
-rw-r--r--app/presenters/dev_ops_score/metric_presenter.rb (renamed from app/presenters/conversational_development_index/metric_presenter.rb)2
-rw-r--r--app/presenters/group_clusterable_presenter.rb5
-rw-r--r--app/presenters/hooks/project_hook_presenter.rb13
-rw-r--r--app/presenters/hooks/service_hook_presenter.rb13
-rw-r--r--app/presenters/instance_clusterable_presenter.rb15
-rw-r--r--app/presenters/project_clusterable_presenter.rb5
-rw-r--r--app/presenters/projects/settings/deploy_keys_presenter.rb62
-rw-r--r--app/presenters/release_presenter.rb8
-rw-r--r--app/presenters/sentry_detailed_error_presenter.rb15
-rw-r--r--app/presenters/snippet_presenter.rb39
-rw-r--r--app/presenters/web_hook_log_presenter.rb13
-rw-r--r--app/serializers/analytics_merge_request_entity.rb4
-rw-r--r--app/serializers/deploy_key_entity.rb11
-rw-r--r--app/serializers/deployment_entity.rb11
-rw-r--r--app/serializers/diff_file_metadata_entity.rb3
-rw-r--r--app/serializers/diffs_entity.rb4
-rw-r--r--app/serializers/diffs_metadata_entity.rb2
-rw-r--r--app/serializers/environment_entity.rb9
-rw-r--r--app/serializers/environment_status_entity.rb4
-rw-r--r--app/serializers/error_tracking/detailed_error_entity.rb1
-rw-r--r--app/serializers/group_child_entity.rb2
-rw-r--r--app/serializers/issue_entity.rb8
-rw-r--r--app/serializers/merge_request_noteable_entity.rb12
-rw-r--r--app/serializers/merge_request_poll_cached_widget_entity.rb13
-rw-r--r--app/serializers/merge_request_poll_widget_entity.rb8
-rw-r--r--app/serializers/merge_request_widget_entity.rb5
-rw-r--r--app/serializers/pipeline_entity.rb4
-rw-r--r--app/services/auto_merge/merge_when_pipeline_succeeds_service.rb2
-rw-r--r--app/services/boards/lists/list_service.rb2
-rw-r--r--app/services/branches/create_service.rb39
-rw-r--r--app/services/branches/delete_merged_service.rb34
-rw-r--r--app/services/branches/delete_service.rb32
-rw-r--r--app/services/branches/validate_new_service.rb31
-rw-r--r--app/services/ci/archive_trace_service.rb6
-rw-r--r--app/services/ci/create_pipeline_service.rb5
-rw-r--r--app/services/ci/generate_exposed_artifacts_report_service.rb2
-rw-r--r--app/services/ci/prepare_build_service.rb2
-rw-r--r--app/services/ci/process_pipeline_service.rb12
-rw-r--r--app/services/ci/register_job_service.rb4
-rw-r--r--app/services/ci/retry_pipeline_service.rb16
-rw-r--r--app/services/clusters/applications/base_helm_service.rb13
-rw-r--r--app/services/clusters/applications/ingress_modsecurity_usage_service.rb69
-rw-r--r--app/services/clusters/aws/authorize_role_service.rb49
-rw-r--r--app/services/clusters/aws/fetch_credentials_service.rb28
-rw-r--r--app/services/clusters/aws/proxy_service.rb134
-rw-r--r--app/services/clusters/cleanup/app_service.rb33
-rw-r--r--app/services/clusters/cleanup/base_service.rb43
-rw-r--r--app/services/clusters/cleanup/project_namespace_service.rb44
-rw-r--r--app/services/clusters/cleanup/service_account_service.rb27
-rw-r--r--app/services/clusters/kubernetes.rb (renamed from app/services/clusters/kubernetes/kubernetes.rb)3
-rw-r--r--app/services/clusters/kubernetes/create_or_update_service_account_service.rb41
-rw-r--r--app/services/cohorts_service.rb2
-rw-r--r--app/services/commits/commit_patch_service.rb2
-rw-r--r--app/services/commits/create_service.rb2
-rw-r--r--app/services/concerns/users/participable_service.rb3
-rw-r--r--app/services/create_branch_service.rb38
-rw-r--r--app/services/create_snippet_service.rb6
-rw-r--r--app/services/delete_branch_service.rb30
-rw-r--r--app/services/delete_merged_branches_service.rb32
-rw-r--r--app/services/deployments/after_create_service.rb7
-rw-r--r--app/services/deployments/create_service.rb17
-rw-r--r--app/services/deployments/update_service.rb17
-rw-r--r--app/services/environments/reset_auto_stop_service.rb22
-rw-r--r--app/services/error_tracking/list_issues_service.rb15
-rw-r--r--app/services/git/base_hooks_service.rb24
-rw-r--r--app/services/issuable/bulk_update_service.rb20
-rw-r--r--app/services/issuable/clone/attributes_rewriter.rb8
-rw-r--r--app/services/issuable/common_system_notes_service.rb26
-rw-r--r--app/services/issuable_base_service.rb28
-rw-r--r--app/services/issues/base_service.rb2
-rw-r--r--app/services/issues/duplicate_service.rb2
-rw-r--r--app/services/issues/zoom_link_service.rb50
-rw-r--r--app/services/merge_requests/create_from_issue_service.rb2
-rw-r--r--app/services/merge_requests/merge_service.rb4
-rw-r--r--app/services/merge_requests/refresh_service.rb2
-rw-r--r--app/services/merge_requests/update_service.rb2
-rw-r--r--app/services/metrics/dashboard/base_embed_service.rb2
-rw-r--r--app/services/metrics/dashboard/custom_metric_embed_service.rb2
-rw-r--r--app/services/metrics/dashboard/grafana_metric_embed_service.rb2
-rw-r--r--app/services/metrics/dashboard/pod_dashboard_service.rb10
-rw-r--r--app/services/metrics/dashboard/predefined_dashboard_service.rb45
-rw-r--r--app/services/metrics/dashboard/system_dashboard_service.rb37
-rw-r--r--app/services/metrics/sample_metrics_service.rb26
-rw-r--r--app/services/notes/base_service.rb2
-rw-r--r--app/services/notes/build_service.rb2
-rw-r--r--app/services/notes/create_service.rb12
-rw-r--r--app/services/notes/update_service.rb6
-rw-r--r--app/services/notification_service.rb8
-rw-r--r--app/services/pages/delete_service.rb10
-rw-r--r--app/services/projects/container_repository/cleanup_tags_service.rb4
-rw-r--r--app/services/projects/container_repository/delete_tags_service.rb34
-rw-r--r--app/services/projects/destroy_service.rb7
-rw-r--r--app/services/projects/fork_service.rb18
-rw-r--r--app/services/projects/hashed_storage/base_repository_service.rb31
-rw-r--r--app/services/projects/hashed_storage/migrate_repository_service.rb6
-rw-r--r--app/services/projects/hashed_storage/rollback_repository_service.rb6
-rw-r--r--app/services/projects/import_service.rb4
-rw-r--r--app/services/projects/overwrite_project_service.rb4
-rw-r--r--app/services/projects/unlink_fork_service.rb61
-rw-r--r--app/services/projects/update_service.rb7
-rw-r--r--app/services/prometheus/proxy_variable_substitution_service.rb51
-rw-r--r--app/services/repair_ldap_blocked_user_service.rb19
-rw-r--r--app/services/submit_usage_ping_service.rb2
-rw-r--r--app/services/todo_service.rb13
-rw-r--r--app/services/update_snippet_service.rb8
-rw-r--r--app/services/users/build_service.rb2
-rw-r--r--app/services/users/repair_ldap_blocked_service.rb21
-rw-r--r--app/services/validate_new_branch_service.rb21
-rw-r--r--app/services/web_hook_service.rb3
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml3
-rw-r--r--app/views/admin/application_settings/_ci_cd.html.haml2
-rw-r--r--app/views/admin/application_settings/_signup.html.haml6
-rw-r--r--app/views/admin/application_settings/_visibility_and_access.html.haml2
-rw-r--r--app/views/admin/application_settings/integrations.html.haml2
-rw-r--r--app/views/admin/broadcast_messages/_form.html.haml9
-rw-r--r--app/views/admin/broadcast_messages/index.html.haml3
-rw-r--r--app/views/admin/dashboard/index.html.haml9
-rw-r--r--app/views/admin/groups/_group.html.haml2
-rw-r--r--app/views/admin/groups/show.html.haml3
-rw-r--r--app/views/admin/hooks/edit.html.haml31
-rw-r--r--app/views/admin/hooks/index.html.haml31
-rw-r--r--app/views/admin/projects/_archived.html.haml3
-rw-r--r--app/views/admin/projects/_projects.html.haml3
-rw-r--r--app/views/admin/runners/index.html.haml4
-rw-r--r--app/views/admin/sessions/_new_base.html.haml2
-rw-r--r--app/views/admin/sessions/_signin_box.html.haml11
-rw-r--r--app/views/admin/sessions/new.html.haml15
-rw-r--r--app/views/admin/users/_access_levels.html.haml2
-rw-r--r--app/views/award_emoji/_awards_block.html.haml1
-rw-r--r--app/views/ci/variables/_variable_row.html.haml14
-rw-r--r--app/views/clusters/clusters/_advanced_settings.html.haml17
-rw-r--r--app/views/clusters/clusters/_banner.html.haml18
-rw-r--r--app/views/clusters/clusters/_form.html.haml8
-rw-r--r--app/views/clusters/clusters/_namespace.html.haml2
-rw-r--r--app/views/clusters/clusters/aws/_new.html.haml13
-rw-r--r--app/views/clusters/clusters/gcp/_form.html.haml2
-rw-r--r--app/views/clusters/clusters/gcp/_gcp_not_configured.html.haml2
-rw-r--r--app/views/clusters/clusters/gcp/_new.html.haml2
-rw-r--r--app/views/clusters/clusters/gcp/_signin_with_google_button.html.haml4
-rw-r--r--app/views/clusters/clusters/new.html.haml14
-rw-r--r--app/views/dashboard/_snippets_head.html.haml3
-rw-r--r--app/views/dashboard/projects/_blank_state_welcome.html.haml24
-rw-r--r--app/views/dashboard/snippets/index.html.haml3
-rw-r--r--app/views/groups/settings/_permissions.html.haml7
-rw-r--r--app/views/import/gitlab_projects/new.html.haml1
-rw-r--r--app/views/instance_statistics/cohorts/_cohorts_table.html.haml27
-rw-r--r--app/views/instance_statistics/conversational_development_index/_callout.html.haml13
-rw-r--r--app/views/instance_statistics/dev_ops_score/_callout.html.haml13
-rw-r--r--app/views/instance_statistics/dev_ops_score/_card.html.haml (renamed from app/views/instance_statistics/conversational_development_index/_card.html.haml)6
-rw-r--r--app/views/instance_statistics/dev_ops_score/_disabled.html.haml (renamed from app/views/instance_statistics/conversational_development_index/_disabled.html.haml)4
-rw-r--r--app/views/instance_statistics/dev_ops_score/_no_data.html.haml (renamed from app/views/instance_statistics/conversational_development_index/_no_data.html.haml)6
-rw-r--r--app/views/instance_statistics/dev_ops_score/index.html.haml (renamed from app/views/instance_statistics/conversational_development_index/index.html.haml)22
-rw-r--r--app/views/layouts/_broadcast.html.haml2
-rw-r--r--app/views/layouts/_flash.html.haml7
-rw-r--r--app/views/layouts/application.html.haml2
-rw-r--r--app/views/layouts/header/_current_user_dropdown.html.haml2
-rw-r--r--app/views/layouts/header/_new_dropdown.haml3
-rw-r--r--app/views/layouts/nav/_breadcrumbs.html.haml2
-rw-r--r--app/views/layouts/nav/_dashboard.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml6
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_instance_statistics.html.haml12
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml10
-rw-r--r--app/views/layouts/notify.html.haml2
-rw-r--r--app/views/layouts/notify.text.erb2
-rw-r--r--app/views/notify/_note_email.html.haml2
-rw-r--r--app/views/notify/_note_email.text.erb2
-rw-r--r--app/views/notify/access_token_about_to_expire_email.html.haml7
-rw-r--r--app/views/notify/access_token_about_to_expire_email.text.erb5
-rw-r--r--app/views/profiles/accounts/_providers.html.haml2
-rw-r--r--app/views/profiles/gpg_keys/_key.html.haml2
-rw-r--r--app/views/profiles/keys/_key.html.haml2
-rw-r--r--app/views/profiles/keys/_key_details.html.haml16
-rw-r--r--app/views/profiles/passwords/edit.html.haml2
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml2
-rw-r--r--app/views/projects/_archived_notice.html.haml5
-rw-r--r--app/views/projects/_remove.html.haml10
-rw-r--r--app/views/projects/_visibility_modal.html.haml30
-rw-r--r--app/views/projects/artifacts/browse.html.haml2
-rw-r--r--app/views/projects/blob/_breadcrumb.html.haml28
-rw-r--r--app/views/projects/blob/viewers/_openapi.html.haml1
-rw-r--r--app/views/projects/buttons/_dropdown.html.haml2
-rw-r--r--app/views/projects/commits/show.html.haml2
-rw-r--r--app/views/projects/edit.html.haml31
-rw-r--r--app/views/projects/environments/empty_logs.html.haml14
-rw-r--r--app/views/projects/environments/folder.html.haml4
-rw-r--r--app/views/projects/environments/show.html.haml4
-rw-r--r--app/views/projects/hook_logs/_index.html.haml2
-rw-r--r--app/views/projects/hook_logs/show.html.haml3
-rw-r--r--app/views/projects/hooks/_index.html.haml17
-rw-r--r--app/views/projects/hooks/edit.html.haml16
-rw-r--r--app/views/projects/issues/_new_branch.html.haml2
-rw-r--r--app/views/projects/merge_requests/_awards_block.html.haml5
-rw-r--r--app/views/projects/merge_requests/_description.html.haml9
-rw-r--r--app/views/projects/merge_requests/_discussion_filter.html.haml2
-rw-r--r--app/views/projects/merge_requests/_mr_box.html.haml15
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml8
-rw-r--r--app/views/projects/merge_requests/_widget.html.haml14
-rw-r--r--app/views/projects/merge_requests/show.html.haml56
-rw-r--r--app/views/projects/merge_requests/tabs/_pane.html.haml7
-rw-r--r--app/views/projects/merge_requests/tabs/_tab.html.haml7
-rw-r--r--app/views/projects/pages/_list.html.haml4
-rw-r--r--app/views/projects/pages_domains/edit.html.haml21
-rw-r--r--app/views/projects/pages_domains/show.html.haml60
-rw-r--r--app/views/projects/pipelines/_info.html.haml5
-rw-r--r--app/views/projects/registry/settings/_index.haml2
-rw-r--r--app/views/projects/services/edit.html.haml4
-rw-r--r--app/views/projects/settings/_archive.html.haml18
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml14
-rw-r--r--app/views/projects/settings/integrations/_project_hook.html.haml16
-rw-r--r--app/views/projects/show.html.haml7
-rw-r--r--app/views/projects/snippets/_actions.html.haml4
-rw-r--r--app/views/projects/snippets/index.html.haml3
-rw-r--r--app/views/projects/snippets/show.html.haml17
-rw-r--r--app/views/projects/stage/_stage.html.haml6
-rw-r--r--app/views/projects/tags/_tag.atom.builder2
-rw-r--r--app/views/projects/tags/_tag.html.haml8
-rw-r--r--app/views/projects/tags/show.html.haml24
-rw-r--r--app/views/projects/tree/_readme.html.haml11
-rw-r--r--app/views/projects/tree/_tree_header.html.haml21
-rw-r--r--app/views/search/_category.html.haml2
-rw-r--r--app/views/search/results/_snippet_blob.html.haml2
-rw-r--r--app/views/search/results/_snippet_title.html.haml2
-rw-r--r--app/views/shared/_personal_access_tokens_form.html.haml3
-rw-r--r--app/views/shared/_service_settings.html.haml18
-rw-r--r--app/views/shared/boards/_show.html.haml1
-rw-r--r--app/views/shared/boards/components/_board.html.haml26
-rw-r--r--app/views/shared/buttons/_project_feature_toggle.html.haml16
-rw-r--r--app/views/shared/empty_states/_snippets.html.haml3
-rw-r--r--app/views/shared/groups/_group.html.haml2
-rw-r--r--app/views/shared/icons/_dev_ops_score_no_data.svg (renamed from app/views/shared/icons/_convdev_no_data.svg)0
-rw-r--r--app/views/shared/icons/_dev_ops_score_no_index.svg (renamed from app/views/shared/icons/_convdev_no_index.svg)0
-rw-r--r--app/views/shared/icons/_dev_ops_score_overview.svg (renamed from app/views/shared/icons/_convdev_overview.svg)0
-rw-r--r--app/views/shared/issuable/_close_reopen_button.html.haml2
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml4
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml8
-rw-r--r--app/views/shared/labels/_nav.html.haml2
-rw-r--r--app/views/shared/members/_sort_dropdown.html.haml10
-rw-r--r--app/views/shared/milestones/_milestone.html.haml5
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml53
-rw-r--r--app/views/shared/milestones/_tabs.html.haml16
-rw-r--r--app/views/shared/notifications/_custom_notifications.html.haml2
-rw-r--r--app/views/shared/projects/_archived.html.haml3
-rw-r--r--app/views/shared/projects/_list.html.haml1
-rw-r--r--app/views/shared/projects/_project.html.haml5
-rw-r--r--app/views/shared/snippets/_header.html.haml6
-rw-r--r--app/views/shared/snippets/_snippet.html.haml6
-rw-r--r--app/views/shared/tokens/_scopes_list.html.haml2
-rw-r--r--app/views/shared/web_hooks/_hook.html.haml16
-rw-r--r--app/views/shared/web_hooks/_index.html.haml14
-rw-r--r--app/views/shared/web_hooks/_test_button.html.haml6
-rw-r--r--app/views/shared/web_hooks/_title_and_docs.html.haml5
-rw-r--r--app/views/snippets/_actions.html.haml16
-rw-r--r--app/views/snippets/_snippets.html.haml2
-rw-r--r--app/views/snippets/_snippets_scope_menu.html.haml2
-rw-r--r--app/views/snippets/edit.html.haml2
-rw-r--r--app/views/snippets/show.html.haml17
-rw-r--r--app/views/users/_profile_basic_info.html.haml6
-rw-r--r--app/views/users/show.html.haml94
-rw-r--r--app/workers/all_queues.yml6
-rw-r--r--app/workers/clusters/applications/activate_service_worker.rb19
-rw-r--r--app/workers/clusters/applications/deactivate_service_worker.rb24
-rw-r--r--app/workers/clusters/cleanup/app_worker.rb15
-rw-r--r--app/workers/clusters/cleanup/project_namespace_worker.rb15
-rw-r--r--app/workers/clusters/cleanup/service_account_worker.rb13
-rw-r--r--app/workers/concerns/cluster_cleanup_methods.rb75
-rw-r--r--app/workers/delete_merged_branches_worker.rb2
-rw-r--r--app/workers/delete_stored_files_worker.rb2
-rw-r--r--app/workers/hashed_storage/project_migrate_worker.rb2
-rw-r--r--app/workers/pages_domain_removal_cron_worker.rb2
-rw-r--r--app/workers/personal_access_tokens/expiring_worker.rb23
-rw-r--r--app/workers/pipeline_process_worker.rb4
-rw-r--r--app/workers/process_commit_worker.rb5
-rw-r--r--app/workers/run_pipeline_schedule_worker.rb6
-rw-r--r--app/workers/stuck_ci_jobs_worker.rb4
909 files changed, 11645 insertions, 5403 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index aee9990bc0b..071ae8ca8cf 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -5,6 +5,8 @@ import { joinPaths } from './lib/utils/url_utility';
import flash from '~/flash';
import { __ } from '~/locale';
+const DEFAULT_PER_PAGE = 20;
+
const Api = {
groupsPath: '/api/:version/groups.json',
groupPath: '/api/:version/groups/:id',
@@ -41,7 +43,7 @@ const Api = {
releasesPath: '/api/:version/projects/:id/releases',
releasePath: '/api/:version/projects/:id/releases/:tag_name',
mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines',
- adminStatisticsPath: 'api/:version/application/statistics',
+ adminStatisticsPath: '/api/:version/application/statistics',
group(groupId, callback) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
@@ -66,7 +68,7 @@ const Api = {
params: Object.assign(
{
search: query,
- per_page: 20,
+ per_page: DEFAULT_PER_PAGE,
},
options,
),
@@ -90,7 +92,7 @@ const Api = {
.get(url, {
params: {
search: query,
- per_page: 20,
+ per_page: DEFAULT_PER_PAGE,
},
})
.then(({ data }) => callback(data));
@@ -101,7 +103,7 @@ const Api = {
const url = Api.buildUrl(Api.projectsPath);
const defaults = {
search: query,
- per_page: 20,
+ per_page: DEFAULT_PER_PAGE,
simple: true,
};
@@ -126,7 +128,7 @@ const Api = {
.get(url, {
params: {
search: query,
- per_page: 20,
+ per_page: DEFAULT_PER_PAGE,
...options,
},
})
@@ -235,7 +237,7 @@ const Api = {
const url = Api.buildUrl(Api.groupProjectsPath).replace(':id', groupId);
const defaults = {
search: query,
- per_page: 20,
+ per_page: DEFAULT_PER_PAGE,
};
return axios
.get(url, {
@@ -325,7 +327,7 @@ const Api = {
params: Object.assign(
{
search: query,
- per_page: 20,
+ per_page: DEFAULT_PER_PAGE,
},
options,
),
@@ -355,7 +357,7 @@ const Api = {
const url = Api.buildUrl(Api.userProjectsPath).replace(':id', userId);
const defaults = {
search: query,
- per_page: 20,
+ per_page: DEFAULT_PER_PAGE,
};
return axios
.get(url, {
@@ -371,7 +373,7 @@ const Api = {
return axios.get(url, {
params: {
search: query,
- per_page: 20,
+ per_page: DEFAULT_PER_PAGE,
...options,
},
});
@@ -403,10 +405,15 @@ const Api = {
return axios.post(url);
},
- releases(id) {
+ releases(id, options = {}) {
const url = Api.buildUrl(this.releasesPath).replace(':id', encodeURIComponent(id));
- return axios.get(url);
+ return axios.get(url, {
+ params: {
+ per_page: DEFAULT_PER_PAGE,
+ ...options,
+ },
+ });
},
release(projectPath, tagName) {
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js
index 7652b67ae1e..07d79ea1c70 100644
--- a/app/assets/javascripts/autosave.js
+++ b/app/assets/javascripts/autosave.js
@@ -1,9 +1,9 @@
-/* eslint-disable no-param-reassign, no-void, consistent-return */
+/* eslint-disable no-param-reassign, consistent-return */
import AccessorUtilities from './lib/utils/accessor';
export default class Autosave {
- constructor(field, key) {
+ constructor(field, key, fallbackKey) {
this.field = field;
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
@@ -11,6 +11,7 @@ export default class Autosave {
key = key.join('/');
}
this.key = `autosave/${key}`;
+ this.fallbackKey = fallbackKey;
this.field.data('autosave', this);
this.restore();
this.field.on('input', () => this.save());
@@ -21,9 +22,12 @@ export default class Autosave {
if (!this.field.length) return;
const text = window.localStorage.getItem(this.key);
+ const fallbackText = window.localStorage.getItem(this.fallbackKey);
- if ((text != null ? text.length : void 0) > 0) {
+ if (text) {
this.field.val(text);
+ } else if (fallbackText) {
+ this.field.val(fallbackText);
}
this.field.trigger('input');
@@ -41,7 +45,10 @@ export default class Autosave {
const text = this.field.val();
- if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) {
+ if (this.isLocalStorageAvailable && text) {
+ if (this.fallbackKey) {
+ window.localStorage.setItem(this.fallbackKey, text);
+ }
return window.localStorage.setItem(this.key, text);
}
@@ -51,6 +58,7 @@ export default class Autosave {
reset() {
if (!this.isLocalStorageAvailable) return;
+ window.localStorage.removeItem(this.fallbackKey);
return window.localStorage.removeItem(this.key);
}
diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue
index eb720f5380b..00c0334db77 100644
--- a/app/assets/javascripts/badges/components/badge.vue
+++ b/app/assets/javascripts/badges/components/badge.vue
@@ -1,6 +1,6 @@
<script>
-import Icon from '~/vue_shared/components/icon.vue';
import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
export default {
// name: 'Badge' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
@@ -14,6 +14,11 @@ export default {
GlTooltip: GlTooltipDirective,
},
props: {
+ name: {
+ type: String,
+ required: false,
+ default: '',
+ },
imageUrl: {
type: String,
required: true,
diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue
index df74eb2c2f7..19668d7e232 100644
--- a/app/assets/javascripts/badges/components/badge_form.vue
+++ b/app/assets/javascripts/badges/components/badge_form.vue
@@ -1,10 +1,10 @@
<script>
import _ from 'underscore';
import { mapActions, mapState } from 'vuex';
+import { GlLoadingIcon, GlFormInput, GlFormGroup } from '@gitlab/ui';
import createFlash from '~/flash';
import { s__, sprintf } from '~/locale';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
-import { GlLoadingIcon } from '@gitlab/ui';
import createEmptyBadge from '../empty_badge';
import Badge from './badge.vue';
@@ -16,6 +16,8 @@ export default {
Badge,
LoadingButton,
GlLoadingIcon,
+ GlFormInput,
+ GlFormGroup,
},
props: {
isEditing: {
@@ -64,6 +66,18 @@ export default {
renderedLinkUrl() {
return this.renderedBadge ? this.renderedBadge.renderedLinkUrl : '';
},
+ name: {
+ get() {
+ return this.badge ? this.badge.name : '';
+ },
+ set(name) {
+ const badge = this.badge || createEmptyBadge();
+ this.updateBadgeInForm({
+ ...badge,
+ name,
+ });
+ },
+ },
imageUrl: {
get() {
return this.badge ? this.badge.imageUrl : '';
@@ -154,6 +168,10 @@ export default {
novalidate
@submit.prevent.stop="onSubmit"
>
+ <gl-form-group :label="s__('Badges|Name')" label-for="badge-name">
+ <gl-form-input id="badge-name" v-model="name" />
+ </gl-form-group>
+
<div class="form-group">
<label for="badge-link-url" class="label-bold">{{ s__('Badges|Link') }}</label>
<p v-html="helpText"></p>
diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue
index cad5611c8c5..bb363b8d85e 100644
--- a/app/assets/javascripts/badges/components/badge_list_row.vue
+++ b/app/assets/javascripts/badges/components/badge_list_row.vue
@@ -1,8 +1,8 @@
<script>
import { mapActions, mapState } from 'vuex';
+import { GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
-import { GlLoadingIcon } from '@gitlab/ui';
import { PROJECT_BADGE } from '../constants';
import Badge from './badge.vue';
@@ -43,13 +43,14 @@ export default {
<badge
:image-url="badge.renderedImageUrl"
:link-url="badge.renderedLinkUrl"
- class="table-section section-40"
+ class="table-section section-30"
/>
- <span class="table-section section-30 str-truncated">{{ badge.linkUrl }}</span>
- <div class="table-section section-15">
+ <div class="table-section section-30">
+ <label class="label-bold str-truncated mb-0">{{ badge.name }}</label>
<span class="badge badge-pill">{{ badgeKindText }}</span>
</div>
- <div class="table-section section-15 table-button-footer">
+ <span class="table-section section-30 str-truncated">{{ badge.linkUrl }}</span>
+ <div class="table-section section-10 table-button-footer">
<div v-if="canEditBadge" class="table-action-buttons">
<button
:disabled="badge.isDeleting"
diff --git a/app/assets/javascripts/badges/empty_badge.js b/app/assets/javascripts/badges/empty_badge.js
index 49a9b5e1be8..527f233bb33 100644
--- a/app/assets/javascripts/badges/empty_badge.js
+++ b/app/assets/javascripts/badges/empty_badge.js
@@ -1,4 +1,5 @@
export default () => ({
+ name: '',
imageUrl: '',
isDeleting: false,
linkUrl: '',
diff --git a/app/assets/javascripts/badges/store/actions.js b/app/assets/javascripts/badges/store/actions.js
index 5542278b3e0..806c2423e7e 100644
--- a/app/assets/javascripts/badges/store/actions.js
+++ b/app/assets/javascripts/badges/store/actions.js
@@ -1,13 +1,9 @@
import axios from '~/lib/utils/axios_utils';
import types from './mutation_types';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export const transformBackendBadge = badge => ({
- id: badge.id,
- imageUrl: badge.image_url,
- kind: badge.kind,
- linkUrl: badge.link_url,
- renderedImageUrl: badge.rendered_image_url,
- renderedLinkUrl: badge.rendered_link_url,
+ ...convertObjectPropsToCamelCase(badge, true),
isDeleting: false,
});
@@ -27,6 +23,7 @@ export default {
dispatch('requestNewBadge');
return axios
.post(endpoint, {
+ name: newBadge.name,
image_url: newBadge.imageUrl,
link_url: newBadge.linkUrl,
})
@@ -141,6 +138,7 @@ export default {
dispatch('requestUpdatedBadge');
return axios
.put(endpoint, {
+ name: badge.name,
image_url: badge.imageUrl,
link_url: badge.linkUrl,
})
diff --git a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js
index b7200150925..6bbd2133344 100644
--- a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js
+++ b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
-import { parseBoolean } from '~/lib/utils/common_utils';
import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
+import { parseBoolean } from '~/lib/utils/common_utils';
export default function initGFMInput() {
$('.js-gfm-input:not(.js-vue-textarea)').each((i, el) => {
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/image.js b/app/assets/javascripts/behaviors/markdown/nodes/image.js
index c225a5ed876..e839396330e 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/image.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/image.js
@@ -1,8 +1,8 @@
/* eslint-disable class-methods-use-this */
import { Image as BaseImage } from 'tiptap-extensions';
-import { placeholderImage } from '~/lazy_loader';
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
+import { placeholderImage } from '~/lazy_loader';
export default class Image extends BaseImage {
get schema() {
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js
index aedd8004ea5..2df7a84ead0 100644
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ b/app/assets/javascripts/blob/file_template_mediator.js
@@ -1,6 +1,6 @@
+import $ from 'jquery';
import Api from '~/api';
-import $ from 'jquery';
import Flash from '../flash';
import FileTemplateTypeSelector from './template_selectors/type_selector';
import BlobCiYamlSelector from './template_selectors/ci_yaml_selector';
diff --git a/app/assets/javascripts/blob/openapi/index.js b/app/assets/javascripts/blob/openapi/index.js
new file mode 100644
index 00000000000..a6f28de799f
--- /dev/null
+++ b/app/assets/javascripts/blob/openapi/index.js
@@ -0,0 +1,19 @@
+import { SwaggerUIBundle } from 'swagger-ui-dist';
+import flash from '~/flash';
+import { __ } from '~/locale';
+
+export default () => {
+ const el = document.getElementById('js-openapi-viewer');
+
+ Promise.all([import(/* webpackChunkName: 'openapi' */ 'swagger-ui-dist/swagger-ui.css')])
+ .then(() => {
+ SwaggerUIBundle({
+ url: el.dataset.endpoint,
+ dom_id: '#js-openapi-viewer',
+ });
+ })
+ .catch(error => {
+ flash(__('Something went wrong while initializing the OpenAPI viewer'));
+ throw error;
+ });
+};
diff --git a/app/assets/javascripts/blob/openapi_viewer.js b/app/assets/javascripts/blob/openapi_viewer.js
new file mode 100644
index 00000000000..0cacc33571f
--- /dev/null
+++ b/app/assets/javascripts/blob/openapi_viewer.js
@@ -0,0 +1,3 @@
+import renderOpenApi from './openapi';
+
+export default renderOpenApi;
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index 07e4dde41d9..742404da46c 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -39,6 +39,9 @@ export default class BlobViewer {
case 'notebook':
initViewer(import(/* webpackChunkName: 'notebook_viewer' */ '../notebook_viewer'));
break;
+ case 'openapi':
+ initViewer(import(/* webpackChunkName: 'openapi_viewer' */ '../openapi_viewer'));
+ break;
case 'pdf':
initViewer(import(/* webpackChunkName: 'pdf_viewer' */ '../pdf_viewer'));
break;
diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js
index 58759fd1efe..8ebdfede8f7 100644
--- a/app/assets/javascripts/boards/components/board.js
+++ b/app/assets/javascripts/boards/components/board.js
@@ -1,6 +1,8 @@
import $ from 'jquery';
import Sortable from 'sortablejs';
import Vue from 'vue';
+import { GlButtonGroup, GlButton, GlTooltip } from '@gitlab/ui';
+import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits';
import { n__, s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import Tooltip from '~/vue_shared/directives/tooltip';
@@ -8,8 +10,10 @@ import AccessorUtilities from '../../lib/utils/accessor';
import BoardBlankState from './board_blank_state.vue';
import BoardDelete from './board_delete';
import BoardList from './board_list.vue';
+import IssueCount from './issue_count.vue';
import boardsStore from '../stores/boards_store';
import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options';
+import { ListType } from '../constants';
export default Vue.extend({
components: {
@@ -17,10 +21,15 @@ export default Vue.extend({
BoardDelete,
BoardList,
Icon,
+ GlButtonGroup,
+ IssueCount,
+ GlButton,
+ GlTooltip,
},
directives: {
Tooltip,
},
+ mixins: [isWipLimitsOn],
props: {
list: {
type: Object,
@@ -53,6 +62,11 @@ export default Vue.extend({
isLoggedIn() {
return Boolean(gon.current_user_id);
},
+ showListHeaderButton() {
+ return (
+ !this.disabled && this.list.type !== ListType.closed && this.list.type !== ListType.blank
+ );
+ },
counterTooltip() {
const { issuesSize } = this.list;
return `${n__('%d issue', '%d issues', issuesSize)}`;
@@ -61,11 +75,19 @@ export default Vue.extend({
return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
},
isNewIssueShown() {
+ return this.list.type === ListType.backlog || this.showListHeaderButton;
+ },
+ isSettingsShown() {
return (
- this.list.type === 'backlog' ||
- (!this.disabled && this.list.type !== 'closed' && this.list.type !== 'blank')
+ this.list.type !== ListType.backlog &&
+ this.showListHeaderButton &&
+ this.list.isExpanded &&
+ this.isWipLimitsOn
);
},
+ showBoardListAndBoardInfo() {
+ return this.list.type !== ListType.blank && this.list.type !== ListType.promotion;
+ },
uniqueKey() {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return `boards.${this.boardId}.${this.list.type}.${this.list.id}`;
diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue
index 9a1da810ad0..afdf0290e8e 100644
--- a/app/assets/javascripts/boards/components/board_blank_state.vue
+++ b/app/assets/javascripts/boards/components/board_blank_state.vue
@@ -1,7 +1,7 @@
<script>
+import Cookies from 'js-cookie';
import { __ } from '~/locale';
import ListLabel from '~/boards/models/label';
-import Cookies from 'js-cookie';
import boardsStore from '../stores/boards_store';
export default {
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index b8439bc8741..1e54d4d6b7d 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -71,6 +71,9 @@ export default {
total: this.list.issuesSize,
});
},
+ issuesSizeExceedsMax() {
+ return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount;
+ },
},
watch: {
filters: {
@@ -435,7 +438,7 @@ export default {
ref="list"
:data-board="list.id"
:data-board-type="list.type"
- :class="{ 'is-smaller': showIssueForm }"
+ :class="{ 'is-smaller': showIssueForm, 'bg-danger-100': issuesSizeExceedsMax }"
class="board-list w-100 h-100 list-unstyled mb-0 p-1 js-board-list"
>
<board-card
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 32491dfbcb6..5d7be0c705a 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -315,8 +315,7 @@ export default {
<gl-dropdown-item
v-if="showDelete"
- class="text-danger"
- data-qa-selector="delete_board_button"
+ class="text-danger js-delete-board"
@click.prevent="showPage('delete')"
>
{{ s__('IssueBoards|Delete board') }}
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue
index d37e49bab46..7f7510545c6 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.vue
+++ b/app/assets/javascripts/boards/components/issue_card_inner.vue
@@ -2,10 +2,10 @@
import _ from 'underscore';
import { mapState } from 'vuex';
import { GlTooltipDirective } from '@gitlab/ui';
+import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner';
import { sprintf, __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
-import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner';
import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import IssueDueDate from './issue_due_date.vue';
import IssueTimeEstimate from './issue_time_estimate.vue';
diff --git a/app/assets/javascripts/boards/components/issue_count.vue b/app/assets/javascripts/boards/components/issue_count.vue
new file mode 100644
index 00000000000..c50a3c1c0d3
--- /dev/null
+++ b/app/assets/javascripts/boards/components/issue_count.vue
@@ -0,0 +1,36 @@
+<script>
+export default {
+ name: 'IssueCount',
+ props: {
+ maxIssueCount: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ issuesSize: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ },
+ computed: {
+ isMaxLimitSet() {
+ return this.maxIssueCount !== 0;
+ },
+ issuesExceedMax() {
+ return this.isMaxLimitSet && this.issuesSize > this.maxIssueCount;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="issue-count">
+ <span class="js-issue-size" :class="{ 'text-danger': issuesExceedMax }">
+ {{ issuesSize }}
+ </span>
+ <span v-if="isMaxLimitSet" class="js-max-issue-size">
+ {{ maxIssueCount }}
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue
index 3bc7f13a9e6..a32ebdab5e1 100644
--- a/app/assets/javascripts/boards/components/issue_due_date.vue
+++ b/app/assets/javascripts/boards/components/issue_due_date.vue
@@ -35,10 +35,10 @@ export default {
title() {
const timeago = getTimeago();
const { timeDifference, standardDateFormat } = this;
- const formatedDate = standardDateFormat;
+ const formattedDate = standardDateFormat;
if (timeDifference >= -1 && timeDifference < 7) {
- return `${timeago.format(this.issueDueDate)} (${formatedDate})`;
+ return `${timeago.format(this.issueDueDate)} (${formattedDate})`;
}
return timeago.format(this.issueDueDate);
diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue
index 618c2ada1f8..20344b66140 100644
--- a/app/assets/javascripts/boards/components/modal/index.vue
+++ b/app/assets/javascripts/boards/components/modal/index.vue
@@ -1,5 +1,6 @@
<script>
/* global ListIssue */
+import { GlLoadingIcon } from '@gitlab/ui';
import { urlParamsToObject } from '~/lib/utils/common_utils';
import boardsStore from '~/boards/stores/boards_store';
import ModalHeader from './header.vue';
@@ -7,7 +8,6 @@ import ModalList from './list.vue';
import ModalFooter from './footer.vue';
import EmptyState from './empty_state.vue';
import ModalStore from '../../stores/modal_store';
-import { GlLoadingIcon } from '@gitlab/ui';
export default {
components: {
diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue
index e5ebb887ce0..4a50b1e2efc 100644
--- a/app/assets/javascripts/boards/components/project_select.vue
+++ b/app/assets/javascripts/boards/components/project_select.vue
@@ -1,9 +1,9 @@
<script>
-import { __ } from '~/locale';
import $ from 'jquery';
import _ from 'underscore';
-import Icon from '~/vue_shared/components/icon.vue';
import { GlLoadingIcon } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+import { __ } from '~/locale';
import eventHub from '../eventhub';
import Api from '../../api';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index 3c66c7a0660..dcecfe5e1bb 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -4,6 +4,8 @@ export const ListType = {
backlog: 'backlog',
closed: 'closed',
label: 'label',
+ promotion: 'promotion',
+ blank: 'blank',
};
export default {
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index e76e2341dfd..f1b481fc386 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -1,6 +1,22 @@
import $ from 'jquery';
import Vue from 'vue';
+import 'ee_else_ce/boards/models/issue';
+import 'ee_else_ce/boards/models/list';
+import Board from 'ee_else_ce/boards/components/board';
+import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar';
+import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown';
+import boardConfigToggle from 'ee_else_ce/boards/config_toggle';
+import toggleFocusMode from 'ee_else_ce/boards/toggle_focus';
+import toggleLabels from 'ee_else_ce/boards/toggle_labels';
+import {
+ setPromotionState,
+ setWeigthFetchingState,
+ setEpicFetchingState,
+ getMilestoneTitle,
+ getBoardsModalData,
+} from 'ee_else_ce/boards/ee_functions';
+
import Flash from '~/flash';
import { __ } from '~/locale';
import './models/label';
@@ -9,35 +25,19 @@ import './models/assignee';
import FilteredSearchBoards from '~/boards/filtered_search_boards';
import eventHub from '~/boards/eventhub';
import sidebarEventHub from '~/sidebar/event_hub';
-import 'ee_else_ce/boards/models/issue';
-import 'ee_else_ce/boards/models/list';
import '~/boards/models/milestone';
import '~/boards/models/project';
import store from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store';
import ModalStore from '~/boards/stores/modal_store';
-import BoardService from 'ee_else_ce/boards/services/board_service';
import modalMixin from '~/boards/mixins/modal_mixins';
import '~/boards/filters/due_date_filters';
-import Board from 'ee_else_ce/boards/components/board';
-import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar';
-import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown';
import BoardAddIssuesModal from '~/boards/components/modal/index.vue';
import {
NavigationType,
convertObjectPropsToCamelCase,
parseBoolean,
} from '~/lib/utils/common_utils';
-import boardConfigToggle from 'ee_else_ce/boards/config_toggle';
-import toggleFocusMode from 'ee_else_ce/boards/toggle_focus';
-import toggleLabels from 'ee_else_ce/boards/toggle_labels';
-import {
- setPromotionState,
- setWeigthFetchingState,
- setEpicFetchingState,
- getMilestoneTitle,
- getBoardsModalData,
-} from 'ee_else_ce/boards/ee_functions';
import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher';
let issueBoardsApp;
@@ -68,6 +68,8 @@ export default () => {
Board,
BoardSidebar,
BoardAddIssuesModal,
+ BoardSettingsSidebar: () =>
+ import('ee_component/boards/components/board_settings_sidebar.vue'),
},
store,
data: {
@@ -97,7 +99,6 @@ export default () => {
bulkUpdatePath: this.bulkUpdatePath,
boardId: this.boardId,
});
- gl.boardService = new BoardService();
boardsStore.rootPath = this.boardsEndpoint;
eventHub.$on('updateTokens', this.updateTokens);
@@ -116,7 +117,7 @@ export default () => {
this.filterManager.setup();
boardsStore.disabled = this.disabled;
- gl.boardService
+ boardsStore
.all()
.then(res => res.data)
.then(lists => {
@@ -155,7 +156,8 @@ export default () => {
newIssue.setFetchingState('subscriptions', true);
setWeigthFetchingState(newIssue, true);
setEpicFetchingState(newIssue, true);
- BoardService.getIssueInfo(sidebarInfoEndpoint)
+ boardsStore
+ .getIssueInfo(sidebarInfoEndpoint)
.then(res => res.data)
.then(data => {
const {
@@ -166,6 +168,7 @@ export default () => {
humanTotalTimeSpent,
weight,
epic,
+ assignees,
} = convertObjectPropsToCamelCase(data);
newIssue.setFetchingState('subscriptions', false);
@@ -179,6 +182,7 @@ export default () => {
subscribed,
weight,
epic,
+ assignees,
});
})
.catch(() => {
@@ -211,7 +215,8 @@ export default () => {
const { issue } = boardsStore.detail;
if (issue.id === id && issue.toggleSubscriptionEndpoint) {
issue.setFetchingState('subscriptions', true);
- BoardService.toggleIssueSubscription(issue.toggleSubscriptionEndpoint)
+ boardsStore
+ .toggleIssueSubscription(issue.toggleSubscriptionEndpoint)
.then(() => {
issue.setFetchingState('subscriptions', false);
issue.updateData({
diff --git a/app/assets/javascripts/boards/mixins/is_wip_limits.js b/app/assets/javascripts/boards/mixins/is_wip_limits.js
new file mode 100644
index 00000000000..f172179d3c7
--- /dev/null
+++ b/app/assets/javascripts/boards/mixins/is_wip_limits.js
@@ -0,0 +1,7 @@
+export default {
+ computed: {
+ isWipLimitsOn() {
+ return false;
+ },
+ },
+};
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index bb8c8e68297..b232fea0882 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -1,9 +1,9 @@
/* eslint-disable no-underscore-dangle, class-methods-use-this, consistent-return, no-shadow */
+import ListIssue from 'ee_else_ce/boards/models/issue';
import { __ } from '~/locale';
import ListLabel from './label';
import ListAssignee from './assignee';
-import ListIssue from 'ee_else_ce/boards/models/issue';
import { urlParamsToObject } from '~/lib/utils/common_utils';
import flash from '~/flash';
import boardsStore from '../stores/boards_store';
@@ -52,6 +52,9 @@ class List {
this.loadingMore = false;
this.issues = obj.issues || [];
this.issuesSize = obj.issuesSize ? obj.issuesSize : 0;
+ this.maxIssueCount = Object.hasOwnProperty.call(obj, 'max_issue_count')
+ ? obj.max_issue_count
+ : 0;
this.defaultAvatar = defaultAvatar;
if (obj.label) {
@@ -90,7 +93,7 @@ class List {
entityType = 'milestone_id';
}
- return gl.boardService
+ return boardsStore
.createList(entity.id, entityType)
.then(res => res.data)
.then(data => {
@@ -108,14 +111,14 @@ class List {
boardsStore.state.lists.splice(index, 1);
boardsStore.updateNewListDropdown(this.id);
- gl.boardService.destroyList(this.id).catch(() => {
+ boardsStore.destroyList(this.id).catch(() => {
// TODO: handle request error
});
}
update() {
const collapsed = !this.isExpanded;
- return gl.boardService.updateList(this.id, this.position, collapsed).catch(() => {
+ return boardsStore.updateList(this.id, this.position, collapsed).catch(() => {
// TODO: handle request error
});
}
@@ -144,7 +147,7 @@ class List {
this.loading = true;
}
- return gl.boardService
+ return boardsStore
.getIssuesForList(this.id, data)
.then(res => res.data)
.then(data => {
@@ -165,7 +168,7 @@ class List {
this.addIssue(issue, null, 0);
this.issuesSize += 1;
- return gl.boardService
+ return boardsStore
.newIssue(this.id, issue)
.then(res => res.data)
.then(data => this.onNewIssueResponse(issue, data));
@@ -273,7 +276,7 @@ class List {
this.issues.splice(oldIndex, 1);
this.issues.splice(newIndex, 0, issue);
- gl.boardService.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId).catch(() => {
+ boardsStore.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId).catch(() => {
// TODO: handle request error
});
}
@@ -284,7 +287,7 @@ class List {
});
this.issues.splice(newIndex, 0, ...issues);
- gl.boardService
+ boardsStore
.moveMultipleIssues({
ids: issues.map(issue => issue.id),
fromListId: null,
@@ -296,15 +299,13 @@ class List {
}
updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) {
- gl.boardService
- .moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId)
- .catch(() => {
- // TODO: handle request error
- });
+ boardsStore.moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId).catch(() => {
+ // TODO: handle request error
+ });
}
updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId) {
- gl.boardService
+ boardsStore
.moveMultipleIssues({
ids: issues.map(issue => issue.id),
fromListId: listFrom.id,
@@ -356,7 +357,7 @@ class List {
if (this.issuesSize > 1) {
const moveBeforeId = this.issues[1].id;
- gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeId);
+ boardsStore.moveIssue(issue.id, null, null, null, moveBeforeId);
}
}
}
diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js
deleted file mode 100644
index 03369febb4a..00000000000
--- a/app/assets/javascripts/boards/services/board_service.js
+++ /dev/null
@@ -1,98 +0,0 @@
-/* eslint-disable class-methods-use-this */
-/**
- * This file is intended to be deleted.
- * The existing functions will removed one by one in favor of using the board store directly.
- * see https://gitlab.com/gitlab-org/gitlab-foss/issues/61621
- */
-
-import boardsStore from '~/boards/stores/boards_store';
-
-export default class BoardService {
- generateBoardsPath(id) {
- return boardsStore.generateBoardsPath(id);
- }
-
- generateIssuesPath(id) {
- return boardsStore.generateIssuesPath(id);
- }
-
- static generateIssuePath(boardId, id) {
- return boardsStore.generateIssuePath(boardId, id);
- }
-
- all() {
- return boardsStore.all();
- }
-
- generateDefaultLists() {
- return boardsStore.generateDefaultLists();
- }
-
- createList(entityId, entityType) {
- return boardsStore.createList(entityId, entityType);
- }
-
- updateList(id, position, collapsed) {
- return boardsStore.updateList(id, position, collapsed);
- }
-
- destroyList(id) {
- return boardsStore.destroyList(id);
- }
-
- getIssuesForList(id, filter = {}) {
- return boardsStore.getIssuesForList(id, filter);
- }
-
- moveIssue(id, fromListId = null, toListId = null, moveBeforeId = null, moveAfterId = null) {
- return boardsStore.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId);
- }
-
- moveMultipleIssues({
- ids,
- fromListId = null,
- toListId = null,
- moveBeforeId = null,
- moveAfterId = null,
- }) {
- return boardsStore.moveMultipleIssues({ ids, fromListId, toListId, moveBeforeId, moveAfterId });
- }
-
- newIssue(id, issue) {
- return boardsStore.newIssue(id, issue);
- }
-
- getBacklog(data) {
- return boardsStore.getBacklog(data);
- }
-
- bulkUpdate(issueIds, extraData = {}) {
- return boardsStore.bulkUpdate(issueIds, extraData);
- }
-
- static getIssueInfo(endpoint) {
- return boardsStore.getIssueInfo(endpoint);
- }
-
- static toggleIssueSubscription(endpoint) {
- return boardsStore.toggleIssueSubscription(endpoint);
- }
-
- allBoards() {
- return boardsStore.allBoards();
- }
-
- recentBoards() {
- return boardsStore.recentBoards();
- }
-
- createBoard(board) {
- return boardsStore.createBoard(board);
- }
-
- deleteBoard({ id }) {
- return boardsStore.deleteBoard({ id });
- }
-}
-
-window.BoardService = BoardService;
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
index 24f44dc5629..731aea996fb 100644
--- a/app/assets/javascripts/boards/stores/state.js
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -1,3 +1,4 @@
export default () => ({
isShowingLabels: true,
+ activeListId: 0,
});
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index 75909dd9d20..d990d2677a8 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -1,7 +1,7 @@
import Visibility from 'visibilityjs';
import Vue from 'vue';
-import AccessorUtilities from '~/lib/utils/accessor';
import { GlToast } from '@gitlab/ui';
+import AccessorUtilities from '~/lib/utils/accessor';
import PersistentUserCallout from '../persistent_user_callout';
import { s__, sprintf } from '../locale';
import Flash from '../flash';
@@ -12,6 +12,7 @@ import { APPLICATION_STATUS, INGRESS, INGRESS_DOMAIN_SUFFIX, CROSSPLANE } from '
import ClustersService from './services/clusters_service';
import ClustersStore from './stores/clusters_store';
import Applications from './components/applications.vue';
+import RemoveClusterConfirmation from './components/remove_cluster_confirmation.vue';
import setupToggleButtons from '../toggle_buttons';
import initProjectSelectDropdown from '~/project_select';
@@ -144,6 +145,8 @@ export default class Clusters {
() => this.handlePollError(),
);
}
+
+ this.initRemoveClusterActions();
}
initApplications(type) {
@@ -205,6 +208,25 @@ export default class Clusters {
});
}
+ initRemoveClusterActions() {
+ const el = document.querySelector('#js-cluster-remove-actions');
+ if (el && el.dataset) {
+ const { clusterName, clusterPath } = el.dataset;
+
+ this.removeClusterAction = new Vue({
+ el,
+ render(createElement) {
+ return createElement(RemoveClusterConfirmation, {
+ props: {
+ clusterName,
+ clusterPath,
+ },
+ });
+ },
+ });
+ }
+ }
+
handleClusterEnvironmentsSuccess(data) {
this.store.toggleFetchEnvironments(false);
this.store.updateEnvironments(data.data);
diff --git a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue
index 966918ae636..6b99bb09504 100644
--- a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue
+++ b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue
@@ -1,6 +1,5 @@
<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import Icon from '~/vue_shared/components/icon.vue';
+import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
import { s__ } from '../../locale';
export default {
@@ -8,7 +7,7 @@ export default {
components: {
GlDropdown,
GlDropdownItem,
- Icon,
+ GlIcon,
},
props: {
stacks: {
@@ -86,8 +85,9 @@ export default {
href="https://crossplane.io/docs/master/stacks-guide.html"
target="_blank"
rel="noopener noreferrer"
- >{{ __('Crossplane') }}</a
- >
+ >{{ __('Crossplane') }}
+ <gl-icon name="external-link" class="vertical-align-middle" />
+ </a>
</p>
</div>
</template>
diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue
index 25347b11b6c..66c8297cb75 100644
--- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue
+++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue
@@ -1,7 +1,7 @@
<script>
+import { GlLoadingIcon } from '@gitlab/ui';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
-import { GlLoadingIcon } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { APPLICATION_STATUS } from '~/clusters/constants';
diff --git a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
new file mode 100644
index 00000000000..c31ba7ef14a
--- /dev/null
+++ b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
@@ -0,0 +1,168 @@
+<script>
+import _ from 'underscore';
+import SplitButton from '~/vue_shared/components/split_button.vue';
+import { GlModal, GlButton, GlFormInput } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+import csrf from '~/lib/utils/csrf';
+
+const splitButtonActionItems = [
+ {
+ title: s__('ClusterIntegration|Remove integration and resources'),
+ description: s__(
+ 'ClusterIntegration|Deletes all GitLab resources attached to this cluster during removal',
+ ),
+ eventName: 'remove-cluster-and-cleanup',
+ },
+ {
+ title: s__('ClusterIntegration|Remove integration'),
+ description: s__(
+ 'ClusterIntegration|Removes cluster from project but keeps associated resources',
+ ),
+ eventName: 'remove-cluster',
+ },
+];
+
+export default {
+ splitButtonActionItems,
+ components: {
+ SplitButton,
+ GlModal,
+ GlButton,
+ GlFormInput,
+ },
+ props: {
+ clusterPath: {
+ type: String,
+ required: true,
+ },
+ clusterName: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ enteredClusterName: '',
+ confirmCleanup: false,
+ };
+ },
+ computed: {
+ csrfToken() {
+ return csrf.token;
+ },
+ modalTitle() {
+ return this.confirmCleanup
+ ? s__('ClusterIntegration|Remove integration and resources?')
+ : s__('ClusterIntegration|Remove integration?');
+ },
+ warningMessage() {
+ return this.confirmCleanup
+ ? s__(
+ 'ClusterIntegration|You are about to remove your cluster integration and all GitLab-created resources associated with this cluster.',
+ )
+ : s__('ClusterIntegration|You are about to remove your cluster integration.');
+ },
+ warningToBeRemoved() {
+ return s__(`ClusterIntegration|
+ This will permanently delete the following resources:
+ <ul>
+ <li>All installed applications and related resources</li>
+ <li>The <code>gitlab-managed-apps</code> namespace</li>
+ <li>Any project namespaces</li>
+ <li><code>clusterroles</code></li>
+ <li><code>clusterrolebindings</code></li>
+ </ul>
+ `);
+ },
+ confirmationTextLabel() {
+ return sprintf(
+ this.confirmCleanup
+ ? s__(
+ 'ClusterIntegration|To remove your integration and resources, type %{clusterName} to confirm:',
+ )
+ : s__('ClusterIntegration|To remove your integration, type %{clusterName} to confirm:'),
+ {
+ clusterName: `<code>${_.escape(this.clusterName)}</code>`,
+ },
+ false,
+ );
+ },
+ canSubmit() {
+ return this.enteredClusterName === this.clusterName;
+ },
+ },
+ methods: {
+ handleClickRemoveCluster(cleanup = false) {
+ this.confirmCleanup = cleanup;
+ this.$refs.modal.show();
+ },
+ handleCancel() {
+ this.$refs.modal.hide();
+ this.enteredClusterName = '';
+ },
+ handleSubmit(cleanup = false) {
+ this.$refs.cleanup.name = cleanup === true ? 'cleanup' : 'no_cleanup';
+ this.$refs.form.submit();
+ this.enteredClusterName = '';
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <split-button
+ :action-items="$options.splitButtonActionItems"
+ menu-class="dropdown-menu-large"
+ variant="danger"
+ @remove-cluster="handleClickRemoveCluster(false)"
+ @remove-cluster-and-cleanup="handleClickRemoveCluster(true)"
+ />
+ <gl-modal
+ ref="modal"
+ size="lg"
+ modal-id="delete-cluster-modal"
+ :title="modalTitle"
+ kind="danger"
+ >
+ <template>
+ <p>{{ warningMessage }}</p>
+ <div v-if="confirmCleanup" v-html="warningToBeRemoved"></div>
+ <strong v-html="confirmationTextLabel"></strong>
+ <form ref="form" :action="clusterPath" method="post" class="append-bottom-20">
+ <input ref="method" type="hidden" name="_method" value="delete" />
+ <input :value="csrfToken" type="hidden" name="authenticity_token" />
+ <input ref="cleanup" type="hidden" name="cleanup" value="true" />
+ <gl-form-input
+ v-model="enteredClusterName"
+ autofocus
+ type="text"
+ name="confirm_cluster_name_input"
+ autocomplete="off"
+ />
+ </form>
+ <span v-if="confirmCleanup">{{
+ s__(
+ 'ClusterIntegration|If you do not wish to delete all associated GitLab resources, you can simply remove the integration.',
+ )
+ }}</span>
+ </template>
+ <template slot="modal-footer">
+ <gl-button variant="secondary" @click="handleCancel">{{ s__('Cancel') }}</gl-button>
+ <template v-if="confirmCleanup">
+ <gl-button :disabled="!canSubmit" variant="warning" @click="handleSubmit">{{
+ s__('ClusterIntegration|Remove integration')
+ }}</gl-button>
+ <gl-button :disabled="!canSubmit" variant="danger" @click="handleSubmit(true)">{{
+ s__('ClusterIntegration|Remove integration and resources')
+ }}</gl-button>
+ </template>
+ <template v-else>
+ <gl-button :disabled="!canSubmit" variant="danger" @click="handleSubmit">{{
+ s__('ClusterIntegration|Remove integration')
+ }}</gl-button>
+ </template>
+ </template>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
index 125bcaacc1c..e33431d2ea1 100644
--- a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
+++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
@@ -1,7 +1,7 @@
<script>
import { GlModal } from '@gitlab/ui';
-import { sprintf, s__ } from '~/locale';
import trackUninstallButtonClickMixin from 'ee_else_ce/clusters/mixins/track_uninstall_button_click';
+import { sprintf, s__ } from '~/locale';
import {
HELM,
INGRESS,
diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js
index 60c2059a876..a28e17f7a56 100644
--- a/app/assets/javascripts/commit/image_file.js
+++ b/app/assets/javascripts/commit/image_file.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, no-else-return, consistent-return, one-var, no-return-assign */
+/* eslint-disable func-names, no-else-return, consistent-return, one-var, no-return-assign */
import $ from 'jquery';
@@ -51,7 +51,7 @@ export default class ImageFile {
}
// eslint-disable-next-line class-methods-use-this
initDraggable($el, padding, callback) {
- var dragging = false;
+ let dragging = false;
const $body = $('body');
const $offsetEl = $el.parent();
const dragStart = function() {
@@ -88,14 +88,12 @@ export default class ImageFile {
}
static prepareFrames(view) {
- var maxHeight, maxWidth;
- maxWidth = 0;
- maxHeight = 0;
+ let maxWidth = 0;
+ let maxHeight = 0;
$('.frame', view)
.each((index, frame) => {
- var height, width;
- width = $(frame).width();
- height = $(frame).height();
+ const width = $(frame).width();
+ const height = $(frame).height();
maxWidth = width > maxWidth ? width : maxWidth;
return (maxHeight = height > maxHeight ? height : maxHeight);
})
@@ -110,8 +108,7 @@ export default class ImageFile {
'two-up': function() {
return $('.two-up.view .wrap', this.file).each((index, wrap) => {
$('img', wrap).each(function() {
- var currentWidth;
- currentWidth = $(this).width();
+ const currentWidth = $(this).width();
if (currentWidth > availWidth / 2) {
return $(this).width(availWidth / 2);
}
@@ -124,16 +121,14 @@ export default class ImageFile {
});
},
swipe() {
- var maxHeight, maxWidth;
- maxWidth = 0;
- maxHeight = 0;
+ let maxWidth = 0;
+ let maxHeight = 0;
return $('.swipe.view', this.file).each((index, view) => {
- var $swipeWrap, $swipeBar, $swipeFrame, wrapPadding;
const ref = ImageFile.prepareFrames(view);
[maxWidth, maxHeight] = ref;
- $swipeFrame = $('.swipe-frame', view);
- $swipeWrap = $('.swipe-wrap', view);
- $swipeBar = $('.swipe-bar', view);
+ const $swipeFrame = $('.swipe-frame', view);
+ const $swipeWrap = $('.swipe-wrap', view);
+ const $swipeBar = $('.swipe-bar', view);
$swipeFrame.css({
width: maxWidth + 16,
@@ -148,7 +143,7 @@ export default class ImageFile {
left: 1,
});
- wrapPadding = parseInt($swipeWrap.css('right').replace('px', ''), 10);
+ const wrapPadding = parseInt($swipeWrap.css('right').replace('px', ''), 10);
this.initDraggable($swipeBar, wrapPadding, (e, left) => {
if (left > 0 && left < $swipeFrame.width() - wrapPadding * 2) {
@@ -159,19 +154,17 @@ export default class ImageFile {
});
},
'onion-skin': function() {
- var dragTrackWidth, maxHeight, maxWidth;
+ let maxHeight, maxWidth;
maxWidth = 0;
maxHeight = 0;
- dragTrackWidth = $('.drag-track', this.file).width() - $('.dragger', this.file).width();
+ const dragTrackWidth = $('.drag-track', this.file).width() - $('.dragger', this.file).width();
return $('.onion-skin.view', this.file).each((index, view) => {
- var $frame, $track, $dragger, $frameAdded, framePadding;
-
const ref = ImageFile.prepareFrames(view);
[maxWidth, maxHeight] = ref;
- $frame = $('.onion-skin-frame', view);
- $frameAdded = $('.frame.added', view);
- $track = $('.drag-track', view);
- $dragger = $('.dragger', $track);
+ const $frame = $('.onion-skin-frame', view);
+ const $frameAdded = $('.frame.added', view);
+ const $track = $('.drag-track', view);
+ const $dragger = $('.dragger', $track);
$frame.css({
width: maxWidth + 16,
@@ -186,10 +179,10 @@ export default class ImageFile {
});
$frameAdded.css('opacity', 1);
- framePadding = parseInt($frameAdded.css('right').replace('px', ''), 10);
+ const framePadding = parseInt($frameAdded.css('right').replace('px', ''), 10);
this.initDraggable($dragger, framePadding, (e, left) => {
- var opacity = left / dragTrackWidth;
+ const opacity = left / dragTrackWidth;
if (opacity >= 0 && opacity <= 1) {
$dragger.css('left', left);
diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js
index 7a6ad3dc771..dd300b8a307 100644
--- a/app/assets/javascripts/commons/polyfills.js
+++ b/app/assets/javascripts/commons/polyfills.js
@@ -4,6 +4,7 @@ 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';
diff --git a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
index 4fa18b19556..f2853564f94 100644
--- a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
+++ b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
@@ -1,6 +1,6 @@
<script>
-import { GlLink } from '@gitlab/ui';
-import { __, sprintf } from '../../locale';
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { __ } from '../../locale';
import createFlash from '../../flash';
import Api from '../../api';
import state from '../state';
@@ -9,6 +9,7 @@ import Dropdown from './dropdown.vue';
export default {
components: {
GlLink,
+ GlSprintf,
Dropdown,
},
props: {
@@ -38,15 +39,6 @@ export default {
selectedProject() {
return state.selectedProject;
},
- noForkText() {
- return sprintf(
- __(
- "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private.",
- ),
- { link_start: `<a href="${this.newForkPath}" class="help-link">`, link_end: '</a>' },
- false,
- );
- },
},
mounted() {
this.fetchProjects();
@@ -123,8 +115,20 @@ export default {
}}
</template>
<template v-else>
- {{ __('No forks available to you.') }}<br />
- <span v-html="noForkText"></span>
+ {{ __('No forks are available to you.') }}<br />
+ <gl-sprintf
+ :message="
+ __(
+ `To protect this issue's confidentiality, %{forkLink} and set the fork's visibility to private.`,
+ )
+ "
+ >
+ <template #forkLink>
+ <a :href="newForkPath" target="_blank" class="help-link">{{
+ __('fork this project')
+ }}</a>
+ </template>
+ </gl-sprintf>
</template>
<gl-link
:href="helpPagePath"
diff --git a/app/assets/javascripts/confirm_danger_modal.js b/app/assets/javascripts/confirm_danger_modal.js
index 1000c310e35..262d501bfba 100644
--- a/app/assets/javascripts/confirm_danger_modal.js
+++ b/app/assets/javascripts/confirm_danger_modal.js
@@ -1,39 +1,54 @@
import $ from 'jquery';
import { rstrip } from './lib/utils/common_utils';
-function openConfirmDangerModal($form, text) {
- const $input = $('.js-confirm-danger-input');
+function openConfirmDangerModal($form, $modal, text) {
+ const $input = $('.js-confirm-danger-input', $modal);
$input.val('');
- $('.js-confirm-text').text(text || '');
- $('#modal-confirm-danger').modal('show');
+ $('.js-confirm-text', $modal).text(text || '');
+ $modal.modal('show');
- const confirmTextMatch = $('.js-confirm-danger-match').text();
- const $submit = $('.js-confirm-danger-submit');
+ const confirmTextMatch = $('.js-confirm-danger-match', $modal).text();
+ const $submit = $('.js-confirm-danger-submit', $modal);
$submit.disable();
$input.focus();
- $('.js-confirm-danger-input')
- .off('input')
- .on('input', function handleInput() {
- const confirmText = rstrip($(this).val());
- if (confirmText === confirmTextMatch) {
- $submit.enable();
- } else {
- $submit.disable();
- }
- });
- $('.js-confirm-danger-submit')
+ $input.off('input').on('input', function handleInput() {
+ const confirmText = rstrip($(this).val());
+ if (confirmText === confirmTextMatch) {
+ $submit.enable();
+ } else {
+ $submit.disable();
+ }
+ });
+ $('.js-confirm-danger-submit', $modal)
.off('click')
.on('click', () => $form.submit());
}
+function getModal($btn) {
+ const $modal = $btn.prev('.modal');
+
+ if ($modal.length) {
+ return $modal;
+ }
+
+ return $('#modal-confirm-danger');
+}
+
export default function initConfirmDangerModal() {
$(document).on('click', '.js-confirm-danger', e => {
- e.preventDefault();
const $btn = $(e.target);
- const $form = $btn.closest('form');
- const text = $btn.data('confirmDangerMessage');
- openConfirmDangerModal($form, text);
+ const checkFieldName = $btn.data('checkFieldName');
+ const checkFieldCompareValue = $btn.data('checkCompareValue');
+ const checkFieldVal = parseInt($(`[name="${checkFieldName}"]`).val(), 10);
+
+ if (!checkFieldName || checkFieldVal < checkFieldCompareValue) {
+ e.preventDefault();
+ const $form = $btn.closest('form');
+ const $modal = getModal($btn);
+ const text = $btn.data('confirmDangerMessage');
+ openConfirmDangerModal($form, $modal, text);
+ }
});
}
diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue
index 7dd6b051cb4..fb7000ee9ed 100644
--- a/app/assets/javascripts/contributors/components/contributors.vue
+++ b/app/assets/javascripts/contributors/components/contributors.vue
@@ -1,9 +1,9 @@
<script>
-import { __ } from '~/locale';
import _ from 'underscore';
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
+import { __ } from '~/locale';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import { getDatesInRange } from '~/lib/utils/datetime_utility';
import { xAxisLabelFormatter, dateFormatter } from '../utils';
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue
index e6893c14cda..2f7fcfcb755 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue
@@ -1,8 +1,9 @@
<script>
+import $ from 'jquery';
+import { GlIcon } from '@gitlab/ui';
import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue';
import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue';
import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
-import { GlIcon } from '@gitlab/ui';
const toArray = value => [].concat(value);
const itemsProp = (items, prop) => items.map(item => item[prop]);
@@ -106,6 +107,7 @@ export default {
data() {
return {
searchQuery: '',
+ focusOnSearch: false,
};
},
computed: {
@@ -141,6 +143,18 @@ export default {
return itemsProp(this.selectedItems, this.valueProperty).join(', ');
},
},
+ mounted() {
+ $(this.$refs.dropdown)
+ .on('shown.bs.dropdown', () => {
+ this.focusOnSearch = true;
+ })
+ .on('hidden.bs.dropdown', () => {
+ this.focusOnSearch = false;
+ });
+ },
+ beforeDestroy() {
+ $(this.$refs.dropdown).off();
+ },
methods: {
getItemsOrEmptyList() {
return this.items || [];
@@ -170,7 +184,7 @@ export default {
<template>
<div>
- <div class="js-gcp-machine-type-dropdown dropdown">
+ <div ref="dropdown" class="dropdown">
<dropdown-hidden-input :name="fieldName" :value="selectedItemsValues" />
<dropdown-button
:class="{ 'border-danger': hasErrors }"
@@ -179,7 +193,11 @@ export default {
:toggle-text="toggleText"
/>
<div class="dropdown-menu dropdown-select">
- <dropdown-search-input v-model="searchQuery" :placeholder-text="searchFieldPlaceholder" />
+ <dropdown-search-input
+ v-model="searchQuery"
+ :focused="focusOnSearch"
+ :placeholder-text="searchFieldPlaceholder"
+ />
<div class="dropdown-content">
<ul>
<li v-if="!results.length">
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 57d5f4f541b..d04d0ff2a6d 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
@@ -1,8 +1,8 @@
<script>
import { createNamespacedHelpers, mapState, mapActions } from 'vuex';
-import { sprintf, s__ } from '~/locale';
import _ from 'underscore';
import { GlFormInput, GlFormCheckbox } from '@gitlab/ui';
+import { sprintf, s__ } from '~/locale';
import ClusterFormDropdown from './cluster_form_dropdown.vue';
import { KUBERNETES_VERSIONS } from '../constants';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
@@ -22,10 +22,7 @@ const {
mapState: mapSecurityGroupsState,
mapActions: mapSecurityGroupsActions,
} = createNamespacedHelpers('securityGroups');
-const {
- mapState: mapInstanceTypesState,
- mapActions: mapInstanceTypesActions,
-} = createNamespacedHelpers('instanceTypes');
+const { mapState: mapInstanceTypesState } = createNamespacedHelpers('instanceTypes');
export default {
components: {
@@ -265,12 +262,10 @@ export default {
mounted() {
this.fetchRegions();
this.fetchRoles();
- this.fetchInstanceTypes();
},
methods: {
...mapActions([
'createCluster',
- 'signOut',
'setClusterName',
'setEnvironmentScope',
'setKubernetesVersion',
@@ -290,7 +285,6 @@ export default {
...mapRolesActions({ fetchRoles: 'fetchItems' }),
...mapKeyPairsActions({ fetchKeyPairs: 'fetchItems' }),
...mapSecurityGroupsActions({ fetchSecurityGroups: 'fetchItems' }),
- ...mapInstanceTypesActions({ fetchInstanceTypes: 'fetchItems' }),
setRegionAndFetchVpcsAndKeyPairs(region) {
this.setRegion({ region });
this.setVpc({ vpc: null });
@@ -316,11 +310,6 @@ export default {
{{ s__('ClusterIntegration|Enter the details for your Amazon EKS Kubernetes cluster') }}
</h2>
<div class="mb-3" v-html="kubernetesIntegrationHelpText"></div>
- <div class="mb-3">
- <button class="btn btn-link js-sign-out" @click.prevent="signOut()">
- {{ s__('ClusterIntegration|Select a different AWS role') }}
- </button>
- </div>
<div class="form-group">
<label class="label-bold" for="eks-cluster-name">{{
s__('ClusterIntegration|Kubernetes 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 ab33e9fbc95..1dd4c468ae6 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
@@ -1,8 +1,8 @@
<script>
import { GlFormInput } from '@gitlab/ui';
-import { sprintf, s__, __ } from '~/locale';
import _ from 'underscore';
import { mapState, mapActions } from 'vuex';
+import { sprintf, s__, __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
@@ -28,7 +28,7 @@ export default {
},
data() {
return {
- roleArn: '',
+ roleArn: this.$store.state.roleArn,
};
},
computed: {
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/index.js b/app/assets/javascripts/create_cluster/eks_cluster/index.js
index 27f859d8972..fb993a7aa59 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/index.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/index.js
@@ -12,20 +12,14 @@ export default el => {
kubernetesIntegrationHelpPath,
accountAndExternalIdsHelpPath,
createRoleArnHelpPath,
- getRolesPath,
- getRegionsPath,
- getKeyPairsPath,
- getVpcsPath,
- getSubnetsPath,
- getSecurityGroupsPath,
- getInstanceTypesPath,
externalId,
accountId,
+ instanceTypes,
hasCredentials,
createRolePath,
createClusterPath,
- signOutPath,
externalLinkIcon,
+ roleArn,
} = el.dataset;
return new Vue({
@@ -35,18 +29,10 @@ export default el => {
hasCredentials: parseBoolean(hasCredentials),
externalId,
accountId,
+ instanceTypes: JSON.parse(instanceTypes),
createRolePath,
createClusterPath,
- signOutPath,
- },
- apiPaths: {
- getRolesPath,
- getRegionsPath,
- getKeyPairsPath,
- getVpcsPath,
- getSubnetsPath,
- getSecurityGroupsPath,
- getInstanceTypesPath,
+ roleArn,
},
}),
components: {
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js
index 21b87d525cf..601ff6f9adc 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js
@@ -1,58 +1,98 @@
-import axios from '~/lib/utils/axios_utils';
-
-export default apiPaths => ({
- fetchRoles() {
- return axios
- .get(apiPaths.getRolesPath)
- .then(({ data: { roles } }) =>
- roles.map(({ role_name: name, arn: value }) => ({ name, value })),
- );
- },
- fetchKeyPairs({ region }) {
- return axios
- .get(apiPaths.getKeyPairsPath, { params: { region } })
- .then(({ data: { key_pairs: keyPairs } }) =>
- keyPairs.map(({ key_name }) => ({ name: key_name, value: key_name })),
- );
- },
- fetchRegions() {
- return axios.get(apiPaths.getRegionsPath).then(({ data: { regions } }) =>
- regions.map(({ region_name }) => ({
- name: region_name,
- value: region_name,
+import AWS from 'aws-sdk/global';
+import EC2 from 'aws-sdk/clients/ec2';
+import IAM from 'aws-sdk/clients/iam';
+
+const lookupVpcName = ({ Tags: tags, VpcId: id }) => {
+ const nameTag = tags.find(({ Key: key }) => key === 'Name');
+
+ return nameTag ? nameTag.Value : id;
+};
+
+export const DEFAULT_REGION = 'us-east-2';
+
+export const setAWSConfig = ({ awsCredentials }) => {
+ AWS.config = {
+ ...awsCredentials,
+ region: DEFAULT_REGION,
+ };
+};
+
+export const fetchRoles = () => {
+ const iam = new IAM();
+
+ return iam
+ .listRoles()
+ .promise()
+ .then(({ Roles: roles }) => roles.map(({ RoleName: name, Arn: value }) => ({ name, value })));
+};
+
+export const fetchRegions = () => {
+ const ec2 = new EC2();
+
+ return ec2
+ .describeRegions()
+ .promise()
+ .then(({ Regions: regions }) =>
+ regions.map(({ RegionName: name }) => ({
+ name,
+ value: name,
})),
);
- },
- fetchVpcs({ region }) {
- return axios.get(apiPaths.getVpcsPath, { params: { region } }).then(({ data: { vpcs } }) =>
- vpcs.map(({ vpc_id }) => ({
- value: vpc_id,
- name: vpc_id,
+};
+
+export const fetchKeyPairs = ({ region }) => {
+ const ec2 = new EC2({ region });
+
+ return ec2
+ .describeKeyPairs()
+ .promise()
+ .then(({ KeyPairs: keyPairs }) => keyPairs.map(({ KeyName: name }) => ({ name, value: name })));
+};
+
+export const fetchVpcs = ({ region }) => {
+ const ec2 = new EC2({ region });
+
+ return ec2
+ .describeVpcs()
+ .promise()
+ .then(({ Vpcs: vpcs }) =>
+ vpcs.map(vpc => ({
+ value: vpc.VpcId,
+ name: lookupVpcName(vpc),
})),
);
- },
- fetchSubnets({ vpc, region }) {
- return axios
- .get(apiPaths.getSubnetsPath, { params: { vpc_id: vpc, region } })
- .then(({ data: { subnets } }) =>
- subnets.map(({ subnet_id }) => ({ name: subnet_id, value: subnet_id })),
- );
- },
- fetchSecurityGroups({ vpc, region }) {
- return axios
- .get(apiPaths.getSecurityGroupsPath, { params: { vpc_id: vpc, region } })
- .then(({ data: { security_groups: securityGroups } }) =>
- securityGroups.map(({ group_name: name, group_id: value }) => ({ name, value })),
- );
- },
- fetchInstanceTypes() {
- return axios
- .get(apiPaths.getInstanceTypesPath)
- .then(({ data: { instance_types: instanceTypes } }) =>
- instanceTypes.map(({ instance_type_name }) => ({
- name: instance_type_name,
- value: instance_type_name,
- })),
- );
- },
-});
+};
+
+export const fetchSubnets = ({ vpc, region }) => {
+ const ec2 = new EC2({ region });
+
+ return ec2
+ .describeSubnets({
+ Filters: [
+ {
+ Name: 'vpc-id',
+ Values: [vpc],
+ },
+ ],
+ })
+ .promise()
+ .then(({ Subnets: subnets }) => subnets.map(({ SubnetId: id }) => ({ value: id, name: id })));
+};
+
+export const fetchSecurityGroups = ({ region, vpc }) => {
+ const ec2 = new EC2({ region });
+
+ return ec2
+ .describeSecurityGroups({
+ Filters: [
+ {
+ Name: 'vpc-id',
+ Values: [vpc],
+ },
+ ],
+ })
+ .promise()
+ .then(({ SecurityGroups: securityGroups }) =>
+ securityGroups.map(({ GroupName: name, GroupId: value }) => ({ name, value })),
+ );
+};
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
index 72f15263a8f..e96e6d6e4f8 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
@@ -1,6 +1,8 @@
import * as types from './mutation_types';
+import { setAWSConfig } from '../services/aws_services_facade';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
const getErrorMessage = data => {
const errorKey = Object.keys(data)[0];
@@ -28,7 +30,7 @@ export const createRole = ({ dispatch, state: { createRolePath } }, payload) =>
role_arn: payload.roleArn,
role_external_id: payload.externalId,
})
- .then(() => dispatch('createRoleSuccess'))
+ .then(({ data }) => dispatch('createRoleSuccess', convertObjectPropsToCamelCase(data)))
.catch(error => dispatch('createRoleError', { error }));
};
@@ -36,7 +38,8 @@ export const requestCreateRole = ({ commit }) => {
commit(types.REQUEST_CREATE_ROLE);
};
-export const createRoleSuccess = ({ commit }) => {
+export const createRoleSuccess = ({ commit }, awsCredentials) => {
+ setAWSConfig({ awsCredentials });
commit(types.CREATE_ROLE_SUCCESS);
};
@@ -117,9 +120,3 @@ export const setInstanceType = ({ commit }, payload) => {
export const setNodeCount = ({ commit }, payload) => {
commit(types.SET_NODE_COUNT, payload);
};
-
-export const signOut = ({ commit, state: { signOutPath } }) =>
- axios
- .delete(signOutPath)
- .then(() => commit(types.SIGN_OUT))
- .catch(({ response: { data } }) => createFlash(getErrorMessage(data)));
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js
index 07a5821c47d..0b19589215c 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js
@@ -3,11 +3,11 @@ import actions from './actions';
import mutations from './mutations';
import state from './state';
-const createStore = fetchFn => ({
+const createStore = ({ fetchFn, initialState }) => ({
actions: actions(fetchFn),
getters,
mutations,
- state: state(),
+ state: Object.assign(state(), initialState || {}),
});
export default createStore;
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js
index 5982fc8a2fd..09fd560240d 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js
@@ -6,12 +6,17 @@ import state from './state';
import clusterDropdownStore from './cluster_dropdown';
-import awsServicesFactory from '../services/aws_services_facade';
+import {
+ fetchRoles,
+ fetchRegions,
+ fetchKeyPairs,
+ fetchVpcs,
+ fetchSubnets,
+ fetchSecurityGroups,
+} from '../services/aws_services_facade';
-const createStore = ({ initialState, apiPaths }) => {
- const awsServices = awsServicesFactory(apiPaths);
-
- return new Vuex.Store({
+const createStore = ({ initialState }) =>
+ new Vuex.Store({
actions,
getters,
mutations,
@@ -19,34 +24,33 @@ const createStore = ({ initialState, apiPaths }) => {
modules: {
roles: {
namespaced: true,
- ...clusterDropdownStore(awsServices.fetchRoles),
+ ...clusterDropdownStore({ fetchFn: fetchRoles }),
},
regions: {
namespaced: true,
- ...clusterDropdownStore(awsServices.fetchRegions),
+ ...clusterDropdownStore({ fetchFn: fetchRegions }),
},
keyPairs: {
namespaced: true,
- ...clusterDropdownStore(awsServices.fetchKeyPairs),
+ ...clusterDropdownStore({ fetchFn: fetchKeyPairs }),
},
vpcs: {
namespaced: true,
- ...clusterDropdownStore(awsServices.fetchVpcs),
+ ...clusterDropdownStore({ fetchFn: fetchVpcs }),
},
subnets: {
namespaced: true,
- ...clusterDropdownStore(awsServices.fetchSubnets),
+ ...clusterDropdownStore({ fetchFn: fetchSubnets }),
},
securityGroups: {
namespaced: true,
- ...clusterDropdownStore(awsServices.fetchSecurityGroups),
+ ...clusterDropdownStore({ fetchFn: fetchSecurityGroups }),
},
instanceTypes: {
namespaced: true,
- ...clusterDropdownStore(awsServices.fetchInstanceTypes),
+ ...clusterDropdownStore({ initialState: { items: initialState.instanceTypes } }),
},
},
});
-};
export default createStore;
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js
index f9204cc2207..9dee6abae5f 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js
@@ -13,7 +13,6 @@ export const SET_GITLAB_MANAGED_CLUSTER = 'SET_GITLAB_MANAGED_CLUSTER';
export const REQUEST_CREATE_ROLE = 'REQUEST_CREATE_ROLE';
export const CREATE_ROLE_SUCCESS = 'CREATE_ROLE_SUCCESS';
export const CREATE_ROLE_ERROR = 'CREATE_ROLE_ERROR';
-export const SIGN_OUT = 'SIGN_OUT';
export const REQUEST_CREATE_CLUSTER = 'REQUEST_CREATE_CLUSTER';
export const CREATE_CLUSTER_SUCCESS = 'CREATE_CLUSTER_SUCCESS';
export const CREATE_CLUSTER_ERROR = 'CREATE_CLUSTER_ERROR';
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js
index aa04c8f7079..c331d27d255 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js
@@ -60,7 +60,4 @@ export default {
state.isCreatingCluster = false;
state.createClusterError = error;
},
- [types.SIGN_OUT](state) {
- state.hasCredentials = false;
- },
};
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js
index 2e3a05a9187..20434dcce98 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js
@@ -12,6 +12,8 @@ export default () => ({
accountId: '',
externalId: '',
+ roleArn: '',
+
clusterName: '',
environmentScope: '*',
kubernetesVersion,
diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_dropdown_mixin.js b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_dropdown_mixin.js
index 5a3407693e5..43fd0cac3be 100644
--- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_dropdown_mixin.js
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_dropdown_mixin.js
@@ -1,8 +1,8 @@
import _ from 'underscore';
+import { GlLoadingIcon } from '@gitlab/ui';
import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue';
import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue';
import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
-import { GlLoadingIcon } from '@gitlab/ui';
import store from '../store';
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 83811ab489a..a9d9f0224e3 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
@@ -1,6 +1,6 @@
<script>
-import { sprintf, s__ } from '~/locale';
import { mapState, mapGetters, mapActions } from 'vuex';
+import { sprintf, s__ } from '~/locale';
import gkeDropdownMixin from './gke_dropdown_mixin';
diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue
index a2eb79af4f9..6815d3629e3 100644
--- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue
@@ -1,7 +1,7 @@
<script>
import _ from 'underscore';
-import { s__, sprintf } from '~/locale';
import { mapState, mapGetters, mapActions } from 'vuex';
+import { s__, sprintf } from '~/locale';
import gkeDropdownMixin from './gke_dropdown_mixin';
diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue
index fd5d5f86401..b60a5be2e63 100644
--- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue
@@ -1,6 +1,6 @@
<script>
-import { sprintf, s__ } from '~/locale';
import { mapState, mapActions } from 'vuex';
+import { sprintf, s__ } from '~/locale';
import gkeDropdownMixin from './gke_dropdown_mixin';
diff --git a/app/assets/javascripts/create_cluster/init_create_cluster.js b/app/assets/javascripts/create_cluster/init_create_cluster.js
index 7c984582fd8..2b09771d772 100644
--- a/app/assets/javascripts/create_cluster/init_create_cluster.js
+++ b/app/assets/javascripts/create_cluster/init_create_cluster.js
@@ -6,7 +6,7 @@ const newClusterViews = [':clusters:new', ':clusters:create_gcp', ':clusters:cre
const isProjectLevelCluster = page => page.startsWith('project:clusters');
-export default (document, gon) => {
+export default document => {
const { page } = document.body.dataset;
const isNewClusterView = newClusterViews.some(view => page.endsWith(view));
@@ -19,17 +19,15 @@ export default (document, gon) => {
initGkeDropdowns();
- if (gon.features.createEksClusters) {
- import(/* webpackChunkName: 'eks_cluster' */ '~/create_cluster/eks_cluster')
- .then(({ default: initCreateEKSCluster }) => {
- const el = document.querySelector('.js-create-eks-cluster-form-container');
+ import(/* webpackChunkName: 'eks_cluster' */ '~/create_cluster/eks_cluster')
+ .then(({ default: initCreateEKSCluster }) => {
+ const el = document.querySelector('.js-create-eks-cluster-form-container');
- if (el) {
- initCreateEKSCluster(el);
- }
- })
- .catch(() => {});
- }
+ if (el) {
+ initCreateEKSCluster(el);
+ }
+ })
+ .catch(() => {});
if (isProjectLevelCluster(page)) {
initGkeNamespace();
diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js
index dce9c1a5410..d9805e5e76a 100644
--- a/app/assets/javascripts/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/create_merge_request_dropdown.js
@@ -311,6 +311,7 @@ export default class CreateMergeRequestDropdown {
}
onChangeInput(event) {
+ this.disable();
let target;
let value;
diff --git a/app/assets/javascripts/cycle_analytics/components/banner.vue b/app/assets/javascripts/cycle_analytics/components/banner.vue
index e44588efbfc..ae8c430dcd6 100644
--- a/app/assets/javascripts/cycle_analytics/components/banner.vue
+++ b/app/assets/javascripts/cycle_analytics/components/banner.vue
@@ -1,6 +1,6 @@
<script>
-import Icon from '~/vue_shared/components/icon.vue';
import iconCycleAnalyticsSplash from 'icons/_icon_cycle_analytics_splash.svg';
+import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue
index 922c907bb36..048f3a2485c 100644
--- a/app/assets/javascripts/deploy_keys/components/app.vue
+++ b/app/assets/javascripts/deploy_keys/components/app.vue
@@ -1,4 +1,5 @@
<script>
+import { GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
import Flash from '~/flash';
import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
@@ -6,7 +7,6 @@ import eventHub from '../eventhub';
import DeployKeysService from '../service';
import DeployKeysStore from '../store';
import KeysPanel from './keys_panel.vue';
-import { GlLoadingIcon } from '@gitlab/ui';
export default {
components: {
@@ -133,7 +133,7 @@ export default {
:keys="keys[currentTab]"
:store="store"
:endpoint="endpoint"
- class="qa-project-deploy-keys"
+ data-qa-selector="project_deploy_keys"
/>
</template>
</div>
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
index 6ffb8c4e1c0..4d36a492c1c 100644
--- a/app/assets/javascripts/deploy_keys/components/key.vue
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -159,7 +159,7 @@ export default {
<div role="rowheader" class="table-mobile-header">{{ __('Created') }}</div>
<div class="table-mobile-content text-secondary key-created-at">
<span v-tooltip :title="tooltipTitle(deployKey.created_at)">
- <icon name="calendar" /> <span>{{ timeFormated(deployKey.created_at) }}</span>
+ <icon name="calendar" /> <span>{{ timeFormatted(deployKey.created_at) }}</span>
</span>
</div>
</div>
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 19b85710710..8ea443814e9 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -1,11 +1,12 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
+import { GlLoadingIcon } from '@gitlab/ui';
+import Mousetrap from 'mousetrap';
import Icon from '~/vue_shared/components/icon.vue';
import { __ } from '~/locale';
import createFlash from '~/flash';
-import { GlLoadingIcon } from '@gitlab/ui';
import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
-import Mousetrap from 'mousetrap';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../../notes/event_hub';
import CompareVersions from './compare_versions.vue';
import DiffFile from './diff_file.vue';
@@ -36,11 +37,20 @@ export default {
GlLoadingIcon,
PanelResizer,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
endpoint: {
type: String,
required: true,
},
+ endpointMetadata: {
+ type: String,
+ required: true,
+ },
+ endpointBatch: {
+ type: String,
+ required: true,
+ },
projectPath: {
type: String,
required: true,
@@ -92,6 +102,7 @@ export default {
computed: {
...mapState({
isLoading: state => state.diffs.isLoading,
+ isBatchLoading: state => state.diffs.isBatchLoading,
diffFiles: state => state.diffs.diffFiles,
diffViewType: state => state.diffs.diffViewType,
mergeRequestDiffs: state => state.diffs.mergeRequestDiffs,
@@ -133,6 +144,9 @@ export default {
isLimitedContainer() {
return !this.showTreeList && !this.isParallelView && !this.isFluidLayout;
},
+ shouldSetDiscussions() {
+ return this.isNotesFetched && !this.assignedDiscussions && !this.isLoading;
+ },
},
watch: {
diffViewType() {
@@ -149,13 +163,21 @@ export default {
},
isLoading: 'adjustView',
showTreeList: 'adjustView',
+ shouldSetDiscussions(newVal) {
+ if (newVal) {
+ this.setDiscussions();
+ }
+ },
},
mounted() {
this.setBaseConfig({
endpoint: this.endpoint,
+ endpointMetadata: this.endpointMetadata,
+ endpointBatch: this.endpointBatch,
projectPath: this.projectPath,
dismissEndpoint: this.dismissEndpoint,
showSuggestPopover: this.showSuggestPopover,
+ useSingleDiffStyle: this.glFeatures.singleMrDiffView,
});
if (this.shouldShow) {
@@ -185,6 +207,8 @@ export default {
...mapActions('diffs', [
'setBaseConfig',
'fetchDiffFiles',
+ 'fetchDiffFilesMeta',
+ 'fetchDiffFilesBatch',
'startRenderDiffsQueue',
'assignDiscussionsToDiff',
'setHighlightedRow',
@@ -196,31 +220,56 @@ export default {
this.assignedDiscussions = false;
this.fetchData(false);
},
+ startDiffRendering() {
+ requestIdleCallback(
+ () => {
+ this.startRenderDiffsQueue();
+ },
+ { timeout: 1000 },
+ );
+ },
fetchData(toggleTree = true) {
- this.fetchDiffFiles()
- .then(() => {
- if (toggleTree) {
- this.hideTreeListIfJustOneFile();
- }
+ if (this.glFeatures.diffsBatchLoad) {
+ this.fetchDiffFilesMeta()
+ .then(() => {
+ if (toggleTree) this.hideTreeListIfJustOneFile();
- requestIdleCallback(
- () => {
- this.setDiscussions();
- this.startRenderDiffsQueue();
- },
- { timeout: 1000 },
- );
- })
- .catch(() => {
- createFlash(__('Something went wrong on our end. Please try again!'));
- });
+ this.startDiffRendering();
+ })
+ .catch(() => {
+ createFlash(__('Something went wrong on our end. Please try again!'));
+ });
+
+ this.fetchDiffFilesBatch()
+ .then(() => this.startDiffRendering())
+ .catch(() => {
+ createFlash(__('Something went wrong on our end. Please try again!'));
+ });
+ } else {
+ this.fetchDiffFiles()
+ .then(() => {
+ if (toggleTree) {
+ this.hideTreeListIfJustOneFile();
+ }
+
+ requestIdleCallback(
+ () => {
+ this.startRenderDiffsQueue();
+ },
+ { timeout: 1000 },
+ );
+ })
+ .catch(() => {
+ createFlash(__('Something went wrong on our end. Please try again!'));
+ });
+ }
if (!this.isNotesFetched) {
eventHub.$emit('fetchNotesData');
}
},
setDiscussions() {
- if (this.isNotesFetched && !this.assignedDiscussions && !this.isLoading) {
+ if (this.shouldSetDiscussions) {
this.assignedDiscussions = true;
requestIdleCallback(
@@ -324,7 +373,8 @@ export default {
}"
>
<commit-widget v-if="commit" :commit="commit" />
- <template v-if="renderDiffFiles">
+ <div v-if="isBatchLoading" class="loading"><gl-loading-icon /></div>
+ <template v-else-if="renderDiffFiles">
<diff-file
v-for="file in diffFiles"
:key="file.newPath"
diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
index 839ab542377..23fbfc2b74b 100644
--- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
@@ -1,7 +1,7 @@
<script>
+import { mapState, mapActions } from 'vuex';
import createFlash from '~/flash';
import { s__ } from '~/locale';
-import { mapState, mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import { UNFOLD_COUNT } from '../constants';
import * as utils from '../store/utils';
@@ -226,7 +226,7 @@ export default {
<icon :size="12" name="expand-up" aria-hidden="true" />
</a>
<a class="mx-2 cursor-pointer js-unfold-all" @click="handleExpandLines()">
- <span>{{ s__('Diffs|Show all lines') }}</span>
+ <span>{{ s__('Diffs|Show unchanged lines') }}</span>
</a>
<a
v-if="canExpandDown"
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 9236f0d5349..0dbff4ffcec 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -1,9 +1,9 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import _ from 'underscore';
+import { GlLoadingIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import createFlash from '~/flash';
-import { GlLoadingIcon } from '@gitlab/ui';
import eventHub from '../../notes/event_hub';
import DiffFileHeader from './diff_file_header.vue';
import DiffContent from './diff_content.vue';
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 665328eb234..91d374eafc0 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -1,17 +1,17 @@
<script>
import _ from 'underscore';
import { mapActions, mapGetters } from 'vuex';
-import { polyfillSticky, stickyMonitor } from '~/lib/utils/sticky';
+import { GlButton, GlTooltipDirective, GlTooltip, GlLoadingIcon } from '@gitlab/ui';
+import { polyfillSticky } from '~/lib/utils/sticky';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
-import { GlButton, GlTooltipDirective, GlTooltip, GlLoadingIcon } from '@gitlab/ui';
import { truncateSha } from '~/lib/utils/text_utility';
import { __, s__, sprintf } from '~/locale';
import { diffViewerModes } from '~/ide/constants';
import EditButton from './edit_button.vue';
import DiffStats from './diff_stats.vue';
-import { scrollToElement, contentTop } from '~/lib/utils/common_utils';
+import { scrollToElement } from '~/lib/utils/common_utils';
export default {
components: {
@@ -127,8 +127,6 @@ export default {
},
mounted() {
polyfillSticky(this.$refs.header);
- const fileHeaderHeight = this.$refs.header.clientHeight;
- stickyMonitor(this.$refs.header, contentTop() - fileHeaderHeight - 1, false);
},
methods: {
...mapActions('diffs', [
diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
index 7ede7a4f430..be19d8520b5 100644
--- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
+++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
@@ -1,9 +1,9 @@
<script>
+import { GlTooltipDirective } from '@gitlab/ui';
import { n__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import { truncate } from '~/lib/utils/text_utility';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
-import { GlTooltipDirective } from '@gitlab/ui';
import { COUNT_OF_AVATARS_IN_GUTTER, LENGTH_OF_AVATAR_TOOLTIP } from '../constants';
export default {
diff --git a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
index 434d554d148..34aa15856d2 100644
--- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
@@ -72,7 +72,7 @@ export default {
lineCode() {
return (
this.line.line_code ||
- (this.line.left && this.line.line.left.line_code) ||
+ (this.line.left && this.line.left.line_code) ||
(this.line.right && this.line.right.line_code)
);
},
diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
index da0cdbe467b..f81f50f8490 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -1,7 +1,7 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
-import { s__ } from '~/locale';
import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form';
+import { s__ } from '~/locale';
import noteForm from '../../notes/components/note_form.vue';
import autosave from '../../notes/mixins/autosave';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index d84e1af11f3..7521f3c950a 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -57,3 +57,4 @@ export const MIN_RENDERING_MS = 2;
export const START_RENDERING_INDEX = 200;
export const INLINE_DIFF_LINES_KEY = 'highlighted_diff_lines';
export const PARALLEL_DIFF_LINES_KEY = 'parallel_diff_lines';
+export const DIFFS_PER_PAGE = 20;
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index c9580e3d3b4..375ac80021f 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -67,6 +67,8 @@ export default function initDiffsApp(store) {
return {
endpoint: dataset.endpoint,
+ endpointMetadata: dataset.endpointMetadata || '',
+ endpointBatch: dataset.endpointBatch || '',
projectPath: dataset.projectPath,
helpPagePath: dataset.helpPagePath,
currentUser: JSON.parse(dataset.currentUserData) || {},
@@ -100,6 +102,8 @@ export default function initDiffsApp(store) {
return createElement('diffs-app', {
props: {
endpoint: this.endpoint,
+ endpointMetadata: this.endpointMetadata,
+ endpointBatch: this.endpointBatch,
currentUser: this.currentUser,
projectPath: this.projectPath,
helpPagePath: this.helpPagePath,
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 6695d9fe96c..992b45c97ac 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
-import axios from '~/lib/utils/axios_utils';
import Cookies from 'js-cookie';
+import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils';
@@ -13,6 +13,7 @@ import {
convertExpandLines,
idleCallback,
allDiscussionWrappersExpanded,
+ prepareDiffData,
} from './utils';
import * as types from './mutation_types';
import {
@@ -33,16 +34,36 @@ import {
START_RENDERING_INDEX,
INLINE_DIFF_LINES_KEY,
PARALLEL_DIFF_LINES_KEY,
+ DIFFS_PER_PAGE,
} from '../constants';
import { diffViewerModes } from '~/ide/constants';
export const setBaseConfig = ({ commit }, options) => {
- const { endpoint, projectPath, dismissEndpoint, showSuggestPopover } = options;
- commit(types.SET_BASE_CONFIG, { endpoint, projectPath, dismissEndpoint, showSuggestPopover });
+ const {
+ endpoint,
+ endpointMetadata,
+ endpointBatch,
+ projectPath,
+ dismissEndpoint,
+ showSuggestPopover,
+ useSingleDiffStyle,
+ } = options;
+ commit(types.SET_BASE_CONFIG, {
+ endpoint,
+ endpointMetadata,
+ endpointBatch,
+ projectPath,
+ dismissEndpoint,
+ showSuggestPopover,
+ useSingleDiffStyle,
+ });
};
export const fetchDiffFiles = ({ state, commit }) => {
const worker = new TreeWorker();
+ const urlParams = {
+ w: state.showWhitespace ? '0' : '1',
+ };
commit(types.SET_LOADING, true);
@@ -53,9 +74,10 @@ export const fetchDiffFiles = ({ state, commit }) => {
});
return axios
- .get(mergeUrlParams({ w: state.showWhitespace ? '0' : '1' }, state.endpoint))
+ .get(mergeUrlParams(urlParams, state.endpoint))
.then(res => {
commit(types.SET_LOADING, false);
+
commit(types.SET_MERGE_REQUEST_DIFFS, res.data.merge_request_diffs || []);
commit(types.SET_DIFF_DATA, res.data);
@@ -67,6 +89,52 @@ export const fetchDiffFiles = ({ state, commit }) => {
.catch(() => worker.terminate());
};
+export const fetchDiffFilesBatch = ({ commit, state }) => {
+ commit(types.SET_BATCH_LOADING, true);
+
+ const getBatch = page =>
+ axios
+ .get(state.endpointBatch, {
+ params: { page, per_page: DIFFS_PER_PAGE, w: state.showWhitespace ? '0' : '1' },
+ })
+ .then(({ data: { pagination, diff_files } }) => {
+ commit(types.SET_DIFF_DATA_BATCH, { diff_files });
+ commit(types.SET_BATCH_LOADING, false);
+ return pagination.next_page;
+ })
+ .then(nextPage => nextPage && getBatch(nextPage));
+
+ return getBatch()
+ .then(handleLocationHash)
+ .catch(() => null);
+};
+
+export const fetchDiffFilesMeta = ({ commit, state }) => {
+ const worker = new TreeWorker();
+
+ commit(types.SET_LOADING, true);
+
+ worker.addEventListener('message', ({ data }) => {
+ commit(types.SET_TREE_DATA, data);
+
+ worker.terminate();
+ });
+
+ return axios
+ .get(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);
+ })
+ .catch(() => worker.terminate());
+};
+
export const setHighlightedRow = ({ commit }, lineCode) => {
const fileHash = lineCode.split('_')[0];
commit(types.SET_HIGHLIGHTED_ROW, lineCode);
diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js
index 6821c8445ea..7366c50752c 100644
--- a/app/assets/javascripts/diffs/store/modules/diff_state.js
+++ b/app/assets/javascripts/diffs/store/modules/diff_state.js
@@ -8,6 +8,7 @@ const defaultViewType = INLINE_DIFF_VIEW_TYPE;
export default () => ({
isLoading: true,
+ isBatchLoading: false,
addedLines: null,
removedLines: null,
endpoint: '',
@@ -30,4 +31,5 @@ export default () => ({
fileFinderVisible: false,
dismissEndpoint: '',
showSuggestPopover: true,
+ useSingleDiffStyle: false,
});
diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js
index 9db56331faa..5a90d78b2bc 100644
--- a/app/assets/javascripts/diffs/store/mutation_types.js
+++ b/app/assets/javascripts/diffs/store/mutation_types.js
@@ -1,6 +1,8 @@
export const SET_BASE_CONFIG = 'SET_BASE_CONFIG';
export const SET_LOADING = 'SET_LOADING';
+export const SET_BATCH_LOADING = 'SET_BATCH_LOADING';
export const SET_DIFF_DATA = 'SET_DIFF_DATA';
+export const SET_DIFF_DATA_BATCH = 'SET_DIFF_DATA_BATCH';
export const SET_DIFF_VIEW_TYPE = 'SET_DIFF_VIEW_TYPE';
export const SET_MERGE_REQUEST_DIFFS = 'SET_MERGE_REQUEST_DIFFS';
export const TOGGLE_LINE_HAS_FORM = 'TOGGLE_LINE_HAS_FORM';
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index a6915a46c00..859f43b3b6d 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -12,22 +12,57 @@ import * as types from './mutation_types';
export default {
[types.SET_BASE_CONFIG](state, options) {
- const { endpoint, projectPath, dismissEndpoint, showSuggestPopover } = options;
- Object.assign(state, { endpoint, projectPath, dismissEndpoint, showSuggestPopover });
+ const {
+ endpoint,
+ endpointMetadata,
+ endpointBatch,
+ projectPath,
+ dismissEndpoint,
+ showSuggestPopover,
+ useSingleDiffStyle,
+ } = options;
+ Object.assign(state, {
+ endpoint,
+ endpointMetadata,
+ endpointBatch,
+ projectPath,
+ dismissEndpoint,
+ showSuggestPopover,
+ useSingleDiffStyle,
+ });
},
[types.SET_LOADING](state, isLoading) {
Object.assign(state, { isLoading });
},
+ [types.SET_BATCH_LOADING](state, isBatchLoading) {
+ Object.assign(state, { isBatchLoading });
+ },
+
[types.SET_DIFF_DATA](state, data) {
- prepareDiffData(data);
+ if (
+ !(
+ gon &&
+ gon.features &&
+ gon.features.diffsBatchLoad &&
+ window.location.search.indexOf('diff_id') === -1
+ )
+ ) {
+ prepareDiffData(data);
+ }
Object.assign(state, {
...convertObjectPropsToCamelCase(data),
});
},
+ [types.SET_DIFF_DATA_BATCH](state, data) {
+ prepareDiffData(data);
+
+ state.diffFiles.push(...data.diff_files);
+ },
+
[types.RENDER_FILE](state, file) {
Object.assign(file, {
renderIt: true,
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index d46bdea9b50..281a0de1fc2 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -252,10 +252,11 @@ export function prepareDiffData(diffData) {
showingLines += file.parallel_diff_lines.length;
}
+ const name = (file.viewer && file.viewer.name) || diffViewerModes.text;
+
Object.assign(file, {
renderIt: showingLines < LINES_TO_BE_RENDERED_DIRECTLY,
- collapsed:
- file.viewer.name === diffViewerModes.text && showingLines > MAX_LINES_TO_BE_RENDERED,
+ collapsed: name === diffViewerModes.text && showingLines > MAX_LINES_TO_BE_RENDERED,
isShowingFullFile: false,
isLoadingFullFile: false,
discussions: [],
@@ -497,7 +498,7 @@ export const allDiscussionWrappersExpanded = diff => {
}
});
} else if (diff.highlighted_diff_lines) {
- diff.parallel_diff_lines.forEach(line => {
+ diff.highlighted_diff_lines.forEach(line => {
if (line.discussions.length) {
discussionsExpandedArray.push(line.discussionsExpanded);
}
diff --git a/app/assets/javascripts/emoji/no_emoji_validator.js b/app/assets/javascripts/emoji/no_emoji_validator.js
index 384d62a133a..edef868619a 100644
--- a/app/assets/javascripts/emoji/no_emoji_validator.js
+++ b/app/assets/javascripts/emoji/no_emoji_validator.js
@@ -1,5 +1,5 @@
-import { __ } from '~/locale';
import emojiRegex from 'emoji-regex';
+import { __ } from '~/locale';
import InputValidator from '../validators/input_validator';
export default class NoEmojiValidator extends InputValidator {
diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue
index 426bb63d4f7..cdf62259479 100644
--- a/app/assets/javascripts/environments/components/container.vue
+++ b/app/assets/javascripts/environments/components/container.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
-import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import containerMixin from 'ee_else_ce/environments/mixins/container_mixin';
+import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import EnvironmentTable from '../components/environments_table.vue';
export default {
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index 22bba21526c..d2978422224 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -1,10 +1,10 @@
<script>
+import { GlLoadingIcon } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
import { formatTime } from '~/lib/utils/datetime_utility';
import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '../event_hub';
import tooltip from '../../vue_shared/directives/tooltip';
-import { GlLoadingIcon } from '@gitlab/ui';
export default {
directives: {
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index c94039326aa..428dfe5fcf7 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -1,12 +1,13 @@
<script>
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
-import { __, sprintf } from '~/locale';
-import Timeago from 'timeago.js';
+import { format } from 'timeago.js';
import _ from 'underscore';
import { GlTooltipDirective } from '@gitlab/ui';
+import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import Icon from '~/vue_shared/components/icon.vue';
-import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import { __, sprintf } from '~/locale';
import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue';
import StopComponent from './environment_stop.vue';
@@ -22,11 +23,9 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
*
* Renders a table row for each environment.
*/
-const timeagoInstance = new Timeago();
export default {
components: {
- UserAvatarLink,
CommitComponent,
Icon,
ActionsComponent,
@@ -35,6 +34,8 @@ export default {
RollbackComponent,
TerminalButtonComponent,
MonitoringButtonComponent,
+ TooltipOnTruncate,
+ UserAvatarLink,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -42,16 +43,21 @@ export default {
mixins: [environmentItemMixin],
props: {
+ canReadEnvironment: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+
model: {
type: Object,
required: true,
default: () => ({}),
},
- canReadEnvironment: {
- type: Boolean,
- required: false,
- default: false,
+ tableData: {
+ type: Object,
+ required: true,
},
},
@@ -121,7 +127,7 @@ export default {
*/
deployedDate() {
if (this.canShowDate) {
- return timeagoInstance.format(this.model.last_deployment.deployed_at);
+ return format(this.model.last_deployment.deployed_at);
}
return '';
},
@@ -446,9 +452,13 @@ export default {
class="gl-responsive-table-row"
role="row"
>
- <div class="table-section section-wrap section-15 text-truncate" role="gridcell">
+ <div
+ class="table-section section-wrap text-truncate"
+ :class="tableData.name.spacing"
+ role="gridcell"
+ >
<div v-if="!model.isFolder" class="table-mobile-header" role="rowheader">
- {{ s__('Environments|Environment') }}
+ {{ tableData.name.title }}
</div>
<span v-if="shouldRenderDeployBoard" class="deploy-board-icon" @click="toggleDeployBoard">
@@ -488,7 +498,8 @@ export default {
</div>
<div
- class="table-section section-10 deployment-column d-none d-sm-none d-md-block"
+ class="table-section deployment-column d-none d-sm-none d-md-block"
+ :class="tableData.deploy.spacing"
role="gridcell"
>
<span v-if="shouldRenderDeploymentID" class="text-break-word">
@@ -507,18 +518,32 @@ export default {
</span>
</div>
- <div class="table-section section-15 d-none d-sm-none d-md-block" role="gridcell">
- <a
- v-if="shouldRenderBuildName"
- :href="buildPath"
- class="build-link cgray flex-truncate-parent"
- >
- <span class="flex-truncate-child">{{ buildName }}</span>
+ <div
+ class="table-section d-none d-sm-none d-md-block"
+ :class="tableData.build.spacing"
+ role="gridcell"
+ >
+ <a v-if="shouldRenderBuildName" :href="buildPath" class="build-link cgray">
+ <tooltip-on-truncate
+ :title="buildName"
+ truncate-target="child"
+ class="flex-truncate-parent"
+ >
+ <span class="flex-truncate-child">
+ {{ buildName }}
+ </span>
+ </tooltip-on-truncate>
</a>
</div>
- <div v-if="!model.isFolder" class="table-section section-20" role="gridcell">
- <div role="rowheader" class="table-mobile-header">{{ s__('Environments|Commit') }}</div>
+ <div
+ v-if="!model.isFolder"
+ class="table-section"
+ :class="tableData.commit.spacing"
+ role="gridcell"
+ >
+ <div role="rowheader" class="table-mobile-header">{{ tableData.commit.title }}</div>
+
<div v-if="hasLastDeploymentKey" class="js-commit-component table-mobile-content">
<commit-component
:tag="commitTag"
@@ -534,8 +559,14 @@ export default {
</div>
</div>
- <div v-if="!model.isFolder" class="table-section section-10" role="gridcell">
- <div role="rowheader" class="table-mobile-header">{{ s__('Environments|Updated') }}</div>
+ <div
+ v-if="!model.isFolder"
+ class="table-section"
+ :class="tableData.date.spacing"
+ role="gridcell"
+ >
+ <div role="rowheader" class="table-mobile-header">{{ tableData.date.title }}</div>
+
<span v-if="canShowDate" class="environment-created-date-timeago table-mobile-content">
{{ deployedDate }}
</span>
@@ -543,7 +574,8 @@ export default {
<div
v-if="!model.isFolder && displayEnvironmentActions"
- class="table-section section-30 table-button-footer"
+ class="table-section table-button-footer"
+ :class="tableData.actions.spacing"
role="gridcell"
>
<div class="btn-group table-action-buttons" role="group">
diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue
index 886490847ea..7b4b633dc7f 100644
--- a/app/assets/javascripts/environments/components/environment_monitoring.vue
+++ b/app/assets/javascripts/environments/components/environment_monitoring.vue
@@ -1,9 +1,9 @@
<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
/**
* Renders the Monitoring (Metrics) link in environments table.
*/
-import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
export default {
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index 81927d18f8b..50c667e6966 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -31,10 +31,6 @@ export default {
type: Boolean,
required: true,
},
- cssContainerClass: {
- type: String,
- required: true,
- },
newEnvironmentPath: {
type: String,
required: true,
@@ -93,7 +89,7 @@ export default {
};
</script>
<template>
- <div :class="cssContainerClass">
+ <div>
<stop-environment-modal :environment="environmentInStopModal" />
<confirm-rollback-modal :environment="environmentInRollbackModal" />
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index 4464f5e5578..453e7610e21 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -5,6 +5,7 @@
import { GlLoadingIcon } from '@gitlab/ui';
import _ from 'underscore';
import environmentTableMixin from 'ee_else_ce/environments/mixins/environments_table_mixin';
+import { s__ } from '~/locale';
import EnvironmentItem from './environment_item.vue';
export default {
@@ -41,6 +42,34 @@ export default {
: env,
);
},
+ tableData() {
+ return {
+ // percent spacing for cols, should add up to 100
+ name: {
+ title: s__('Environments|Environment'),
+ spacing: 'section-15',
+ },
+ deploy: {
+ title: s__('Environments|Deployment'),
+ spacing: 'section-10',
+ },
+ build: {
+ title: s__('Environments|Job'),
+ spacing: 'section-15',
+ },
+ commit: {
+ title: s__('Environments|Commit'),
+ spacing: 'section-20',
+ },
+ date: {
+ title: s__('Environments|Updated'),
+ spacing: 'section-10',
+ },
+ actions: {
+ spacing: 'section-30',
+ },
+ };
+ },
},
methods: {
folderUrl(model) {
@@ -79,20 +108,20 @@ export default {
<template>
<div class="ci-table" role="grid">
<div class="gl-responsive-table-row table-row-header" role="row">
- <div class="table-section section-15 environments-name" role="columnheader">
- {{ s__('Environments|Environment') }}
+ <div class="table-section" :class="tableData.name.spacing" role="columnheader">
+ {{ tableData.name.title }}
</div>
- <div class="table-section section-10 environments-deploy" role="columnheader">
- {{ s__('Environments|Deployment') }}
+ <div class="table-section" :class="tableData.deploy.spacing" role="columnheader">
+ {{ tableData.deploy.title }}
</div>
- <div class="table-section section-15 environments-build" role="columnheader">
- {{ s__('Environments|Job') }}
+ <div class="table-section" :class="tableData.build.spacing" role="columnheader">
+ {{ tableData.build.title }}
</div>
- <div class="table-section section-20 environments-commit" role="columnheader">
- {{ s__('Environments|Commit') }}
+ <div class="table-section" :class="tableData.commit.spacing" role="columnheader">
+ {{ tableData.commit.title }}
</div>
- <div class="table-section section-10 environments-date" role="columnheader">
- {{ s__('Environments|Updated') }}
+ <div class="table-section" :class="tableData.date.spacing" role="columnheader">
+ {{ tableData.date.title }}
</div>
</div>
<template v-for="(model, i) in sortedEnvironments" :model="model">
@@ -101,6 +130,7 @@ export default {
:key="`environment-item-${i}`"
:model="model"
:can-read-environment="canReadEnvironment"
+ :table-data="tableData"
/>
<div
@@ -115,7 +145,8 @@ export default {
:is-loading="model.isLoadingDeployBoard"
:is-empty="model.isEmptyDeployBoard"
:has-legacy-app-label="model.hasLegacyAppLabel"
- :logs-path="model.logs_path"
+ :project-path="model.project_path"
+ :environment-name="model.name"
/>
</div>
</div>
@@ -132,6 +163,7 @@ export default {
:key="`env-item-${i}-${index}`"
:model="children"
:can-read-environment="canReadEnvironment"
+ :table-data="tableData"
/>
<div :key="`sub-div-${i}`">
diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue
index 6fd0561f682..d60c2efd618 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_view.vue
+++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue
@@ -40,13 +40,13 @@ export default {
<div :class="cssContainerClass">
<stop-environment-modal :environment="environmentInStopModal" />
- <div v-if="!isLoading" class="top-area">
- <h4 class="js-folder-name environments-folder-name">
- {{ s__('Environments|Environments') }} /
- <b>{{ folderName }}</b>
- </h4>
+ <h4 class="js-folder-name environments-folder-name">
+ {{ s__('Environments|Environments') }} /
+ <b>{{ folderName }}</b>
+ </h4>
- <tabs :tabs="tabs" scope="environments" @onChangeTab="onChangeTab" />
+ <div class="top-area">
+ <tabs v-if="!isLoading" :tabs="tabs" scope="environments" @onChangeTab="onChangeTab" />
</div>
<container
diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js
index dcdaf8731f8..9a68619d4f7 100644
--- a/app/assets/javascripts/environments/index.js
+++ b/app/assets/javascripts/environments/index.js
@@ -21,7 +21,6 @@ export default () =>
newEnvironmentPath: environmentsData.newEnvironmentPath,
helpPagePath: environmentsData.helpPagePath,
deployBoardsHelpPath: environmentsData.deployBoardsHelpPath,
- cssContainerClass: environmentsData.cssClass,
canCreateEnvironment: parseBoolean(environmentsData.canCreateEnvironment),
canReadEnvironment: parseBoolean(environmentsData.canReadEnvironment),
};
@@ -33,7 +32,6 @@ export default () =>
newEnvironmentPath: this.newEnvironmentPath,
helpPagePath: this.helpPagePath,
deployBoardsHelpPath: this.deployBoardsHelpPath,
- cssContainerClass: this.cssContainerClass,
canCreateEnvironment: this.canCreateEnvironment,
canReadEnvironment: this.canReadEnvironment,
...this.canaryCalloutProps,
diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js
index 5fb420e9da5..81c257acd53 100644
--- a/app/assets/javascripts/environments/stores/environments_store.js
+++ b/app/assets/javascripts/environments/stores/environments_store.js
@@ -1,5 +1,5 @@
-import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { setDeployBoard } from 'ee_else_ce/environments/stores/helpers';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
/**
* Environments Store.
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue
index 37c9818f869..14b2e59009a 100644
--- a/app/assets/javascripts/error_tracking/components/error_details.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details.vue
@@ -1,8 +1,9 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import dateFormat from 'dateformat';
-import { __, sprintf } from '~/locale';
-import { GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui';
+import { GlFormInput, GlLink, GlLoadingIcon } 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';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import Stacktrace from './stacktrace.vue';
@@ -12,7 +13,8 @@ import { trackClickErrorLinkToSentryOptions } from '../utils';
export default {
components: {
- GlButton,
+ LoadingButton,
+ GlFormInput,
GlLink,
GlLoadingIcon,
TooltipOnTruncate,
@@ -32,6 +34,19 @@ export default {
type: String,
required: true,
},
+ projectIssuesPath: {
+ type: String,
+ required: true,
+ },
+ csrfToken: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ issueCreationInProgress: false,
+ };
},
computed: {
...mapState('details', ['error', 'loading', 'loadingStacktrace', 'stacktraceData']),
@@ -41,7 +56,7 @@ export default {
__('Reported %{timeAgo} by %{reportedBy}'),
{
reportedBy: `<strong>${this.error.culprit}</strong>`,
- timeAgo: this.timeFormated(this.stacktraceData.date_received),
+ timeAgo: this.timeFormatted(this.stacktraceData.date_received),
},
false,
);
@@ -58,6 +73,27 @@ export default {
showStacktrace() {
return Boolean(!this.loadingStacktrace && this.stacktrace && this.stacktrace.length);
},
+ issueTitle() {
+ return this.error.title;
+ },
+ issueDescription() {
+ return sprintf(
+ __(
+ '%{description}- Sentry event: %{errorUrl}- First seen: %{firstSeen}- Last seen: %{lastSeen} %{countLabel}: %{count}%{userCountLabel}: %{userCount}',
+ ),
+ {
+ description: '# Error Details:\n',
+ errorUrl: `${this.error.external_url}\n`,
+ firstSeen: `\n${this.error.first_seen}\n`,
+ lastSeen: `${this.error.last_seen}\n`,
+ countLabel: n__('- Event', '- Events', this.error.count),
+ count: `${this.error.count}\n`,
+ userCountLabel: n__('- User', '- Users', this.error.user_count),
+ userCount: `${this.error.user_count}\n`,
+ },
+ false,
+ );
+ },
},
mounted() {
this.startPollingDetails(this.issueDetailsPath);
@@ -66,8 +102,12 @@ export default {
methods: {
...mapActions('details', ['startPollingDetails', 'startPollingStacktrace']),
trackClickErrorLinkToSentryOptions,
+ createIssue() {
+ this.issueCreationInProgress = true;
+ this.$refs.sentryIssueForm.submit();
+ },
formatDate(date) {
- return `${this.timeFormated(date)} (${dateFormat(date, 'UTC:yyyy-mm-dd h:MM:ssTT Z')})`;
+ return `${this.timeFormatted(date)} (${dateFormat(date, 'UTC:yyyy-mm-dd h:MM:ssTT Z')})`;
},
},
};
@@ -78,13 +118,27 @@ export default {
<div v-if="loading" class="py-3">
<gl-loading-icon :size="3" />
</div>
-
<div v-else-if="showDetails" class="error-details">
<div class="top-area align-items-center justify-content-between py-3">
<span v-if="!loadingStacktrace && stacktrace" v-html="reported"></span>
- <!-- <gl-button class="my-3 ml-auto" variant="success">
- {{ __('Create Issue') }}
- </gl-button>-->
+ <form ref="sentryIssueForm" :action="projectIssuesPath" method="POST">
+ <gl-form-input class="hidden" name="issue[title]" :value="issueTitle" />
+ <input name="issue[description]" :value="issueDescription" type="hidden" />
+ <gl-form-input
+ :value="error.id"
+ 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"
+ class="btn-success"
+ :label="__('Create issue')"
+ :loading="issueCreationInProgress"
+ data-qa-selector="create_issue_button"
+ @click="createIssue"
+ />
+ </form>
</div>
<div>
<tooltip-on-truncate :title="error.title" truncate-target="child" placement="top">
@@ -92,6 +146,12 @@ export default {
</tooltip-on-truncate>
<h3>{{ __('Error details') }}</h3>
<ul>
+ <li v-if="error.gitlab_issue">
+ <span class="bold">{{ __('GitLab Issue') }}:</span>
+ <gl-link :href="error.gitlab_issue">
+ <span>{{ error.gitlab_issue }}</span>
+ </gl-link>
+ </li>
<li>
<span class="bold">{{ __('Sentry event') }}:</span>
<gl-link
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 88139ce7403..8e2128ac713 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -1,39 +1,57 @@
<script>
-import { mapActions, mapState, mapGetters } from 'vuex';
+import { mapActions, mapState } from 'vuex';
import {
GlEmptyState,
GlButton,
+ GlIcon,
GlLink,
GlLoadingIcon,
GlTable,
- GlSearchBoxByType,
+ GlFormInput,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlTooltipDirective,
+ GlPagination,
} from '@gitlab/ui';
-import { visitUrl } from '~/lib/utils/url_utility';
+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 TrackEventDirective from '~/vue_shared/directives/track_event';
-import { trackViewInSentryOptions } from '../utils';
+import _ from 'underscore';
export default {
+ FIRST_PAGE: 1,
+ PREV_PAGE: 1,
+ NEXT_PAGE: 2,
fields: [
{ key: 'error', label: __('Open errors'), thClass: 'w-70p' },
{ key: 'events', label: __('Events') },
{ key: 'users', label: __('Users') },
{ key: 'lastSeen', label: __('Last seen'), thClass: 'w-15p' },
],
+ sortFields: {
+ last_seen: __('Last Seen'),
+ first_seen: __('First Seen'),
+ frequency: __('Frequency'),
+ },
components: {
GlEmptyState,
GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlIcon,
GlLink,
GlLoadingIcon,
GlTable,
- GlSearchBoxByType,
+ GlFormInput,
Icon,
+ GlPagination,
TimeAgo,
},
directives: {
- TrackEvent: TrackEventDirective,
+ GlTooltip: GlTooltipDirective,
},
props: {
indexPath: {
@@ -57,112 +75,214 @@ export default {
required: true,
},
},
+ hasLocalStorage: AccessorUtils.isLocalStorageAccessSafe(),
data() {
return {
errorSearchQuery: '',
+ pageValue: this.$options.FIRST_PAGE,
};
},
computed: {
- ...mapState('list', ['errors', 'externalUrl', 'loading']),
- ...mapGetters('list', ['filterErrorsByTitle']),
- filteredErrors() {
- return this.errorSearchQuery ? this.filterErrorsByTitle(this.errorSearchQuery) : this.errors;
+ ...mapState('list', [
+ 'errors',
+ 'loading',
+ 'searchQuery',
+ 'sortField',
+ 'recentSearches',
+ 'pagination',
+ ]),
+ paginationRequired() {
+ return !_.isEmpty(this.pagination);
+ },
+ },
+ watch: {
+ pagination() {
+ if (typeof this.pagination.previous === 'undefined') {
+ this.pageValue = this.$options.FIRST_PAGE;
+ }
},
},
created() {
if (this.errorTrackingEnabled) {
- this.startPolling(this.indexPath);
+ this.setEndpoint(this.indexPath);
+ this.startPolling();
}
},
methods: {
- ...mapActions('list', ['startPolling', 'restartPolling']),
- trackViewInSentryOptions,
- viewDetails(errorId) {
- visitUrl(`error_tracking/${errorId}/details`);
+ ...mapActions('list', [
+ 'startPolling',
+ 'restartPolling',
+ 'setEndpoint',
+ 'searchByQuery',
+ 'sortByField',
+ 'addRecentSearch',
+ 'clearRecentSearches',
+ 'loadRecentSearches',
+ 'setIndexPath',
+ ]),
+ setSearchText(text) {
+ this.errorSearchQuery = text;
+ this.searchByQuery(text);
+ },
+ getDetailsLink(errorId) {
+ return `error_tracking/${errorId}/details`;
+ },
+ goToNextPage() {
+ this.pageValue = this.$options.NEXT_PAGE;
+ this.startPolling(`${this.indexPath}?cursor=${this.pagination.next.cursor}`);
+ },
+ goToPrevPage() {
+ this.startPolling(`${this.indexPath}?cursor=${this.pagination.previous.cursor}`);
+ },
+ goToPage(page) {
+ window.scrollTo(0, 0);
+ return page === this.$options.PREV_PAGE ? this.goToPrevPage() : this.goToNextPage();
+ },
+ isCurrentSortField(field) {
+ return field === this.sortField;
},
},
};
</script>
<template>
- <div>
+ <div class="error-list">
<div v-if="errorTrackingEnabled">
- <div v-if="loading" class="py-3">
- <gl-loading-icon :size="3" />
- </div>
- <div v-else>
- <div class="d-flex flex-row justify-content-around bg-secondary border">
- <gl-search-box-by-type
- v-model="errorSearchQuery"
- class="col-lg-10 m-3 p-0"
- :placeholder="__('Search or filter results...')"
- type="search"
- autofocus
- />
- <gl-button
- v-track-event="trackViewInSentryOptions(externalUrl)"
- class="m-3"
- variant="primary"
- :href="externalUrl"
- target="_blank"
+ <div
+ class="d-flex flex-row justify-content-around align-items-center bg-secondary border mt-2"
+ >
+ <div class="filtered-search-box flex-grow-1 my-3 ml-3 mr-2">
+ <gl-dropdown
+ :text="__('Recent searches')"
+ class="filtered-search-history-dropdown-wrapper d-none d-md-block"
+ toggle-class="filtered-search-history-dropdown-toggle-button"
+ :disabled="loading"
>
- {{ __('View in Sentry') }}
- <icon name="external-link" class="flex-shrink-0" />
- </gl-button>
+ <div v-if="!$options.hasLocalStorage" class="px-3">
+ {{ __('This feature requires local storage to be enabled') }}
+ </div>
+ <template v-else-if="recentSearches.length > 0">
+ <gl-dropdown-item
+ v-for="searchQuery in recentSearches"
+ :key="searchQuery"
+ @click="setSearchText(searchQuery)"
+ >{{ searchQuery }}</gl-dropdown-item
+ >
+ <gl-dropdown-divider />
+ <gl-dropdown-item ref="clearRecentSearches" @click="clearRecentSearches">{{
+ __('Clear recent searches')
+ }}</gl-dropdown-item>
+ </template>
+ <div v-else class="px-3">{{ __("You don't have any recent searches") }}</div>
+ </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…')"
+ autofocus
+ @keyup.enter.native="searchByQuery(errorSearchQuery)"
+ />
+ </div>
+ <div class="gl-search-box-by-type-right-icons">
+ <gl-button
+ v-if="errorSearchQuery.length > 0"
+ v-gl-tooltip.hover
+ :title="__('Clear')"
+ class="clear-search text-secondary"
+ name="clear"
+ @click="errorSearchQuery = ''"
+ >
+ <gl-icon name="close" :size="12" />
+ </gl-button>
+ </div>
</div>
- <gl-table
- class="mt-3"
- :items="filteredErrors"
- :fields="$options.fields"
- :show-empty="true"
- fixed
- stacked="sm"
+ <gl-dropdown
+ :text="$options.sortFields[sortField]"
+ left
+ :disabled="loading"
+ class="mr-3"
+ menu-class="sort-dropdown"
>
- <template slot="HEAD_events" slot-scope="data">
- <div class="text-md-right">{{ data.label }}</div>
- </template>
- <template slot="HEAD_users" slot-scope="data">
- <div class="text-md-right">{{ data.label }}</div>
- </template>
- <template slot="error" slot-scope="errors">
- <div class="d-flex flex-column">
- <gl-link
- class="d-flex text-dark"
- target="_blank"
- @click="viewDetails(errors.item.id)"
- >
- <strong class="text-truncate">{{ errors.item.title.trim() }}</strong>
- </gl-link>
- <span class="text-secondary text-truncate">
- {{ errors.item.culprit }}
- </span>
- </div>
- </template>
+ <gl-dropdown-item
+ v-for="(label, field) in $options.sortFields"
+ :key="field"
+ @click="sortByField(field)"
+ >
+ <span class="d-flex">
+ <icon
+ class="flex-shrink-0 append-right-4"
+ :class="{ invisible: !isCurrentSortField(field) }"
+ name="mobile-issue-close"
+ />
+ {{ label }}
+ </span>
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
- <template slot="events" slot-scope="errors">
- <div class="text-md-right">{{ errors.item.count }}</div>
- </template>
+ <div v-if="loading" class="py-3">
+ <gl-loading-icon size="md" />
+ </div>
- <template slot="users" slot-scope="errors">
- <div class="text-md-right">{{ errors.item.userCount }}</div>
- </template>
+ <gl-table
+ v-else
+ class="mt-3"
+ :items="errors"
+ :fields="$options.fields"
+ :show-empty="true"
+ fixed
+ stacked="sm"
+ >
+ <template slot="HEAD_events" slot-scope="data">
+ <div class="text-md-right">{{ data.label }}</div>
+ </template>
+ <template slot="HEAD_users" slot-scope="data">
+ <div class="text-md-right">{{ data.label }}</div>
+ </template>
+ <template slot="error" slot-scope="errors">
+ <div class="d-flex flex-column">
+ <gl-link class="d-flex text-dark" :href="getDetailsLink(errors.item.id)">
+ <strong class="text-truncate">{{ errors.item.title.trim() }}</strong>
+ </gl-link>
+ <span class="text-secondary text-truncate">
+ {{ errors.item.culprit }}
+ </span>
+ </div>
+ </template>
+ <template slot="events" slot-scope="errors">
+ <div class="text-md-right">{{ errors.item.count }}</div>
+ </template>
- <template slot="lastSeen" slot-scope="errors">
- <div class="d-flex align-items-center">
- <time-ago :time="errors.item.lastSeen" class="text-secondary" />
- </div>
- </template>
- <template slot="empty">
- <div ref="empty">
- {{ __('No errors to display.') }}
- <gl-link class="js-try-again" @click="restartPolling">
- {{ __('Check again') }}
- </gl-link>
- </div>
- </template>
- </gl-table>
- </div>
+ <template slot="users" slot-scope="errors">
+ <div class="text-md-right">{{ errors.item.userCount }}</div>
+ </template>
+
+ <template slot="lastSeen" slot-scope="errors">
+ <div class="d-flex align-items-center">
+ <time-ago :time="errors.item.lastSeen" class="text-secondary" />
+ </div>
+ </template>
+ <template slot="empty">
+ <div ref="empty">
+ {{ __('No errors to display.') }}
+ <gl-link class="js-try-again" @click="restartPolling">
+ {{ __('Check again') }}
+ </gl-link>
+ </div>
+ </template>
+ </gl-table>
+ <gl-pagination
+ v-show="!loading"
+ v-if="paginationRequired"
+ :prev-page="$options.PREV_PAGE"
+ :next-page="$options.NEXT_PAGE"
+ :value="pageValue"
+ align="center"
+ @input="goToPage"
+ />
</div>
<div v-else-if="userCanEnableErrorTracking">
<gl-empty-state
diff --git a/app/assets/javascripts/error_tracking/components/stacktrace.vue b/app/assets/javascripts/error_tracking/components/stacktrace.vue
index 6b71967624f..f58d54f2933 100644
--- a/app/assets/javascripts/error_tracking/components/stacktrace.vue
+++ b/app/assets/javascripts/error_tracking/components/stacktrace.vue
@@ -27,6 +27,8 @@ export default {
:lines="entry.context"
:file-path="entry.filename"
:error-line="entry.lineNo"
+ :error-fn="entry.function"
+ :error-column="entry.colNo"
:expanded="isFirstEntry(index)"
/>
</div>
diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
index ad542c579a9..62fd379aa4c 100644
--- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
+++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
@@ -1,5 +1,6 @@
<script>
import { GlTooltip } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import Icon from '~/vue_shared/components/icon.vue';
@@ -22,9 +23,20 @@ export default {
type: String,
required: true,
},
+ errorFn: {
+ type: String,
+ required: false,
+ default: '',
+ },
errorLine: {
type: Number,
- required: true,
+ required: false,
+ default: 0,
+ },
+ errorColumn: {
+ type: Number,
+ required: false,
+ default: 0,
},
expanded: {
type: Boolean,
@@ -38,12 +50,23 @@ export default {
};
},
computed: {
- linesLength() {
- return this.lines.length;
+ hasCode() {
+ return Boolean(this.lines.length);
},
collapseIcon() {
return this.isExpanded ? 'chevron-down' : 'chevron-right';
},
+ noCodeFn() {
+ return this.errorFn ? sprintf(__('in %{errorFn} '), { errorFn: this.errorFn }) : '';
+ },
+ noCodeLine() {
+ return this.errorLine
+ ? sprintf(__('at line %{errorLine}%{errorColumn}'), {
+ errorLine: this.errorLine,
+ errorColumn: this.errorColumn ? `:${this.errorColumn}` : '',
+ })
+ : '';
+ },
},
methods: {
isHighlighted(lineNum) {
@@ -66,27 +89,31 @@ export default {
<template>
<div class="file-holder">
<div ref="header" class="file-title file-title-flex-parent">
- <div class="file-header-content ">
- <div class="d-inline-block cursor-pointer" @click="toggle()">
+ <div class="file-header-content d-flex align-content-center">
+ <div v-if="hasCode" class="d-inline-block cursor-pointer" @click="toggle()">
<icon :name="collapseIcon" :size="16" aria-hidden="true" class="append-right-5" />
</div>
- <div class="d-inline-block append-right-4">
- <file-icon
- :file-name="filePath"
- :size="18"
- aria-hidden="true"
- css-classes="append-right-5"
- />
- <strong v-gl-tooltip :title="filePath" class="file-title-name" data-container="body">
- {{ filePath }}
- </strong>
- </div>
-
+ <file-icon
+ :file-name="filePath"
+ :size="18"
+ aria-hidden="true"
+ css-classes="append-right-5"
+ />
+ <strong
+ v-gl-tooltip
+ :title="filePath"
+ class="file-title-name d-inline-block overflow-hidden text-truncate"
+ :class="{ 'limited-width': !hasCode }"
+ data-container="body"
+ >
+ {{ filePath }}
+ </strong>
<clipboard-button
:title="__('Copy file path')"
:text="filePath"
- css-class="btn-default btn-transparent btn-clipboard"
+ css-class="btn-default btn-transparent btn-clipboard position-static"
/>
+ <span v-if="!hasCode" class="text-tertiary">{{ noCodeFn }}{{ noCodeLine }}</span>
</div>
</div>
diff --git a/app/assets/javascripts/error_tracking/details.js b/app/assets/javascripts/error_tracking/details.js
index b9b51a6539f..872cb8868a2 100644
--- a/app/assets/javascripts/error_tracking/details.js
+++ b/app/assets/javascripts/error_tracking/details.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import store from './store';
import ErrorDetails from './components/error_details.vue';
+import csrf from '~/lib/utils/csrf';
export default () => {
// eslint-disable-next-line no-new
@@ -12,12 +13,14 @@ export default () => {
store,
render(createElement) {
const domEl = document.querySelector(this.$options.el);
- const { issueDetailsPath, issueStackTracePath } = domEl.dataset;
+ const { issueDetailsPath, issueStackTracePath, projectIssuesPath } = domEl.dataset;
return createElement('error-details', {
props: {
issueDetailsPath,
issueStackTracePath,
+ projectIssuesPath,
+ csrfToken: csrf.token,
},
});
},
diff --git a/app/assets/javascripts/error_tracking/services/index.js b/app/assets/javascripts/error_tracking/services/index.js
index 68988296cc2..3b3f8311d67 100644
--- a/app/assets/javascripts/error_tracking/services/index.js
+++ b/app/assets/javascripts/error_tracking/services/index.js
@@ -1,7 +1,7 @@
import axios from '~/lib/utils/axios_utils';
export default {
- getSentryData({ endpoint }) {
- return axios.get(endpoint);
+ getSentryData({ endpoint, params }) {
+ return axios.get(endpoint, { params });
},
};
diff --git a/app/assets/javascripts/error_tracking/store/details/getters.js b/app/assets/javascripts/error_tracking/store/details/getters.js
index 7d13439d721..a36c84dc28c 100644
--- a/app/assets/javascripts/error_tracking/store/details/getters.js
+++ b/app/assets/javascripts/error_tracking/store/details/getters.js
@@ -1,3 +1,6 @@
-export const stacktrace = state => state.stacktraceData.stack_trace_entries.reverse();
+export const stacktrace = state =>
+ state.stacktraceData.stack_trace_entries
+ ? state.stacktraceData.stack_trace_entries.reverse()
+ : [];
export default () => {};
diff --git a/app/assets/javascripts/error_tracking/store/index.js b/app/assets/javascripts/error_tracking/store/index.js
index 941c752e96a..ad05eecef6c 100644
--- a/app/assets/javascripts/error_tracking/store/index.js
+++ b/app/assets/javascripts/error_tracking/store/index.js
@@ -4,7 +4,6 @@ import Vuex from 'vuex';
import * as listActions from './list/actions';
import listMutations from './list/mutations';
import listState from './list/state';
-import * as listGetters from './list/getters';
import * as detailsActions from './details/actions';
import detailsMutations from './details/mutations';
@@ -21,7 +20,6 @@ export const createStore = () =>
state: listState(),
actions: listActions,
mutations: listMutations,
- getters: listGetters,
},
details: {
namespaced: true,
diff --git a/app/assets/javascripts/error_tracking/store/list/actions.js b/app/assets/javascripts/error_tracking/store/list/actions.js
index 18c6e5e9695..c9e882c4ed2 100644
--- a/app/assets/javascripts/error_tracking/store/list/actions.js
+++ b/app/assets/javascripts/error_tracking/store/list/actions.js
@@ -6,17 +6,25 @@ import { __, sprintf } from '~/locale';
let eTagPoll;
-export function startPolling({ commit, dispatch }, endpoint) {
+export function startPolling({ state, commit, dispatch }) {
+ commit(types.SET_LOADING, true);
+
eTagPoll = new Poll({
resource: Service,
method: 'getSentryData',
- data: { endpoint },
+ data: {
+ endpoint: state.endpoint,
+ params: {
+ search_term: state.searchQuery,
+ sort: state.sortField,
+ },
+ },
successCallback: ({ data }) => {
if (!data) {
return;
}
+ commit(types.SET_PAGINATION, data.pagination);
commit(types.SET_ERRORS, data.errors);
- commit(types.SET_EXTERNAL_URL, data.external_url);
commit(types.SET_LOADING, false);
dispatch('stopPolling');
},
@@ -43,10 +51,43 @@ export const stopPolling = () => {
export function restartPolling({ commit }) {
commit(types.SET_ERRORS, []);
- commit(types.SET_EXTERNAL_URL, '');
commit(types.SET_LOADING, true);
if (eTagPoll) eTagPoll.restart();
}
+export function setIndexPath({ commit }, path) {
+ commit(types.SET_INDEX_PATH, path);
+}
+
+export function loadRecentSearches({ commit }) {
+ commit(types.LOAD_RECENT_SEARCHES);
+}
+
+export function addRecentSearch({ commit }, searchQuery) {
+ commit(types.ADD_RECENT_SEARCH, searchQuery);
+}
+
+export function clearRecentSearches({ commit }) {
+ commit(types.CLEAR_RECENT_SEARCHES);
+}
+
+export const searchByQuery = ({ commit, dispatch }, query) => {
+ const searchQuery = query.trim();
+ commit(types.SET_SEARCH_QUERY, searchQuery);
+ commit(types.ADD_RECENT_SEARCH, searchQuery);
+ dispatch('stopPolling');
+ dispatch('startPolling');
+};
+
+export const sortByField = ({ commit, dispatch }, field) => {
+ commit(types.SET_SORT_FIELD, field);
+ dispatch('stopPolling');
+ dispatch('startPolling');
+};
+
+export const setEndpoint = ({ commit }, endpoint) => {
+ commit(types.SET_ENDPOINT, endpoint);
+};
+
export default () => {};
diff --git a/app/assets/javascripts/error_tracking/store/list/getters.js b/app/assets/javascripts/error_tracking/store/list/getters.js
deleted file mode 100644
index 1a2ec62f79f..00000000000
--- a/app/assets/javascripts/error_tracking/store/list/getters.js
+++ /dev/null
@@ -1,4 +0,0 @@
-export const filterErrorsByTitle = state => errorQuery =>
- state.errors.filter(error => error.title.match(new RegExp(`${errorQuery}`, 'i')));
-
-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 f9d77a6b08e..301984a1ee0 100644
--- a/app/assets/javascripts/error_tracking/store/list/mutation_types.js
+++ b/app/assets/javascripts/error_tracking/store/list/mutation_types.js
@@ -1,3 +1,10 @@
export const SET_ERRORS = 'SET_ERRORS';
-export const SET_EXTERNAL_URL = 'SET_EXTERNAL_URL';
+export const SET_INDEX_PATH = 'SET_INDEX_PATH';
export const SET_LOADING = 'SET_LOADING';
+export const ADD_RECENT_SEARCH = 'ADD_RECENT_SEARCH';
+export const CLEAR_RECENT_SEARCHES = 'CLEAR_RECENT_SEARCHES';
+export const LOAD_RECENT_SEARCHES = 'LOAD_RECENT_SEARCHES';
+export const SET_PAGINATION = 'SET_PAGINATION';
+export const SET_ENDPOINT = 'SET_ENDPOINT';
+export const SET_SORT_FIELD = 'SET_SORT_FIELD';
+export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY';
diff --git a/app/assets/javascripts/error_tracking/store/list/mutations.js b/app/assets/javascripts/error_tracking/store/list/mutations.js
index e4bd81db9c9..5648013bb89 100644
--- a/app/assets/javascripts/error_tracking/store/list/mutations.js
+++ b/app/assets/javascripts/error_tracking/store/list/mutations.js
@@ -1,14 +1,59 @@
import * as types from './mutation_types';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import AccessorUtils from '~/lib/utils/accessor';
export default {
[types.SET_ERRORS](state, data) {
state.errors = convertObjectPropsToCamelCase(data, { deep: true });
},
- [types.SET_EXTERNAL_URL](state, url) {
- state.externalUrl = url;
- },
[types.SET_LOADING](state, loading) {
state.loading = loading;
},
+ [types.SET_INDEX_PATH](state, path) {
+ state.indexPath = path;
+ },
+ [types.ADD_RECENT_SEARCH](state, searchTerm) {
+ if (searchTerm.length === 0) {
+ return;
+ }
+ // remove any existing item, then add it to the start of the list
+ const recentSearches = state.recentSearches.filter(s => s !== searchTerm);
+ recentSearches.unshift(searchTerm);
+ // only keep the last 5
+ state.recentSearches = recentSearches.slice(0, 5);
+
+ if (AccessorUtils.isLocalStorageAccessSafe()) {
+ localStorage.setItem(
+ `recent-searches${state.indexPath}`,
+ JSON.stringify(state.recentSearches),
+ );
+ }
+ },
+ [types.CLEAR_RECENT_SEARCHES](state) {
+ state.recentSearches = [];
+ if (AccessorUtils.isLocalStorageAccessSafe()) {
+ localStorage.removeItem(`recent-searches${state.indexPath}`);
+ }
+ },
+ [types.LOAD_RECENT_SEARCHES](state) {
+ const recentSearches = localStorage.getItem(`recent-searches${state.indexPath}`) || [];
+ try {
+ state.recentSearches = JSON.parse(recentSearches);
+ } catch (e) {
+ state.recentSearches = [];
+ throw e;
+ }
+ },
+ [types.SET_PAGINATION](state, pagination) {
+ state.pagination = pagination;
+ },
+ [types.SET_SORT_FIELD](state, field) {
+ state.sortField = field;
+ },
+ [types.SET_SEARCH_QUERY](state, query) {
+ state.searchQuery = query;
+ },
+ [types.SET_ENDPOINT](state, endpoint) {
+ state.endpoint = endpoint;
+ },
};
diff --git a/app/assets/javascripts/error_tracking/store/list/state.js b/app/assets/javascripts/error_tracking/store/list/state.js
index d371350ef0e..93dc1040fde 100644
--- a/app/assets/javascripts/error_tracking/store/list/state.js
+++ b/app/assets/javascripts/error_tracking/store/list/state.js
@@ -1,5 +1,10 @@
export default () => ({
errors: [],
- externalUrl: '',
loading: true,
+ endpoint: null,
+ sortField: 'last_seen',
+ searchQuery: null,
+ indexPath: '',
+ recentSearches: [],
+ pagination: {},
});
diff --git a/app/assets/javascripts/error_tracking/utils.js b/app/assets/javascripts/error_tracking/utils.js
index b832b1371b1..3c382ccd1aa 100644
--- a/app/assets/javascripts/error_tracking/utils.js
+++ b/app/assets/javascripts/error_tracking/utils.js
@@ -1,15 +1,4 @@
-/* eslint-disable @gitlab/i18n/no-non-i18n-strings */
-
-/**
- * Tracks snowplow event when user clicks View in Sentry btn
- * @param {String} externalUrl that will be send as a property for the event
- */
-export const trackViewInSentryOptions = url => ({
- category: 'Error Tracking',
- action: 'click_view_in_sentry',
- label: 'External Url',
- property: url,
-});
+/* eslint-disable @gitlab/i18n/no-non-i18n-strings, import/prefer-default-export */
/**
* Tracks snowplow event when User clicks on error link to Sentry
diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
index d86116aa315..9f77fe8cd59 100644
--- a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
@@ -32,12 +32,16 @@ export default {
placeholder="https://mysentryserver.com"
@input="updateApiHost"
/>
+ <p class="form-text text-muted">
+ {{
+ s__(
+ "ErrorTracking|If you self-host Sentry, enter the full URL of your Sentry instance. If you're using Sentry's hosted solution, enter https://sentry.io",
+ )
+ }}
+ </p>
<!-- eslint-enable @gitlab/vue-i18n/no-bare-attribute-strings -->
</div>
</div>
- <p class="form-text text-muted">
- {{ s__('ErrorTracking|Find your hostname in your Sentry account settings page') }}
- </p>
</div>
<div class="form-group" :class="{ 'gl-show-field-errors': connectError }">
<label class="label-bold" for="error-tracking-token">
diff --git a/app/assets/javascripts/filtered_search/.eslintrc.yml b/app/assets/javascripts/filtered_search/.eslintrc.yml
new file mode 100644
index 00000000000..9faca7152f6
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/.eslintrc.yml
@@ -0,0 +1,3 @@
+rules:
+ # https://gitlab.com/gitlab-org/gitlab/issues/28716
+ import/no-cycle: off
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 5c2d32f4e85..a4edc5fd52d 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -1,7 +1,7 @@
import _ from 'underscore';
+import recentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys';
import { getParameterByName, getUrlParamsArray } from '~/lib/utils/common_utils';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
-import recentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys';
import { visitUrl } from '../lib/utils/url_utility';
import Flash from '../flash';
import FilteredSearchContainer from './container';
diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js
index ea58dbd3fa9..1343ccd6468 100644
--- a/app/assets/javascripts/filtered_search/visual_token_value.js
+++ b/app/assets/javascripts/filtered_search/visual_token_value.js
@@ -1,4 +1,5 @@
import _ from 'underscore';
+import { USER_TOKEN_TYPES } from 'ee_else_ce/filtered_search/constants';
import FilteredSearchContainer from '~/filtered_search/container';
import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens';
import AjaxCache from '~/lib/utils/ajax_cache';
@@ -6,7 +7,6 @@ import DropdownUtils from '~/filtered_search/dropdown_utils';
import Flash from '~/flash';
import UsersCache from '~/lib/utils/users_cache';
import { __ } from '~/locale';
-import { USER_TOKEN_TYPES } from 'ee_else_ce/filtered_search/constants';
export default class VisualTokenValue {
constructor(tokenValue, tokenType) {
diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue
index 968e255e1fc..8cf939254c1 100644
--- a/app/assets/javascripts/frequent_items/components/app.vue
+++ b/app/assets/javascripts/frequent_items/components/app.vue
@@ -1,7 +1,7 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
-import AccessorUtilities from '~/lib/utils/accessor';
import { GlLoadingIcon } from '@gitlab/ui';
+import AccessorUtilities from '~/lib/utils/accessor';
import eventHub from '../event_hub';
import store from '../store/';
import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants';
diff --git a/app/assets/javascripts/frequent_items/store/mutations.js b/app/assets/javascripts/frequent_items/store/mutations.js
index 92ac3a2c94d..78ccef7f253 100644
--- a/app/assets/javascripts/frequent_items/store/mutations.js
+++ b/app/assets/javascripts/frequent_items/store/mutations.js
@@ -48,7 +48,7 @@ export default {
});
},
[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, results) {
- const rawItems = results.data;
+ const rawItems = results.data ? results.data : results; // Api.groups returns array, Api.projects returns object
Object.assign(state, {
items: rawItems.map(rawItem => ({
id: rawItem.id,
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index db3ad0bb4c9..e25c9d90f60 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -3,11 +3,44 @@ import 'at.js';
import _ from 'underscore';
import glRegexp from './lib/utils/regexp';
import AjaxCache from './lib/utils/ajax_cache';
+import { spriteIcon } from './lib/utils/common_utils';
function sanitize(str) {
return str.replace(/<(?:.|\n)*?>/gm, '');
}
+export function membersBeforeSave(members) {
+ return _.map(members, member => {
+ const GROUP_TYPE = 'Group';
+
+ let title = '';
+ if (member.username == null) {
+ return member;
+ }
+ title = member.name;
+ if (member.count && !member.mentionsDisabled) {
+ title += ` (${member.count})`;
+ }
+
+ const autoCompleteAvatar = member.avatar_url || member.username.charAt(0).toUpperCase();
+
+ const rectAvatarClass = member.type === GROUP_TYPE ? 'rect-avatar' : '';
+ const imgAvatar = `<img src="${member.avatar_url}" alt="${member.username}" class="avatar ${rectAvatarClass} avatar-inline center s26"/>`;
+ const txtAvatar = `<div class="avatar ${rectAvatarClass} center avatar-inline s26">${autoCompleteAvatar}</div>`;
+ const avatarIcon = member.mentionsDisabled
+ ? spriteIcon('notifications-off', 's16 vertical-align-middle prepend-left-5')
+ : '';
+
+ return {
+ username: member.username,
+ avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar,
+ title: sanitize(title),
+ search: sanitize(`${member.username} ${member.name}`),
+ icon: avatarIcon,
+ };
+ });
+}
+
export const defaultAutocompleteConfig = {
emojis: true,
members: true,
@@ -167,12 +200,13 @@ class GfmAutoComplete {
alias: 'users',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
- const { avatarTag, username, title } = value;
+ const { avatarTag, username, title, icon } = value;
if (username != null) {
tmpl = GfmAutoComplete.Members.templateFunction({
avatarTag,
username,
title,
+ icon,
});
}
return tmpl;
@@ -185,33 +219,7 @@ class GfmAutoComplete {
data: GfmAutoComplete.defaultLoadingData,
callbacks: {
...this.getDefaultCallbacks(),
- beforeSave(members) {
- return $.map(members, m => {
- let title = '';
- if (m.username == null) {
- return m;
- }
- title = m.name;
- if (m.count) {
- title += ` (${m.count})`;
- }
-
- const GROUP_TYPE = 'Group';
-
- const autoCompleteAvatar = m.avatar_url || m.username.charAt(0).toUpperCase();
-
- const rectAvatarClass = m.type === GROUP_TYPE ? 'rect-avatar' : '';
- const imgAvatar = `<img src="${m.avatar_url}" alt="${m.username}" class="avatar ${rectAvatarClass} avatar-inline center s26"/>`;
- const txtAvatar = `<div class="avatar ${rectAvatarClass} center avatar-inline s26">${autoCompleteAvatar}</div>`;
-
- return {
- username: m.username,
- avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar,
- title: sanitize(title),
- search: sanitize(`${m.username} ${m.name}`),
- };
- });
- },
+ beforeSave: membersBeforeSave,
},
});
}
@@ -624,8 +632,8 @@ GfmAutoComplete.Emoji = {
};
// Team Members
GfmAutoComplete.Members = {
- templateFunction({ avatarTag, username, title }) {
- return `<li>${avatarTag} ${username} <small>${_.escape(title)}</small></li>`;
+ templateFunction({ avatarTag, username, title, icon }) {
+ return `<li>${avatarTag} ${username} <small>${_.escape(title)}</small> ${icon}</li>`;
},
};
GfmAutoComplete.Labels = {
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index 045f77af7ea..65d05887453 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-underscore-dangle, no-var, one-var, vars-on-top, no-shadow, no-cond-assign, 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 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 */
import $ from 'jquery';
import _ from 'underscore';
@@ -8,984 +8,887 @@ import { visitUrl } from './lib/utils/url_utility';
import { isObject } from './lib/utils/type_utility';
import renderItem from './gl_dropdown/render';
-var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote, GitLabDropdownInput;
-
-GitLabDropdownInput = (function() {
- function GitLabDropdownInput(input, options) {
- var $inputContainer, $clearButton;
- var _this = this;
- this.input = input;
- this.options = options;
- this.fieldName = this.options.fieldName || 'field-name';
- $inputContainer = this.input.parent();
- $clearButton = $inputContainer.find('.js-dropdown-input-clear');
- $clearButton.on(
- 'click',
- (function(_this) {
- // Clear click
- return function(e) {
- e.preventDefault();
- e.stopPropagation();
- return _this.input
- .val('')
- .trigger('input')
- .focus();
- };
- })(this),
- );
+const BLUR_KEYCODES = [27, 40];
- this.input
- .on('keydown', e => {
- var keyCode = e.which;
- if (keyCode === 13 && !options.elIsInput) {
- e.preventDefault();
- }
- })
- .on('input', e => {
- var 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);
- });
- }
+const HAS_VALUE_CLASS = 'has-value';
- GitLabDropdownInput.prototype.onInput = function(cb) {
- this.cb = cb;
- };
-
- return GitLabDropdownInput;
-})();
-
-GitLabDropdownFilter = (function() {
- var BLUR_KEYCODES, HAS_VALUE_CLASS;
-
- BLUR_KEYCODES = [27, 40];
-
- HAS_VALUE_CLASS = 'has-value';
-
- function GitLabDropdownFilter(input, options) {
- var $clearButton, $inputContainer, ref, timeout;
- this.input = input;
- this.options = options;
- this.filterInputBlur = (ref = this.options.filterInputBlur) != null ? ref : true;
- $inputContainer = this.input.parent();
- $clearButton = $inputContainer.find('.js-dropdown-input-clear');
- $clearButton.on(
- 'click',
- (function(_this) {
- // Clear click
- return function(e) {
- e.preventDefault();
- e.stopPropagation();
- return _this.input
- .val('')
- .trigger('input')
- .focus();
- };
- })(this),
- );
- // Key events
- timeout = '';
- this.input
- .on('keydown', e => {
- var 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());
- }
- });
- }
+const LOADING_CLASS = 'is-loading';
- GitLabDropdownFilter.prototype.shouldBlur = function(keyCode) {
- return BLUR_KEYCODES.indexOf(keyCode) !== -1;
- };
+const PAGE_TWO_CLASS = 'is-page-two';
- GitLabDropdownFilter.prototype.filter = function(search_text) {
- var data, elements, group, key, results, tmp;
- if (this.options.onFilter) {
- this.options.onFilter(search_text);
- }
- 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,
+const ACTIVE_CLASS = 'is-active';
+
+const INDETERMINATE_CLASS = 'is-indeterminate';
+
+let currentIndex = -1;
+
+const NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-item';
+
+const SELECTABLE_CLASSES = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES}, .option-hidden)`;
+
+const CURSOR_SELECT_SCROLL_PADDING = 5;
+
+const FILTER_INPUT = '.dropdown-input .dropdown-input-field:not(.dropdown-no-filter)';
+
+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);
+ });
+}
+
+GitLabDropdownInput.prototype.onInput = function(cb) {
+ this.cb = cb;
+};
+
+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);
});
- } else {
- // If data is grouped therefore an [object Object]. e.g.
- // {
- // groupName1: [
- // { prop: 'foo' },
- // { prop: 'baz' }
- // ],
- // groupName2: [
- // { prop: 'abc' },
- // { prop: 'def' }
- // ]
- // }
- if (isObject(data)) {
- results = {};
- for (key in data) {
- group = data[key];
- tmp = fuzzaldrinPlus.filter(group, search_text, {
- key: this.options.keys,
- });
- if (tmp.length) {
- results[key] = tmp.map(item => item);
- }
+ }, 250));
+ } else {
+ return this.filter(this.input.val());
+ }
+ });
+}
+
+GitLabDropdownFilter.prototype.shouldBlur = function(keyCode) {
+ return BLUR_KEYCODES.indexOf(keyCode) !== -1;
+};
+
+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 {
+ // If data is grouped therefore an [object Object]. e.g.
+ // {
+ // groupName1: [
+ // { prop: 'foo' },
+ // { prop: 'baz' }
+ // ],
+ // groupName2: [
+ // { prop: 'abc' },
+ // { prop: 'def' }
+ // ]
+ // }
+ if (isObject(data)) {
+ results = {};
+ for (key in data) {
+ group = data[key];
+ tmp = fuzzaldrinPlus.filter(group, search_text, {
+ key: this.options.keys,
+ });
+ if (tmp.length) {
+ results[key] = tmp.map(item => item);
}
}
}
}
- return this.options.callback(results);
- } else {
- elements = this.options.elements();
- if (search_text) {
- elements.each(function() {
- var $el, matches;
- $el = $(this);
- matches = fuzzaldrinPlus.match($el.text().trim(), search_text);
- if (!$el.is('.dropdown-header')) {
- if (matches.length) {
- return $el.show().removeClass('option-hidden');
- } else {
- return $el.hide().addClass('option-hidden');
- }
+ }
+ return this.options.callback(results);
+ } else {
+ elements = this.options.elements();
+ if (search_text) {
+ elements.each(function() {
+ const $el = $(this);
+ const matches = fuzzaldrinPlus.match($el.text().trim(), search_text);
+ if (!$el.is('.dropdown-header')) {
+ if (matches.length) {
+ return $el.show().removeClass('option-hidden');
+ } else {
+ return $el.hide().addClass('option-hidden');
}
- });
- } else {
- elements.show().removeClass('option-hidden');
- }
-
- elements
- .parent()
- .find('.dropdown-menu-empty-item')
- .toggleClass('hidden', elements.is(':visible'));
+ }
+ });
+ } else {
+ elements.show().removeClass('option-hidden');
}
- };
-
- return GitLabDropdownFilter;
-})();
-GitLabDropdownRemote = (function() {
- function GitLabDropdownRemote(dataEndpoint, options) {
- this.dataEndpoint = dataEndpoint;
- this.options = options;
+ elements
+ .parent()
+ .find('.dropdown-menu-empty-item')
+ .toggleClass('hidden', elements.is(':visible'));
}
+};
- GitLabDropdownRemote.prototype.execute = function() {
- if (typeof this.dataEndpoint === 'string') {
- return this.fetchData();
- } else if (typeof this.dataEndpoint === 'function') {
- if (this.options.beforeSend) {
- this.options.beforeSend();
- }
- return this.dataEndpoint(
- '',
- (function(_this) {
- // Fetch the data by calling the data funcfion
- return function(data) {
- if (_this.options.success) {
- _this.options.success(data);
- }
- if (_this.options.beforeSend) {
- return _this.options.beforeSend();
- }
- };
- })(this),
- );
- }
- };
+function GitLabDropdownRemote(dataEndpoint, options) {
+ this.dataEndpoint = dataEndpoint;
+ this.options = options;
+}
- GitLabDropdownRemote.prototype.fetchData = function() {
+GitLabDropdownRemote.prototype.execute = function() {
+ if (typeof this.dataEndpoint === 'string') {
+ return this.fetchData();
+ } else if (typeof this.dataEndpoint === '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 }) => {
+ return this.dataEndpoint('', data => {
+ // Fetch the data by calling the data function
if (this.options.success) {
- return this.options.success(data);
+ this.options.success(data);
+ }
+ if (this.options.beforeSend) {
+ return this.options.beforeSend();
}
});
- };
-
- return GitLabDropdownRemote;
-})();
-
-GitLabDropdown = (function() {
- var ACTIVE_CLASS,
- FILTER_INPUT,
- NO_FILTER_INPUT,
- INDETERMINATE_CLASS,
- LOADING_CLASS,
- PAGE_TWO_CLASS,
- NON_SELECTABLE_CLASSES,
- SELECTABLE_CLASSES,
- CURSOR_SELECT_SCROLL_PADDING,
- currentIndex;
-
- LOADING_CLASS = 'is-loading';
-
- PAGE_TWO_CLASS = 'is-page-two';
-
- ACTIVE_CLASS = 'is-active';
-
- INDETERMINATE_CLASS = 'is-indeterminate';
+ }
+};
- currentIndex = -1;
+GitLabDropdownRemote.prototype.fetchData = function() {
+ if (this.options.beforeSend) {
+ this.options.beforeSend();
+ }
- NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-item';
-
- SELECTABLE_CLASSES = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES}, .option-hidden)`;
-
- CURSOR_SELECT_SCROLL_PADDING = 5;
-
- FILTER_INPUT = '.dropdown-input .dropdown-input-field:not(.dropdown-no-filter)';
-
- NO_FILTER_INPUT = '.dropdown-input .dropdown-input-field.dropdown-no-filter';
-
- function GitLabDropdown(el1, options) {
- var searchFields, 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);
+ // 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);
}
- 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: (function(_this) {
- return function(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');
- }
- };
- // Remote data
- })(this),
- 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,
+ });
+};
+
+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,
- elements: (function(_this) {
- return function() {
- selector = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES})`;
- if (_this.dropdown.find('.dropdown-toggle-page').length) {
- selector = `.dropdown-page-one ${selector}`;
- }
- return $(selector, this.instance.dropdown);
- };
- })(this),
- data: (function(_this) {
- return function() {
- return _this.fullData;
- };
- })(this),
- callback: (function(_this) {
- return function(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;
- }
- }
- };
- })(this),
});
}
- // 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',
- (function(_this) {
- return function(e) {
- // Escape key
- if (e.which === 27) {
- return $('.dropdown-menu-close', _this.dropdown).trigger('click');
+ }
+ 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.dropdown.find('.dropdown-toggle-page').length) {
+ selector = `.dropdown-page-one ${selector}`;
}
- };
- })(this),
- );
- this.dropdown.on(
- 'blur',
- 'a',
- (function(_this) {
- return function(e) {
- var $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.el).is('input')) {
+ currentIndex = -1;
+ } else {
+ $(selector, this.dropdown)
+ .first()
+ .find('a')
+ .addClass('is-focused');
+ currentIndex = 0;
}
- };
- })(this),
- );
- if (this.dropdown.find('.dropdown-toggle-page').length) {
- this.dropdown.find('.dropdown-toggle-page, .dropdown-menu-back').on(
- 'click',
- (function(_this) {
- return function(e) {
- e.preventDefault();
- e.stopPropagation();
- return _this.togglePage();
- };
- })(this),
- );
+ }
+ },
+ });
+ }
+ // 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');
}
- 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('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.on('click', selector, e => {
- var $el, selected, selectedObj, isMarking;
- $el = $(e.currentTarget);
- selected = self.rowClicked($el);
- selectedObj = selected ? selected[0] : null;
- isMarking = selected ? selected[1] : null;
- if (this.options.clicked) {
- this.options.clicked.call(this, {
- selectedObj,
- $el,
- e,
- isMarking,
- });
- }
- // Update label right after all modifications in dropdown has been done
- if (this.options.toggleLabel) {
- this.updateLabel(selectedObj, $el, this);
- }
+ // Update label right after all modifications in dropdown has been done
+ if (this.options.toggleLabel) {
+ this.updateLabel(selectedObj, $el, this);
+ }
- $el.trigger('blur');
- });
- }
+ $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
+GitLabDropdown.prototype.getElement = function(selector) {
+ return this.dropdown.find(selector);
+};
- GitLabDropdown.prototype.toggleLoading = function() {
- return $('.dropdown-menu', this.dropdown).toggleClass(LOADING_CLASS);
- };
+GitLabDropdown.prototype.toggleLoading = function() {
+ return $('.dropdown-menu', this.dropdown).toggleClass(LOADING_CLASS);
+};
- GitLabDropdown.prototype.togglePage = function() {
- var menu;
- menu = $('.dropdown-menu', this.dropdown);
- if (menu.hasClass(PAGE_TWO_CLASS)) {
- if (this.remote) {
- this.remote.execute();
- }
+GitLabDropdown.prototype.togglePage = function() {
+ 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();
- };
-
- GitLabDropdown.prototype.parseData = function(data) {
- var full_html, groupData, html, name;
- this.renderedData = data;
- if (this.options.filterable && data.length === 0) {
- // render no matching results
- html = [this.noResults()];
- } else {
- // Handle array groups
- if (isObject(data)) {
- html = [];
- for (name in data) {
- groupData = data[name];
- html.push(
- this.renderItem(
- {
- content: name,
- type: 'header',
- },
- name,
- ),
- );
- this.renderData(groupData, name).map(item => html.push(item));
- }
- } else {
- // Render each row
- html = this.renderData(data);
+ }
+ 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 {
+ // Handle array groups
+ if (isObject(data)) {
+ html = [];
+ for (name in data) {
+ groupData = data[name];
+ html.push(
+ this.renderItem(
+ {
+ content: name,
+ type: 'header',
+ },
+ name,
+ ),
+ );
+ this.renderData(groupData, name).map(item => html.push(item));
}
+ } else {
+ // Render each row
+ html = this.renderData(data);
}
- // Render the full menu
- 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) {
- var $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 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));
+};
- return true;
+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;
+ }
}
- };
-
- 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) {
- var contentHtml;
- this.resetRows();
- this.addArrowKeyEvent();
+ return true;
+ }
+};
- 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');
+GitLabDropdown.prototype.filteredFullData = function() {
+ return this.fullData.filter(
+ r =>
+ typeof r === 'object' &&
+ !Object.prototype.hasOwnProperty.call(r, 'beforeDivider') &&
+ !Object.prototype.hasOwnProperty.call(r, 'header'),
+ );
+};
- // Makes indeterminate items effective
- if (this.fullData && (shouldRefreshOnOpen || hasFilterBulkUpdate)) {
- this.parseData(this.fullData);
- }
+GitLabDropdown.prototype.opened = function(e) {
+ this.resetRows();
+ this.addArrowKeyEvent();
- // 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),
- );
- }
+ 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');
- contentHtml = $('.dropdown-content', this.dropdown).html();
- if (this.remote && contentHtml === '') {
- this.remote.execute();
+ // 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),
+ );
+ }
+
+ const contentHtml = $('.dropdown-content', this.dropdown).html();
+ if (this.remote && contentHtml === '') {
+ this.remote.execute();
+ } else {
+ this.focusTextInput();
+ }
+
+ if (this.options.showMenuAbove) {
+ this.positionMenuAbove();
+ }
+
+ if (this.options.opened) {
+ if (this.options.preserveContext) {
+ this.options.opened(e);
} else {
- this.focusTextInput();
+ this.options.opened.call(this, e);
}
+ }
- if (this.options.showMenuAbove) {
- this.positionMenuAbove();
- }
+ return this.dropdown.trigger('shown.gl.dropdown');
+};
- if (this.options.opened) {
- if (this.options.preserveContext) {
- this.options.opened(e);
- } else {
- this.options.opened.call(this, e);
- }
- }
+GitLabDropdown.prototype.positionMenuAbove = function() {
+ const $menu = this.dropdown.find('.dropdown-menu');
- return this.dropdown.trigger('shown.gl.dropdown');
- };
+ $menu.addClass('dropdown-open-top');
+ $menu.css('top', 'initial');
+ $menu.css('bottom', '100%');
+};
- GitLabDropdown.prototype.positionMenuAbove = function() {
- var $menu = this.dropdown.find('.dropdown-menu');
+GitLabDropdown.prototype.hidden = function(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');
+};
- $menu.addClass('dropdown-open-top');
- $menu.css('top', 'initial');
- $menu.css('bottom', '100%');
- };
+// Render the full menu
+GitLabDropdown.prototype.renderMenu = function(html) {
+ if (this.options.renderMenu) {
+ return this.options.renderMenu(html);
+ } else {
+ return $('<ul>').append(html);
+ }
+};
- GitLabDropdown.prototype.hidden = function(e) {
- var $input;
- this.resetRows();
- this.removeArrowKeyEvent();
- $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');
- };
+// Append the menu into the dropdown
+GitLabDropdown.prototype.appendMenu = function(html) {
+ return this.clearMenu().append(html);
+};
- // Render the full menu
- GitLabDropdown.prototype.renderMenu = function(html) {
- if (this.options.renderMenu) {
- return this.options.renderMenu(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 {
- return $('<ul>').append(html);
+ selector = '.dropdown-page-one .dropdown-content';
}
- };
+ }
- // Append the menu into the dropdown
- GitLabDropdown.prototype.appendMenu = function(html) {
- return this.clearMenu().append(html);
- };
+ return $(selector, this.dropdown).empty();
+};
- GitLabDropdown.prototype.clearMenu = function() {
- var 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';
- }
- }
+GitLabDropdown.prototype.renderItem = function(data, group, index) {
+ let parent;
- return $(selector, this.dropdown).empty();
- };
+ if (this.dropdown && this.dropdown[0]) {
+ parent = this.dropdown[0].parentNode;
+ }
- GitLabDropdown.prototype.renderItem = function(data, group, index) {
- let parent;
+ 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.highlightTemplate = function(text, template) {
- return `"<b>${_.escape(text)}</b>" ${template}`;
- };
-
- GitLabDropdown.prototype.highlightTextMatches = function(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>`;
- } else {
- return character;
- }
- })
- .join('');
- };
-
- GitLabDropdown.prototype.noResults = function() {
- return '<li class="dropdown-menu-empty-item"><a>No matching results</a></li>';
- };
-
- GitLabDropdown.prototype.rowClicked = function(el) {
- var field, groupName, isInput, selectedIndex, selectedObject, value, isMarking;
-
- const { fieldName } = this.options;
- 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];
- }
- }
+GitLabDropdown.prototype.highlightTextMatches = function(text, term) {
+ const occurrences = fuzzaldrinPlus.match(text, term);
+ const { indexOf } = [];
- if (this.options.vue) {
- if (el.hasClass(ACTIVE_CLASS)) {
- el.removeClass(ACTIVE_CLASS);
+ return text
+ .split('')
+ .map((character, i) => {
+ if (indexOf.call(occurrences, i) !== -1) {
+ return `<b>${character}</b>`;
} else {
- el.addClass(ACTIVE_CLASS);
+ return character;
}
+ })
+ .join('');
+};
- return [selectedObject];
- }
-
- field = [];
- 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, "\\'")}']`);
- }
+GitLabDropdown.prototype.noResults = function() {
+ return '<li class="dropdown-menu-empty-item"><a>No matching results</a></li>';
+};
- if (this.options.isSelectable && !this.options.isSelectable(selectedObject, el)) {
- return [selectedObject];
+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];
}
+ }
- if (el.hasClass(ACTIVE_CLASS) && value !== 0) {
- isMarking = false;
+ if (this.options.vue) {
+ if (el.hasClass(ACTIVE_CLASS)) {
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 (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];
+ }
+
+ 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, "\\'")}']`);
+ }
- GitLabDropdown.prototype.focusTextInput = function() {
- if (this.options.filterable) {
- const initialScrollTop = $(window).scrollTop();
+ if (this.options.isSelectable && !this.options.isSelectable(selectedObject, el)) {
+ return [selectedObject];
+ }
- if (this.dropdown.is('.show') && !this.filterInput.is(':focus')) {
- this.filterInput.focus();
+ 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 ($(window).scrollTop() < initialScrollTop) {
- $(window).scrollTop(initialScrollTop);
+ }
+ 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');
}
}
- };
+ }
- GitLabDropdown.prototype.addInput = function(fieldName, value, selectedObject, single) {
- var $input;
- // Create hidden input for form
- if (single) {
- $(`input[name="${fieldName}"]`).remove();
- }
+ return [selectedObject, isMarking];
+};
- $input = $('<input>')
- .attr('type', 'hidden')
- .attr('name', fieldName)
- .val(value);
- if (this.options.inputId != null) {
- $input.attr('id', this.options.inputId);
- }
+GitLabDropdown.prototype.focusTextInput = function() {
+ if (this.options.filterable) {
+ const initialScrollTop = $(window).scrollTop();
- if (this.options.multiSelect) {
- Object.keys(selectedObject).forEach(attribute => {
- $input.attr(`data-${attribute}`, selectedObject[attribute]);
- });
+ if (this.dropdown.is('.show') && !this.filterInput.is(':focus')) {
+ this.filterInput.focus();
}
- if (this.options.inputMeta) {
- $input.attr('data-meta', selectedObject[this.options.inputMeta]);
+ if ($(window).scrollTop() < initialScrollTop) {
+ $(window).scrollTop(initialScrollTop);
}
+ }
+};
+
+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);
+ }
- this.dropdown.before($input).trigger('change');
- };
+ 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]);
+ }
- GitLabDropdown.prototype.selectRowAtIndex = function(index) {
- var $el, selector;
- // If we pass an option index
- if (typeof index !== 'undefined') {
- selector = `${SELECTABLE_CLASSES}:eq(${index}) a`;
+ 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);
} else {
- selector = '.dropdown-content .is-focused';
+ $el.trigger('click');
}
- if (this.dropdown.find('.dropdown-toggle-page').length) {
- selector = `.dropdown-page-one ${selector}`;
- }
- // simulate a click on the first link
- $el = $(selector, this.dropdown);
- if ($el.length) {
- var href = $el.attr('href');
- if (href && href !== '#') {
- visitUrl(href);
- } else {
- $el.trigger('click');
- }
- }
- };
+ }
+};
- GitLabDropdown.prototype.addArrowKeyEvent = function() {
- var ARROW_KEY_CODES, selector;
- ARROW_KEY_CODES = [38, 40];
- selector = SELECTABLE_CLASSES;
- if (this.dropdown.find('.dropdown-toggle-page').length) {
- selector = `.dropdown-page-one ${selector}`;
+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;
+ }
+ } else if (currentKeyCode === 38) {
+ // Move up
+ if (currentIndex > 0) {
+ currentIndex -= 1;
+ }
+ }
+ if (currentIndex !== PREV_INDEX) {
+ this.highlightRowAtIndex($listItems, currentIndex);
+ }
+ return false;
}
- return $('body').on(
- 'keydown',
- (function(_this) {
- return function(e) {
- var $listItems, PREV_INDEX, currentKeyCode;
- 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;
- }
- }
- if (currentIndex !== PREV_INDEX) {
- _this.highlightRowAtIndex($listItems, currentIndex);
- }
- return false;
- }
- if (currentKeyCode === 13 && currentIndex !== -1) {
- e.preventDefault();
- _this.selectRowAtIndex();
- }
- };
- })(this),
- );
- };
-
- 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) {
- var $dropdownContent,
- $listItem,
- dropdownContentBottom,
- dropdownContentHeight,
- dropdownContentTop,
- dropdownScrollTop,
- listItemBottom,
- listItemHeight,
- listItemTop;
-
- if (!$listItems) {
- $listItems = $(SELECTABLE_CLASSES, this.dropdown);
+ if (currentKeyCode === 13 && currentIndex !== -1) {
+ e.preventDefault();
+ this.selectRowAtIndex();
}
+ });
+};
- // 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
- $listItem = $listItems.eq(index);
- $listItem.find('a:first-child').addClass('is-focused');
- // Dropdown content scroll area
- $dropdownContent = $listItem.closest('.dropdown-content');
- dropdownScrollTop = $dropdownContent.scrollTop();
- dropdownContentHeight = $dropdownContent.outerHeight();
- dropdownContentTop = $dropdownContent.prop('offsetTop');
- dropdownContentBottom = dropdownContentTop + dropdownContentHeight;
- // Get the offset bottom of the list item
- listItemHeight = $listItem.outerHeight();
- listItemTop = $listItem.prop('offsetTop');
- 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.removeArrowKeyEvent = function() {
+ return $('body').off('keydown');
+};
- GitLabDropdown.prototype.updateLabel = function(selected, el, instance) {
- if (selected == null) {
- selected = null;
- }
- if (el == null) {
- el = null;
- }
- if (instance == null) {
- instance = null;
- }
+GitLabDropdown.prototype.resetRows = function 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;
- }
+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;
+ }
+ if (el == null) {
+ el = null;
+ }
+ if (instance == null) {
+ instance = null;
+ }
- return $(this.el)
- .find('.dropdown-toggle-text')
- .text(toggleText);
- };
+ 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);
+};
- return GitLabDropdown;
-})();
+GitLabDropdown.prototype.clearField = function(field, isInput) {
+ return isInput ? field.val('') : field.remove();
+};
$.fn.glDropdown = function(opts) {
return this.each(function() {
diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
index bd504d95ee2..6258ee7f153 100644
--- a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
+++ b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton, GlFormGroup, GlFormInput, GlFormCheckbox, GlLink } from '@gitlab/ui';
-import Icon from '~/vue_shared/components/icon.vue';
import { mapState, mapActions } from 'vuex';
+import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index 8d2dac47ff2..ce6591e85cf 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -2,13 +2,13 @@
/* global Flash */
import $ from 'jquery';
+import { GlLoadingIcon } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import { HIDDEN_CLASS } from '~/lib/utils/constants';
import { getParameterByName } from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
-import { GlLoadingIcon } from '@gitlab/ui';
import eventHub from '../event_hub';
import { COMMON_STR, CONTENT_LIST_CLASS } from '../constants';
import groupsComponent from './groups.vue';
diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue
index 734a9a89c72..675552e6c2b 100644
--- a/app/assets/javascripts/groups/components/item_stats.vue
+++ b/app/assets/javascripts/groups/components/item_stats.vue
@@ -1,5 +1,6 @@
<script>
import icon from '~/vue_shared/components/icon.vue';
+import { GlBadge } from '@gitlab/ui';
import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import {
ITEM_TYPE,
@@ -8,13 +9,16 @@ import {
PROJECT_VISIBILITY_TYPE,
} from '../constants';
import itemStatsValue from './item_stats_value.vue';
+import isProjectPendingRemoval from 'ee_else_ce/groups/mixins/is_project_pending_removal';
export default {
components: {
icon,
timeAgoTooltip,
itemStatsValue,
+ GlBadge,
},
+ mixins: [isProjectPendingRemoval],
props: {
item: {
type: Object,
@@ -70,6 +74,9 @@ export default {
css-class="project-stars"
icon-name="star"
/>
+ <div v-if="isProjectPendingRemoval">
+ <gl-badge variant="warning">{{ __('pending removal') }}</gl-badge>
+ </div>
<div v-if="isProject" class="last-updated">
<time-ago-tooltip :time="item.updatedAt" tooltip-placement="bottom" />
</div>
diff --git a/app/assets/javascripts/groups/mixins/is_project_pending_removal.js b/app/assets/javascripts/groups/mixins/is_project_pending_removal.js
new file mode 100644
index 00000000000..e44e5780199
--- /dev/null
+++ b/app/assets/javascripts/groups/mixins/is_project_pending_removal.js
@@ -0,0 +1,7 @@
+export default {
+ computed: {
+ isProjectPendingRemoval() {
+ return false;
+ },
+ },
+};
diff --git a/app/assets/javascripts/groups/store/groups_store.js b/app/assets/javascripts/groups/store/groups_store.js
index 16f95d5a0cc..214ac5e3db5 100644
--- a/app/assets/javascripts/groups/store/groups_store.js
+++ b/app/assets/javascripts/groups/store/groups_store.js
@@ -93,6 +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,
};
}
diff --git a/app/assets/javascripts/helpers/monitor_helper.js b/app/assets/javascripts/helpers/monitor_helper.js
index d172aa8a444..87b4b14f6bf 100644
--- a/app/assets/javascripts/helpers/monitor_helper.js
+++ b/app/assets/javascripts/helpers/monitor_helper.js
@@ -7,6 +7,7 @@
export const makeDataSeries = (queryResults, defaultConfig) =>
queryResults
.map(result => {
+ // NaN values may disrupt avg., max. & min. calculations in the legend, filter them out
const data = result.values.filter(([, value]) => !Number.isNaN(value));
if (!data.length) {
return null;
diff --git a/app/assets/javascripts/ide/.eslintrc.yml b/app/assets/javascripts/ide/.eslintrc.yml
index 92b96d717be..2af5c1798f5 100644
--- a/app/assets/javascripts/ide/.eslintrc.yml
+++ b/app/assets/javascripts/ide/.eslintrc.yml
@@ -1,3 +1,5 @@
rules:
+ # https://gitlab.com/gitlab-org/gitlab/issues/28717
+ import/no-cycle: off
# https://gitlab.com/gitlab-org/gitlab/issues/33024
promise/no-nesting: off
diff --git a/app/assets/javascripts/ide/components/branches/search_list.vue b/app/assets/javascripts/ide/components/branches/search_list.vue
index db8365a08e0..31f1dec43ad 100644
--- a/app/assets/javascripts/ide/components/branches/search_list.vue
+++ b/app/assets/javascripts/ide/components/branches/search_list.vue
@@ -1,8 +1,8 @@
<script>
import { mapActions, mapState } from 'vuex';
import _ from 'underscore';
-import Icon from '~/vue_shared/components/icon.vue';
import { GlLoadingIcon } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
import Item from './item.vue';
export default {
diff --git a/app/assets/javascripts/ide/components/file_templates/dropdown.vue b/app/assets/javascripts/ide/components/file_templates/dropdown.vue
index 343e0cca672..35e5f9bcf69 100644
--- a/app/assets/javascripts/ide/components/file_templates/dropdown.vue
+++ b/app/assets/javascripts/ide/components/file_templates/dropdown.vue
@@ -1,8 +1,8 @@
<script>
import $ from 'jquery';
import { mapActions, mapState } from 'vuex';
-import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
import { GlLoadingIcon } from '@gitlab/ui';
+import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
export default {
components: {
diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue
index 326589fa50f..6eaf08e8033 100644
--- a/app/assets/javascripts/ide/components/ide_status_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_status_bar.vue
@@ -22,7 +22,7 @@ export default {
mixins: [timeAgoMixin],
data() {
return {
- lastCommitFormatedAge: null,
+ lastCommitFormattedAge: null,
};
},
computed: {
@@ -62,7 +62,7 @@ export default {
},
commitAgeUpdate() {
if (this.lastCommit) {
- this.lastCommitFormatedAge = this.timeFormated(this.lastCommit.committed_date);
+ this.lastCommitFormattedAge = this.timeFormatted(this.lastCommit.committed_date);
}
},
getCommitPath(shortSha) {
@@ -118,7 +118,7 @@ export default {
:title="tooltipTitle(lastCommit.committed_date)"
data-placement="top"
data-container="body"
- >{{ lastCommitFormatedAge }}</time
+ >{{ lastCommitFormattedAge }}</time
>
</div>
<ide-status-list class="ml-auto" />
diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue
index 95782b2c88a..3a0dd60f0e0 100644
--- a/app/assets/javascripts/ide/components/ide_tree_list.vue
+++ b/app/assets/javascripts/ide/components/ide_tree_list.vue
@@ -1,7 +1,7 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
-import Icon from '~/vue_shared/components/icon.vue';
import { GlSkeletonLoading } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
import FileRow from '~/vue_shared/components/file_row.vue';
import NavDropdown from './nav_dropdown.vue';
import FileRowExtra from './file_row_extra.vue';
diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue
index 5daf2d1422c..5a8face062b 100644
--- a/app/assets/javascripts/ide/components/merge_requests/list.vue
+++ b/app/assets/javascripts/ide/components/merge_requests/list.vue
@@ -1,9 +1,9 @@
<script>
import { mapActions, mapState } from 'vuex';
import _ from 'underscore';
+import { GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
-import { GlLoadingIcon } from '@gitlab/ui';
import Item from './item.vue';
import TokenedInput from '../shared/tokened_input.vue';
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index d2ed1fe3e55..ecafb4e81c4 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -1,8 +1,8 @@
<script>
import $ from 'jquery';
+import { mapActions, mapState, mapGetters } from 'vuex';
import flash from '~/flash';
import { __, sprintf, s__ } from '~/locale';
-import { mapActions, mapState, mapGetters } from 'vuex';
import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { modalTypes } from '../../constants';
diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue
index bc80e1dba25..ff23485f0f0 100644
--- a/app/assets/javascripts/ide/components/preview/navigator.vue
+++ b/app/assets/javascripts/ide/components/preview/navigator.vue
@@ -1,7 +1,7 @@
<script>
import { listen } from 'codesandbox-api';
-import Icon from '~/vue_shared/components/icon.vue';
import { GlLoadingIcon } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue
index 7615cfc966e..8370833233a 100644
--- a/app/assets/javascripts/ide/components/repo_tab.vue
+++ b/app/assets/javascripts/ide/components/repo_tab.vue
@@ -1,6 +1,6 @@
<script>
-import { __, sprintf } from '~/locale';
import { mapActions } from 'vuex';
+import { __, sprintf } from '~/locale';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import Icon from '~/vue_shared/components/icon.vue';
diff --git a/app/assets/javascripts/ide/lib/files.js b/app/assets/javascripts/ide/lib/files.js
index e86dac20104..bee867fa47c 100644
--- a/app/assets/javascripts/ide/lib/files.js
+++ b/app/assets/javascripts/ide/lib/files.js
@@ -1,5 +1,6 @@
import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
-import { decorateData, sortTree, escapeFileUrl } from '../stores/utils';
+import { escapeFileUrl } from '~/lib/utils/url_utility';
+import { decorateData, sortTree } from '../stores/utils';
export const splitParent = path => {
const idx = path.lastIndexOf('/');
diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js
index f6ad2f9c7d1..b130e6e8b81 100644
--- a/app/assets/javascripts/ide/services/index.js
+++ b/app/assets/javascripts/ide/services/index.js
@@ -1,6 +1,5 @@
import axios from '~/lib/utils/axios_utils';
-import { joinPaths } from '~/lib/utils/url_utility';
-import { escapeFileUrl } from '../stores/utils';
+import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
import Api from '~/api';
export default {
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index 4e18ec58feb..dd69e2d6f1f 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -1,9 +1,9 @@
import $ from 'jquery';
import Vue from 'vue';
+import _ from 'underscore';
import { __, sprintf } from '~/locale';
import { visitUrl } from '~/lib/utils/url_utility';
import flash from '~/flash';
-import _ from 'underscore';
import * as types from './mutation_types';
import { decorateFiles } from '../lib/files';
import { stageKeys } from '../constants';
@@ -17,10 +17,18 @@ export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DAT
export const discardAllChanges = ({ state, commit, dispatch }) => {
state.changedFiles.forEach(file => {
- commit(types.DISCARD_FILE_CHANGES, file.path);
+ if (file.tempFile || file.prevPath) dispatch('closeFile', file);
if (file.tempFile) {
- dispatch('closeFile', file.path);
+ dispatch('deleteEntry', file.path);
+ } else if (file.prevPath) {
+ dispatch('renameEntry', {
+ path: file.path,
+ name: file.prevName,
+ parentPath: file.prevParentPath,
+ });
+ } else {
+ commit(types.DISCARD_FILE_CHANGES, file.path);
}
});
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
index 9af0b50d1a5..8864224c19e 100644
--- a/app/assets/javascripts/ide/stores/actions/file.js
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -1,10 +1,10 @@
-import { joinPaths } from '~/lib/utils/url_utility';
+import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import eventHub from '../../eventhub';
import service from '../../services';
import * as types from '../mutation_types';
import router from '../../ide_router';
-import { escapeFileUrl, addFinalNewlineIfNeeded, setPageTitleForFile } from '../utils';
+import { addFinalNewlineIfNeeded, setPageTitleForFile } from '../utils';
import { viewerTypes, stageKeys } from '../../constants';
export const closeFile = ({ commit, state, dispatch }, file) => {
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index a176fd0aca8..bb8374b4e78 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -115,5 +115,30 @@ export const isOnDefaultBranch = (_state, getters) =>
export const canPushToBranch = (_state, getters) =>
getters.currentBranch && getters.currentBranch.can_push;
+export const isFileDeletedAndReadded = (state, getters) => path => {
+ const stagedFile = getters.getStagedFile(path);
+ const file = state.entries[path];
+ return Boolean(stagedFile && stagedFile.deleted && file.tempFile);
+};
+
+// checks if any diff exists in the staged or unstaged changes for this path
+export const getDiffInfo = (state, getters) => path => {
+ const stagedFile = getters.getStagedFile(path);
+ const file = state.entries[path];
+ const renamed = file.prevPath ? file.path !== file.prevPath : false;
+ const deletedAndReadded = getters.isFileDeletedAndReadded(path);
+ const deleted = deletedAndReadded ? false : file.deleted;
+ const tempFile = deletedAndReadded ? false : file.tempFile;
+ const changed = file.content !== (deletedAndReadded ? stagedFile.raw : file.raw);
+
+ return {
+ exists: changed || renamed || deleted || tempFile,
+ changed,
+ renamed,
+ deleted,
+ tempFile,
+ };
+};
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index be7ee80656f..47a2e6b5202 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -1,4 +1,5 @@
import { commitActionTypes, FILE_VIEW_MODE_EDITOR } from '../constants';
+import { escapeFileUrl } from '~/lib/utils/url_utility';
export const dataStructure = () => ({
id: '',
@@ -217,8 +218,6 @@ export const mergeTrees = (fromTree, toTree) => {
return toTree;
};
-export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/');
-
export const replaceFileUrl = (url, oldPath, newPath) => {
// Add `/-/` so that we don't accidentally replace project path
const result = url.replace(`/-/${escapeFileUrl(oldPath)}`, `/-/${escapeFileUrl(newPath)}`);
diff --git a/app/assets/javascripts/image_diff/.eslintrc.yml b/app/assets/javascripts/image_diff/.eslintrc.yml
new file mode 100644
index 00000000000..bf9e184381e
--- /dev/null
+++ b/app/assets/javascripts/image_diff/.eslintrc.yml
@@ -0,0 +1,3 @@
+rules:
+ # https://gitlab.com/gitlab-org/gitlab/issues/28719
+ import/no-cycle: off
diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js
index 7576d36f27d..1d0807dc15d 100644
--- a/app/assets/javascripts/issuable_form.js
+++ b/app/assets/javascripts/issuable_form.js
@@ -6,6 +6,36 @@ import UsersSelect from './users_select';
import ZenMode from './zen_mode';
import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility';
+import { queryToObject, objectToQuery } from './lib/utils/url_utility';
+
+function organizeQuery(obj, isFallbackKey = false) {
+ const sourceBranch = 'merge_request[source_branch]';
+ const targetBranch = 'merge_request[target_branch]';
+
+ if (isFallbackKey) {
+ return {
+ [sourceBranch]: obj[sourceBranch],
+ };
+ }
+
+ return {
+ [sourceBranch]: obj[sourceBranch],
+ [targetBranch]: obj[targetBranch],
+ };
+}
+
+function format(searchTerm, isFallbackKey = false) {
+ const queryObject = queryToObject(searchTerm);
+ const organizeQueryObject = organizeQuery(queryObject, isFallbackKey);
+ const formattedQuery = objectToQuery(organizeQueryObject);
+
+ return formattedQuery;
+}
+
+function getFallbackKey() {
+ const searchTerm = format(document.location.search, true);
+ return ['autosave', document.location.pathname, searchTerm].join('/');
+}
export default class IssuableForm {
constructor(form) {
@@ -57,16 +87,20 @@ export default class IssuableForm {
}
initAutosave() {
- this.autosave = new Autosave(this.titleField, [
- document.location.pathname,
- document.location.search,
- 'title',
- ]);
- return new Autosave(this.descriptionField, [
- document.location.pathname,
- document.location.search,
- 'description',
- ]);
+ const searchTerm = format(document.location.search);
+ const fallbackKey = getFallbackKey();
+
+ this.autosave = new Autosave(
+ this.titleField,
+ [document.location.pathname, searchTerm, 'title'],
+ `${fallbackKey}=title`,
+ );
+
+ return new Autosave(
+ this.descriptionField,
+ [document.location.pathname, searchTerm, 'description'],
+ `${fallbackKey}=description`,
+ );
}
handleSubmit() {
diff --git a/app/assets/javascripts/issuable_suggestions/components/item.vue b/app/assets/javascripts/issuable_suggestions/components/item.vue
index 7629e04684c..66a4cc44d51 100644
--- a/app/assets/javascripts/issuable_suggestions/components/item.vue
+++ b/app/assets/javascripts/issuable_suggestions/components/item.vue
@@ -91,7 +91,7 @@ export default {
/>
<gl-tooltip :target="() => $refs.state" placement="bottom">
<span class="d-block">
- <span class="bold"> {{ stateTitle }} </span> {{ timeFormated(closedOrCreatedDate) }}
+ <span class="bold"> {{ stateTitle }} </span> {{ timeFormatted(closedOrCreatedDate) }}
</span>
<span class="text-tertiary">{{ tooltipTitle(closedOrCreatedDate) }}</span>
</gl-tooltip>
diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue
index 06477477aad..415fa46835b 100644
--- a/app/assets/javascripts/jobs/components/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/sidebar.vue
@@ -1,8 +1,8 @@
<script>
-import { __, sprintf } from '~/locale';
import _ from 'underscore';
import { mapActions, mapState } from 'vuex';
import { GlLink, GlButton } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
import Icon from '~/vue_shared/components/icon.vue';
@@ -168,13 +168,13 @@ export default {
/>
<detail-row
v-if="job.finished_at"
- :value="timeFormated(job.finished_at)"
+ :value="timeFormatted(job.finished_at)"
class="js-job-finished"
title="Finished"
/>
<detail-row
v-if="job.erased_at"
- :value="timeFormated(job.erased_at)"
+ :value="timeFormatted(job.erased_at)"
class="js-job-erased"
title="Erased"
/>
diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue
index 922f64d93fe..5edb8ff555b 100644
--- a/app/assets/javascripts/jobs/components/trigger_block.vue
+++ b/app/assets/javascripts/jobs/components/trigger_block.vue
@@ -1,6 +1,6 @@
<script>
-import { __ } from '~/locale';
import { GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
const HIDDEN_VALUE = '••••••';
diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js
index 179d0bc4e0f..0b28c52a78f 100644
--- a/app/assets/javascripts/jobs/store/utils.js
+++ b/app/assets/javascripts/jobs/store/utils.js
@@ -114,7 +114,7 @@ export const logLinesParser = (lines = [], accumulator = []) =>
acc.push(parseHeaderLine(line, lineNumber));
} else if (isCollapsibleSection(acc, last, line)) {
// if the object belongs to a nested section, we append it to the new `lines` array of the
- // previously formated header
+ // previously formatted header
last.lines.push(parseLine(line, lineNumber));
} else if (line.section_duration) {
// if the line has section_duration, we look for the correct header to add it
diff --git a/app/assets/javascripts/lib/utils/axios_utils.js b/app/assets/javascripts/lib/utils/axios_utils.js
index a04fe609015..4eec5bffc66 100644
--- a/app/assets/javascripts/lib/utils/axios_utils.js
+++ b/app/assets/javascripts/lib/utils/axios_utils.js
@@ -33,11 +33,9 @@ window.addEventListener('beforeunload', () => {
// Ignore AJAX errors caused by requests
// being cancelled due to browser navigation
-const { gon } = window;
-const featureFlagEnabled = gon && gon.features && gon.features.suppressAjaxNavigationErrors;
axios.interceptors.response.use(
response => response,
- err => suppressAjaxErrorsDuringNavigation(err, isUserNavigating, featureFlagEnabled),
+ err => suppressAjaxErrorsDuringNavigation(err, isUserNavigating),
);
export default axios;
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 177ae4f9838..e4001e94478 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -5,7 +5,7 @@
import $ from 'jquery';
import axios from './axios_utils';
import { getLocationHash } from './url_utility';
-import { convertToCamelCase } from './text_utility';
+import { convertToCamelCase, convertToSnakeCase } from './text_utility';
import { isObject } from './type_utility';
import breakpointInstance from '../../breakpoints';
@@ -490,6 +490,8 @@ export const historyPushState = newUrl => {
*/
export const parseBoolean = value => (value && value.toString()) === 'true';
+export const BACKOFF_TIMEOUT = 'BACKOFF_TIMEOUT';
+
/**
* @callback backOffCallback
* @param {Function} next
@@ -541,7 +543,7 @@ export const backOff = (fn, timeout = 60000) => {
timeElapsed += nextInterval;
nextInterval = Math.min(nextInterval + nextInterval, maxInterval);
} else {
- reject(new Error('BACKOFF_TIMEOUT'));
+ reject(new Error(BACKOFF_TIMEOUT));
}
};
@@ -697,6 +699,22 @@ export const convertObjectPropsToCamelCase = (obj = {}, options = {}) => {
}, initial);
};
+/**
+ * Converts all the object keys to snake case
+ *
+ * @param {Object} obj Object to transform
+ * @returns {Object}
+ */
+// Follow up to add additional options param:
+// https://gitlab.com/gitlab-org/gitlab/issues/39173
+export const convertObjectPropsToSnakeCase = (obj = {}) =>
+ obj
+ ? Object.entries(obj).reduce(
+ (acc, [key, value]) => ({ ...acc, [convertToSnakeCase(key)]: value }),
+ {},
+ )
+ : {};
+
export const imagePath = imgUrl =>
`${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`;
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index 28143859e4c..996692bacb3 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import _ from 'underscore';
-import timeago from 'timeago.js';
+import * as timeago from 'timeago.js';
import dateFormat from 'dateformat';
import { languageCode, s__, __, n__ } from '../../locale';
@@ -92,90 +92,80 @@ export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z') => {
*/
const timeagoLanguageCode = languageCode().replace(/-/g, '_');
-let timeagoInstance;
-
/**
- * Sets a timeago Instance
+ * Registers timeago locales
*/
-export const getTimeago = () => {
- if (!timeagoInstance) {
- const memoizedLocaleRemaining = () => {
- const cache = [];
-
- const timeAgoLocaleRemaining = [
- () => [s__('Timeago|just now'), s__('Timeago|right now')],
- () => [s__('Timeago|just now'), s__('Timeago|%s seconds remaining')],
- () => [s__('Timeago|1 minute ago'), s__('Timeago|1 minute remaining')],
- () => [s__('Timeago|%s minutes ago'), s__('Timeago|%s minutes remaining')],
- () => [s__('Timeago|1 hour ago'), s__('Timeago|1 hour remaining')],
- () => [s__('Timeago|%s hours ago'), s__('Timeago|%s hours remaining')],
- () => [s__('Timeago|1 day ago'), s__('Timeago|1 day remaining')],
- () => [s__('Timeago|%s days ago'), s__('Timeago|%s days remaining')],
- () => [s__('Timeago|1 week ago'), s__('Timeago|1 week remaining')],
- () => [s__('Timeago|%s weeks ago'), s__('Timeago|%s weeks remaining')],
- () => [s__('Timeago|1 month ago'), s__('Timeago|1 month remaining')],
- () => [s__('Timeago|%s months ago'), s__('Timeago|%s months remaining')],
- () => [s__('Timeago|1 year ago'), s__('Timeago|1 year remaining')],
- () => [s__('Timeago|%s years ago'), s__('Timeago|%s years remaining')],
- ];
-
- return (number, index) => {
- if (cache[index]) {
- return cache[index];
- }
- cache[index] = timeAgoLocaleRemaining[index] && timeAgoLocaleRemaining[index]();
- return cache[index];
- };
- };
-
- const memoizedLocale = () => {
- const cache = [];
-
- const timeAgoLocale = [
- () => [s__('Timeago|just now'), s__('Timeago|right now')],
- () => [s__('Timeago|just now'), s__('Timeago|in %s seconds')],
- () => [s__('Timeago|1 minute ago'), s__('Timeago|in 1 minute')],
- () => [s__('Timeago|%s minutes ago'), s__('Timeago|in %s minutes')],
- () => [s__('Timeago|1 hour ago'), s__('Timeago|in 1 hour')],
- () => [s__('Timeago|%s hours ago'), s__('Timeago|in %s hours')],
- () => [s__('Timeago|1 day ago'), s__('Timeago|in 1 day')],
- () => [s__('Timeago|%s days ago'), s__('Timeago|in %s days')],
- () => [s__('Timeago|1 week ago'), s__('Timeago|in 1 week')],
- () => [s__('Timeago|%s weeks ago'), s__('Timeago|in %s weeks')],
- () => [s__('Timeago|1 month ago'), s__('Timeago|in 1 month')],
- () => [s__('Timeago|%s months ago'), s__('Timeago|in %s months')],
- () => [s__('Timeago|1 year ago'), s__('Timeago|in 1 year')],
- () => [s__('Timeago|%s years ago'), s__('Timeago|in %s years')],
- ];
-
- return (number, index) => {
- if (cache[index]) {
- return cache[index];
- }
- cache[index] = timeAgoLocale[index] && timeAgoLocale[index]();
- return cache[index];
- };
- };
-
- timeago.register(timeagoLanguageCode, memoizedLocale());
- timeago.register(`${timeagoLanguageCode}-remaining`, memoizedLocaleRemaining());
-
- timeagoInstance = timeago();
- }
+const memoizedLocaleRemaining = () => {
+ const cache = [];
+
+ const timeAgoLocaleRemaining = [
+ () => [s__('Timeago|just now'), s__('Timeago|right now')],
+ () => [s__('Timeago|just now'), s__('Timeago|%s seconds remaining')],
+ () => [s__('Timeago|1 minute ago'), s__('Timeago|1 minute remaining')],
+ () => [s__('Timeago|%s minutes ago'), s__('Timeago|%s minutes remaining')],
+ () => [s__('Timeago|1 hour ago'), s__('Timeago|1 hour remaining')],
+ () => [s__('Timeago|%s hours ago'), s__('Timeago|%s hours remaining')],
+ () => [s__('Timeago|1 day ago'), s__('Timeago|1 day remaining')],
+ () => [s__('Timeago|%s days ago'), s__('Timeago|%s days remaining')],
+ () => [s__('Timeago|1 week ago'), s__('Timeago|1 week remaining')],
+ () => [s__('Timeago|%s weeks ago'), s__('Timeago|%s weeks remaining')],
+ () => [s__('Timeago|1 month ago'), s__('Timeago|1 month remaining')],
+ () => [s__('Timeago|%s months ago'), s__('Timeago|%s months remaining')],
+ () => [s__('Timeago|1 year ago'), s__('Timeago|1 year remaining')],
+ () => [s__('Timeago|%s years ago'), s__('Timeago|%s years remaining')],
+ ];
+
+ return (number, index) => {
+ if (cache[index]) {
+ return cache[index];
+ }
+ cache[index] = timeAgoLocaleRemaining[index] && timeAgoLocaleRemaining[index]();
+ return cache[index];
+ };
+};
+
+const memoizedLocale = () => {
+ const cache = [];
+
+ const timeAgoLocale = [
+ () => [s__('Timeago|just now'), s__('Timeago|right now')],
+ () => [s__('Timeago|just now'), s__('Timeago|in %s seconds')],
+ () => [s__('Timeago|1 minute ago'), s__('Timeago|in 1 minute')],
+ () => [s__('Timeago|%s minutes ago'), s__('Timeago|in %s minutes')],
+ () => [s__('Timeago|1 hour ago'), s__('Timeago|in 1 hour')],
+ () => [s__('Timeago|%s hours ago'), s__('Timeago|in %s hours')],
+ () => [s__('Timeago|1 day ago'), s__('Timeago|in 1 day')],
+ () => [s__('Timeago|%s days ago'), s__('Timeago|in %s days')],
+ () => [s__('Timeago|1 week ago'), s__('Timeago|in 1 week')],
+ () => [s__('Timeago|%s weeks ago'), s__('Timeago|in %s weeks')],
+ () => [s__('Timeago|1 month ago'), s__('Timeago|in 1 month')],
+ () => [s__('Timeago|%s months ago'), s__('Timeago|in %s months')],
+ () => [s__('Timeago|1 year ago'), s__('Timeago|in 1 year')],
+ () => [s__('Timeago|%s years ago'), s__('Timeago|in %s years')],
+ ];
- return timeagoInstance;
+ return (number, index) => {
+ if (cache[index]) {
+ return cache[index];
+ }
+ cache[index] = timeAgoLocale[index] && timeAgoLocale[index]();
+ return cache[index];
+ };
};
+timeago.register(timeagoLanguageCode, memoizedLocale());
+timeago.register(`${timeagoLanguageCode}-remaining`, memoizedLocaleRemaining());
+
+export const getTimeago = () => timeago;
+
/**
* For the given elements, sets a tooltip with a formatted date.
* @param {JQuery} $timeagoEls
* @param {Boolean} setTimeago
*/
export const localTimeAgo = ($timeagoEls, setTimeago = true) => {
- getTimeago();
-
$timeagoEls.each((i, el) => {
- $(el).text(timeagoInstance.format($(el).attr('datetime'), timeagoLanguageCode));
+ $(el).text(timeago.format($(el).attr('datetime'), timeagoLanguageCode));
});
if (!setTimeago) {
@@ -207,9 +197,7 @@ export const timeFor = (time, expiredLabel) => {
if (new Date(time) < new Date()) {
return expiredLabel || s__('Timeago|Past due');
}
- return getTimeago()
- .format(time, `${timeagoLanguageCode}-remaining`)
- .trim();
+ return timeago.format(time, `${timeagoLanguageCode}-remaining`).trim();
};
export const getDayDifference = (a, b) => {
@@ -459,7 +447,7 @@ export const parsePikadayDate = dateString => {
/**
* Used `onSelect` method in pickaday
* @param {Date} date UTC format
- * @return {String} Date formated in yyyy-mm-dd
+ * @return {String} Date formatted in yyyy-mm-dd
*/
export const pikadayToString = date => {
const day = pad(date.getDate());
@@ -525,8 +513,8 @@ export const stringifyTime = (timeObject, fullNameFormat = false) => {
if (fullNameFormat && isNonZero) {
// Remove traling 's' if unit value is singular
- const formatedUnitName = unitValue > 1 ? unitName : unitName.replace(/s$/, '');
- return `${memo} ${unitValue} ${formatedUnitName}`;
+ const formattedUnitName = unitValue > 1 ? unitName : unitName.replace(/s$/, '');
+ return `${memo} ${unitValue} ${formattedUnitName}`;
}
return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo;
@@ -602,3 +590,19 @@ export const getDatesInRange = (d1, d2, formatter = x => x) => {
* @return {Number} number of milliseconds
*/
export const secondsToMilliseconds = seconds => seconds * 1000;
+
+/**
+ * Converts the supplied number of seconds to days.
+ *
+ * @param {Number} seconds
+ * @return {Number} number of days
+ */
+export const secondsToDays = seconds => Math.round(seconds / 86400);
+
+/**
+ * Returns the date after the date provided
+ *
+ * @param {Date} date the initial date
+ * @return {Date} the date following the date provided
+ */
+export const dayAfter = date => new Date(newDate(date).setDate(date.getDate() + 1));
diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js
index 5e5d10883a3..1c7d59054dc 100644
--- a/app/assets/javascripts/lib/utils/http_status.js
+++ b/app/assets/javascripts/lib/utils/http_status.js
@@ -21,6 +21,7 @@ const httpStatusCodes = {
NOT_FOUND: 404,
GONE: 410,
UNPROCESSABLE_ENTITY: 422,
+ SERVICE_UNAVAILABLE: 503,
};
export const successCodes = [
diff --git a/app/assets/javascripts/lib/utils/logoutput_behaviours.js b/app/assets/javascripts/lib/utils/logoutput_behaviours.js
deleted file mode 100644
index 41b57025cc9..00000000000
--- a/app/assets/javascripts/lib/utils/logoutput_behaviours.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import $ from 'jquery';
-import {
- canScroll,
- isScrolledToBottom,
- isScrolledToTop,
- isScrolledToMiddle,
- toggleDisableButton,
-} from './scroll_utils';
-
-export default class LogOutputBehaviours {
- constructor() {
- // Scroll buttons
- this.$scrollTopBtn = $('.js-scroll-up');
- this.$scrollBottomBtn = $('.js-scroll-down');
-
- this.$scrollTopBtn.off('click').on('click', this.scrollToTop.bind(this));
- this.$scrollBottomBtn.off('click').on('click', this.scrollToBottom.bind(this));
- }
-
- toggleScroll() {
- if (canScroll()) {
- if (isScrolledToMiddle()) {
- // User is in the middle of the log
-
- toggleDisableButton(this.$scrollTopBtn, false);
- toggleDisableButton(this.$scrollBottomBtn, false);
- } else if (isScrolledToTop()) {
- // User is at Top of Log
-
- toggleDisableButton(this.$scrollTopBtn, true);
- toggleDisableButton(this.$scrollBottomBtn, false);
- } else if (isScrolledToBottom()) {
- // User is at the bottom of the build log.
-
- toggleDisableButton(this.$scrollTopBtn, false);
- toggleDisableButton(this.$scrollBottomBtn, true);
- }
- } else {
- toggleDisableButton(this.$scrollTopBtn, true);
- toggleDisableButton(this.$scrollBottomBtn, true);
- }
- }
-
- toggleScrollAnimation(toggle) {
- this.$scrollBottomBtn.toggleClass('animate', toggle);
- }
-}
diff --git a/app/assets/javascripts/lib/utils/suppress_ajax_errors_during_navigation.js b/app/assets/javascripts/lib/utils/suppress_ajax_errors_during_navigation.js
index 4c61da9b862..fb4d9b7de9c 100644
--- a/app/assets/javascripts/lib/utils/suppress_ajax_errors_during_navigation.js
+++ b/app/assets/javascripts/lib/utils/suppress_ajax_errors_during_navigation.js
@@ -2,8 +2,8 @@
* An Axios error interceptor that suppresses AJAX errors caused
* by the request being cancelled when the user navigates to a new page
*/
-export default (err, isUserNavigating, featureFlagEnabled) => {
- if (featureFlagEnabled && isUserNavigating && err.code === 'ECONNABORTED') {
+export default (err, isUserNavigating) => {
+ if (isUserNavigating && err.code === 'ECONNABORTED') {
// If the user is navigating away from the current page,
// prevent .then() and .catch() handlers from being
// called by returning a Promise that never resolves
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 2e0270ee42f..cccf9ad311c 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, no-param-reassign, one-var, operator-assignment, no-else-return, consistent-return */
+/* eslint-disable func-names, no-param-reassign, operator-assignment, no-else-return, consistent-return */
import $ from 'jquery';
import { insertText } from '~/lib/utils/common_utils';
@@ -13,8 +13,7 @@ function addBlockTags(blockTag, selected) {
}
function lineBefore(text, textarea) {
- var split;
- split = text
+ const split = text
.substring(0, textarea.selectionStart)
.trim()
.split('\n');
@@ -80,7 +79,7 @@ function moveCursor({
editorSelectionStart,
editorSelectionEnd,
}) {
- var pos;
+ let pos;
if (textArea && !textArea.setSelectionRange) {
return;
}
@@ -132,18 +131,13 @@ export function insertMarkdownText({
select,
editor,
}) {
- var textToInsert,
- selectedSplit,
- startChar,
- removedLastNewLine,
- removedFirstNewLine,
- currentLineEmpty,
- lastNewLine,
- editorSelectionStart,
- editorSelectionEnd;
- removedLastNewLine = false;
- removedFirstNewLine = false;
- currentLineEmpty = false;
+ let removedLastNewLine = false;
+ let removedFirstNewLine = false;
+ let currentLineEmpty = false;
+ let editorSelectionStart;
+ let editorSelectionEnd;
+ let lastNewLine;
+ let textToInsert;
if (editor) {
const selectionRange = editor.getSelectionRange();
@@ -186,7 +180,7 @@ export function insertMarkdownText({
}
}
- selectedSplit = selected.split('\n');
+ const selectedSplit = selected.split('\n');
if (editor && !wrap) {
lastNewLine = editor.getValue().split('\n')[editorSelectionStart.row];
@@ -207,8 +201,7 @@ export function insertMarkdownText({
(textArea && textArea.selectionStart === 0) ||
(editor && editorSelectionStart.column === 0 && editorSelectionStart.row === 0);
- startChar = !wrap && !currentLineEmpty && !isBeginning ? '\n' : '';
-
+ const startChar = !wrap && !currentLineEmpty && !isBeginning ? '\n' : '';
const textPlaceholder = '{text}';
if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) {
@@ -263,11 +256,10 @@ export function insertMarkdownText({
}
function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) {
- var $textArea, selected, text;
- $textArea = $(textArea);
+ const $textArea = $(textArea);
textArea = $textArea.get(0);
- text = $textArea.val();
- selected = selectedText(text, textArea) || tagContent;
+ const text = $textArea.val();
+ const selected = selectedText(text, textArea) || tagContent;
$textArea.focus();
return insertMarkdownText({
textArea,
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 0c194d67bce..6bbf118d7d1 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -72,7 +72,7 @@ export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3
* @param {String} sha
* @returns {String}
*/
-export const truncateSha = sha => sha.substr(0, 8);
+export const truncateSha = sha => sha.substring(0, 8);
const ELLIPSIS_CHAR = '…';
export const truncatePathMiddleToLength = (text, maxWidth) => {
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index 4be0d05a9b7..d48678c21f6 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -1,4 +1,6 @@
-import { join as joinPaths } from 'path';
+const PATH_SEPARATOR = '/';
+const PATH_SEPARATOR_LEADING_REGEX = new RegExp(`^${PATH_SEPARATOR}+`);
+const PATH_SEPARATOR_ENDING_REGEX = new RegExp(`${PATH_SEPARATOR}+$`);
// Returns a decoded url parameter value
// - Treats '+' as '%20'
@@ -6,6 +8,37 @@ function decodeUrlParameter(val) {
return decodeURIComponent(val.replace(/\+/g, '%20'));
}
+function cleanLeadingSeparator(path) {
+ return path.replace(PATH_SEPARATOR_LEADING_REGEX, '');
+}
+
+function cleanEndingSeparator(path) {
+ return path.replace(PATH_SEPARATOR_ENDING_REGEX, '');
+}
+
+/**
+ * Safely joins the given paths which might both start and end with a `/`
+ *
+ * Example:
+ * - `joinPaths('abc/', '/def') === 'abc/def'`
+ * - `joinPaths(null, 'abc/def', 'zoo) === 'abc/def/zoo'`
+ *
+ * @param {...String} paths
+ * @returns {String}
+ */
+export function joinPaths(...paths) {
+ return paths.reduce((acc, path) => {
+ if (!path) {
+ return acc;
+ }
+ if (!acc) {
+ return path;
+ }
+
+ return [cleanEndingSeparator(acc), PATH_SEPARATOR, cleanLeadingSeparator(path)].join('');
+ }, '');
+}
+
// Returns an array containing the value(s) of the
// of the key passed as an argument
export function getParameterValues(sParam, url = window.location) {
@@ -181,4 +214,71 @@ export function getWebSocketUrl(path) {
return `${getWebSocketProtocol()}//${joinPaths(window.location.host, path)}`;
}
-export { joinPaths };
+/**
+ * Convert search query into an object
+ *
+ * @param {String} query from "document.location.search"
+ * @returns {Object}
+ *
+ * ex: "?one=1&two=2" into {one: 1, two: 2}
+ */
+export function queryToObject(query) {
+ const removeQuestionMarkFromQuery = String(query).startsWith('?') ? query.slice(1) : query;
+ return removeQuestionMarkFromQuery.split('&').reduce((accumulator, curr) => {
+ const p = curr.split('=');
+ accumulator[decodeURIComponent(p[0])] = decodeURIComponent(p[1]);
+ return accumulator;
+ }, {});
+}
+
+/**
+ * Convert search query object back into a search query
+ *
+ * @param {Object} obj that needs to be converted
+ * @returns {String}
+ *
+ * ex: {one: 1, two: 2} into "one=1&two=2"
+ *
+ */
+export function objectToQuery(obj) {
+ return Object.keys(obj)
+ .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(obj[k])}`)
+ .join('&');
+}
+
+/**
+ * Sets query params for a given URL
+ * It adds new query params, updates existing params with a new value and removes params with value null/undefined
+ *
+ * @param {Object} params The query params to be set/updated
+ * @param {String} url The url to be operated on
+ * @param {Boolean} clearParams Indicates whether existing query params should be removed or not
+ * @returns {String} A copy of the original with the updated query params
+ */
+export const setUrlParams = (params, url = window.location.href, clearParams = false) => {
+ const urlObj = new URL(url);
+ const queryString = urlObj.search;
+ const searchParams = clearParams ? new URLSearchParams('') : new URLSearchParams(queryString);
+
+ Object.keys(params).forEach(key => {
+ if (params[key] === null || params[key] === undefined) {
+ searchParams.delete(key);
+ } else if (Array.isArray(params[key])) {
+ params[key].forEach((val, idx) => {
+ if (idx === 0) {
+ searchParams.set(key, val);
+ } else {
+ searchParams.append(key, val);
+ }
+ });
+ } else {
+ searchParams.set(key, params[key]);
+ }
+ });
+
+ urlObj.search = searchParams.toString();
+
+ return urlObj.toString();
+};
+
+export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/');
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 465c9a362ba..674415c9d01 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -222,6 +222,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
+ // eslint-disable-next-line no-jquery/no-ajax-events
$(document).ajaxError((e, xhrObj) => {
const ref = xhrObj.status;
diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
index 8eeac737a11..1df7ca37a98 100644
--- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue
+++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
@@ -29,7 +29,7 @@ const AREA_COLOR_RGBA = `rgba(${hexToRgb(AREA_COLOR).join(',')},${AREA_OPACITY})
* time series chart, the boundary band shows the normal
* range of values the metric should take.
*
- * This component accepts 3 queries, which contain the
+ * This component accepts 3 metrics, which contain the
* "metric", "upper" limit and "lower" limit.
*
* The upper and lower series are "stacked areas" visually
@@ -62,10 +62,11 @@ export default {
},
computed: {
series() {
- return this.graphData.queries.map(query => {
- const values = query.result[0] ? query.result[0].values : [];
+ return this.graphData.metrics.map(metric => {
+ const values = metric.result && metric.result[0] ? metric.result[0].values : [];
return {
- label: query.label,
+ label: metric.label,
+ // NaN values may disrupt avg., max. & min. calculations in the legend, filter them out
data: values.filter(([, value]) => !Number.isNaN(value)),
};
});
@@ -82,7 +83,7 @@ export default {
return min < 0 ? -min : 0;
},
metricData() {
- const originalMetricQuery = this.graphData.queries[0];
+ const originalMetricQuery = this.graphData.metrics[0];
const metricQuery = { ...originalMetricQuery };
metricQuery.result[0].values = metricQuery.result[0].values.map(([x, y]) => [
@@ -92,7 +93,7 @@ export default {
return {
...this.graphData,
type: 'line-chart',
- queries: [metricQuery],
+ metrics: [metricQuery],
};
},
metricSeriesConfig() {
diff --git a/app/assets/javascripts/monitoring/components/charts/column.vue b/app/assets/javascripts/monitoring/components/charts/column.vue
index ee6aaeb7dde..eb407ad1d7f 100644
--- a/app/assets/javascripts/monitoring/components/charts/column.vue
+++ b/app/assets/javascripts/monitoring/components/charts/column.vue
@@ -32,8 +32,8 @@ export default {
},
computed: {
chartData() {
- const queryData = this.graphData.queries.reduce((acc, query) => {
- const series = makeDataSeries(query.result, {
+ const queryData = this.graphData.metrics.reduce((acc, query) => {
+ const series = makeDataSeries(query.result || [], {
name: this.formatLegendLabel(query),
});
@@ -45,13 +45,13 @@ export default {
};
},
xAxisTitle() {
- return this.graphData.queries[0].result[0].x_label !== undefined
- ? this.graphData.queries[0].result[0].x_label
+ return this.graphData.metrics[0].result[0].x_label !== undefined
+ ? this.graphData.metrics[0].result[0].x_label
: '';
},
yAxisTitle() {
- return this.graphData.queries[0].result[0].y_label !== undefined
- ? this.graphData.queries[0].result[0].y_label
+ return this.graphData.metrics[0].result[0].y_label !== undefined
+ ? this.graphData.metrics[0].result[0].y_label
: '';
},
xAxisType() {
diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue
index b8158247e49..6ab5aaeba1d 100644
--- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue
+++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue
@@ -24,7 +24,7 @@ export default {
},
computed: {
chartData() {
- return this.queries.result.reduce(
+ return this.metrics.result.reduce(
(acc, result, i) => [...acc, ...result.values.map((value, j) => [i, j, value[1]])],
[],
);
@@ -36,7 +36,7 @@ export default {
return this.graphData.y_label || '';
},
xAxisLabels() {
- return this.queries.result.map(res => Object.values(res.metric)[0]);
+ return this.metrics.result.map(res => Object.values(res.metric)[0]);
},
yAxisLabels() {
return this.result.values.map(val => {
@@ -46,10 +46,10 @@ export default {
});
},
result() {
- return this.queries.result[0];
+ return this.metrics.result[0];
},
- queries() {
- return this.graphData.queries[0];
+ metrics() {
+ return this.graphData.metrics[0];
},
},
};
diff --git a/app/assets/javascripts/monitoring/components/charts/single_stat.vue b/app/assets/javascripts/monitoring/components/charts/single_stat.vue
index 076682820e6..e75ddb05808 100644
--- a/app/assets/javascripts/monitoring/components/charts/single_stat.vue
+++ b/app/assets/javascripts/monitoring/components/charts/single_stat.vue
@@ -17,7 +17,7 @@ export default {
},
computed: {
queryInfo() {
- return this.graphData.queries[0];
+ return this.graphData.metrics[0];
},
engineeringNotation() {
return `${roundOffFloat(this.queryInfo.result[0].value[1], 1)}${this.queryInfo.unit}`;
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
index 6a88c8a5ee3..0d442f14aea 100644
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -1,9 +1,9 @@
<script>
-import { s__, __ } from '~/locale';
import _ from 'underscore';
import { GlLink, GlButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/ui';
import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
+import { s__, __ } from '~/locale';
import { roundOffFloat } from '~/lib/utils/common_utils';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import Icon from '~/vue_shared/components/icon.vue';
@@ -105,7 +105,7 @@ export default {
// Transforms & supplements query data to render appropriate labels & styles
// Input: [{ queryAttributes1 }, { queryAttributes2 }]
// Output: [{ seriesAttributes1 }, { seriesAttributes2 }]
- return this.graphData.queries.reduce((acc, query) => {
+ return this.graphData.metrics.reduce((acc, query) => {
const { appearance } = query;
const lineType =
appearance && appearance.line && appearance.line.type
@@ -121,7 +121,7 @@ export default {
? appearance.area.opacity
: undefined,
};
- const series = makeDataSeries(query.result, {
+ const series = makeDataSeries(query.result || [], {
name: this.formatLegendLabel(query),
lineStyle: {
type: lineType,
@@ -253,23 +253,25 @@ export default {
this.tooltip.title = dateFormat(params.value, dateFormats.default);
this.tooltip.content = [];
params.seriesData.forEach(dataPoint => {
- const [xVal, yVal] = dataPoint.value;
- this.tooltip.isDeployment = dataPoint.componentSubType === graphTypes.deploymentData;
- if (this.tooltip.isDeployment) {
- const [deploy] = this.recentDeployments.filter(
- deployment => deployment.createdAt === xVal,
- );
- this.tooltip.sha = deploy.sha.substring(0, 8);
- this.tooltip.commitUrl = deploy.commitUrl;
- } else {
- const { seriesName, color, dataIndex } = dataPoint;
- const value = yVal.toFixed(3);
- this.tooltip.content.push({
- name: seriesName,
- dataIndex,
- value,
- color,
- });
+ if (dataPoint.value) {
+ const [xVal, yVal] = dataPoint.value;
+ this.tooltip.isDeployment = dataPoint.componentSubType === graphTypes.deploymentData;
+ if (this.tooltip.isDeployment) {
+ const [deploy] = this.recentDeployments.filter(
+ deployment => deployment.createdAt === xVal,
+ );
+ this.tooltip.sha = deploy.sha.substring(0, 8);
+ this.tooltip.commitUrl = deploy.commitUrl;
+ } else {
+ const { seriesName, color, dataIndex } = dataPoint;
+ const value = yVal.toFixed(3);
+ this.tooltip.content.push({
+ name: seriesName,
+ dataIndex,
+ value,
+ color,
+ });
+ }
}
});
},
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 26e2c2568c1..c1ca5449ba3 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,6 +1,6 @@
<script>
import _ from 'underscore';
-import { mapActions, mapState } from 'vuex';
+import { mapActions, mapState, mapGetters } from 'vuex';
import VueDraggable from 'vuedraggable';
import {
GlButton,
@@ -11,28 +11,27 @@ import {
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 invalidUrl from '~/lib/utils/invalid_url';
-import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
import DateTimePicker from './date_time_picker/date_time_picker.vue';
-import MonitorTimeSeriesChart from './charts/time_series.vue';
-import MonitorSingleStatChart from './charts/single_stat.vue';
import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue';
+import GroupEmptyState from './group_empty_state.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
-import { getTimeDiff, isValidDate, downloadCSVOptions, generateLinkToChartOptions } from '../utils';
+import { getTimeDiff, isValidDate, getAddMetricTrackingOptions } from '../utils';
+import { metricStates } from '../constants';
export default {
components: {
VueDraggable,
- MonitorTimeSeriesChart,
- MonitorSingleStatChart,
PanelType,
GraphGroup,
EmptyState,
+ GroupEmptyState,
Icon,
GlButton,
GlDropdown,
@@ -103,6 +102,10 @@ export default {
type: String,
required: true,
},
+ emptyNoDataSmallSvgPath: {
+ type: String,
+ required: true,
+ },
emptyUnableToConnectSvgPath: {
type: String,
required: true,
@@ -180,11 +183,11 @@ export default {
'showEmptyState',
'environments',
'deploymentData',
- 'metricsWithData',
'useDashboardEndpoint',
'allDashboards',
'additionalPanelTypesEnabled',
]),
+ ...mapGetters('monitoringDashboard', ['getMetricStates']),
firstDashboard() {
return this.environmentsEndpoint.length > 0 && this.allDashboards.length > 0
? this.allDashboards[0]
@@ -252,28 +255,18 @@ export default {
'setEndpoints',
'setPanelGroupMetrics',
]),
- chartsWithData(charts) {
- return charts.filter(chart =>
- chart.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)),
- );
- },
- updateMetrics(key, metrics) {
+ updatePanels(key, panels) {
this.setPanelGroupMetrics({
- metrics,
+ panels,
key,
});
},
- removeMetric(key, metrics, graphIndex) {
+ removePanel(key, panels, graphIndex) {
this.setPanelGroupMetrics({
- metrics: metrics.filter((v, i) => i !== graphIndex),
+ panels: panels.filter((v, i) => i !== graphIndex),
key,
});
},
- removeGraph(metrics, graphIndex) {
- // At present graphs will not be removed, they should removed using the vuex store
- // See https://gitlab.com/gitlab-org/gitlab/issues/27835
- metrics.splice(graphIndex, 1);
- },
showInvalidDateError() {
createFlash(s__('Metrics|Link contains an invalid time window.'));
},
@@ -294,14 +287,36 @@ export default {
submitCustomMetricsForm() {
this.$refs.customMetricsForm.submit();
},
- groupHasData(group) {
- return this.chartsWithData(group.metrics).length > 0;
- },
onDateTimePickerApply(timeWindowUrlParams) {
return redirectTo(mergeUrlParams(timeWindowUrlParams, window.location.href));
},
- downloadCSVOptions,
- generateLinkToChartOptions,
+ /**
+ * Return a single empty state for a group.
+ *
+ * If all states are the same a single state is returned to be displayed
+ * Except if the state is OK, in which case the group is displayed.
+ *
+ * @param {String} groupKey - Identifier for group
+ * @returns {String} state code from `metricStates`
+ */
+ groupSingleEmptyState(groupKey) {
+ const states = this.getMetricStates(groupKey);
+ if (states.length === 1 && states[0] !== metricStates.OK) {
+ return states[0];
+ }
+ return null;
+ },
+ /**
+ * A group should be not collapsed if any metric is loaded (OK)
+ *
+ * @param {String} groupKey - Identifier for group
+ * @returns {Boolean} If the group should be collapsed
+ */
+ collapseGroup(groupKey) {
+ // Collapse group if no data is available
+ return !this.getMetricStates(groupKey).includes(metricStates.OK);
+ },
+ getAddMetricTrackingOptions,
},
addMetric: {
title: s__('Metrics|Add metric'),
@@ -393,9 +408,10 @@ export default {
</gl-button>
<gl-button
v-if="addingMetricsAvailable"
+ ref="addMetricBtn"
v-gl-modal="$options.addMetric.modalId"
variant="outline-success"
- class="mr-2 mt-1 js-add-metric-button"
+ class="mr-2 mt-1"
>
{{ $options.addMetric.title }}
</gl-button>
@@ -415,6 +431,8 @@ export default {
<div slot="modal-footer">
<gl-button @click="hideAddMetricModal">{{ __('Cancel') }}</gl-button>
<gl-button
+ ref="submitCustomMetricsFormBtn"
+ v-track-event="getAddMetricTrackingOptions()"
:disabled="!formIsValid"
variant="success"
@click="submitCustomMetricsForm"
@@ -454,42 +472,55 @@ export default {
:key="`${groupData.group}.${groupData.priority}`"
:name="groupData.group"
:show-panels="showPanels"
- :collapse-group="groupHasData(groupData)"
+ :collapse-group="collapseGroup(groupData.key)"
>
- <vue-draggable
- :value="groupData.metrics"
- group="metrics-dashboard"
- :component-data="{ attrs: { class: 'row mx-0 w-100' } }"
- :disabled="!isRearrangingPanels"
- @input="updateMetrics(groupData.key, $event)"
- >
- <div
- v-for="(graphData, graphIndex) in groupData.metrics"
- :key="`panel-type-${graphIndex}`"
- class="col-12 col-lg-6 px-2 mb-2 draggable"
- :class="{ 'draggable-enabled': isRearrangingPanels }"
+ <div 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)"
>
- <div class="position-relative draggable-panel js-draggable-panel">
- <div
- v-if="isRearrangingPanels"
- class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end"
- @click="removeGraph(groupData.metrics, graphIndex)"
- >
- <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')"
- ><icon name="close"
- /></a>
- </div>
+ <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}`"
- />
+ <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>
</div>
- </div>
- </vue-draggable>
+ </vue-draggable>
+ </div>
+ <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"
+ :documentation-path="documentationPath"
+ :settings-path="settingsPath"
+ :selected-state="groupSingleEmptyState(groupData.key)"
+ :svg-path="emptyNoDataSmallSvgPath"
+ />
+ </div>
</graph-group>
</div>
<empty-state
@@ -501,6 +532,7 @@ export default {
:empty-getting-started-svg-path="emptyGettingStartedSvgPath"
:empty-loading-svg-path="emptyLoadingSvgPath"
:empty-no-data-svg-path="emptyNoDataSvgPath"
+ :empty-no-data-small-svg-path="emptyNoDataSmallSvgPath"
:empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath"
:compact="smallEmptyState"
/>
diff --git a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue
index 0388a6190d9..c3beae18726 100644
--- a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue
+++ b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker_input.vue
@@ -1,7 +1,7 @@
<script>
import _ from 'underscore';
-import { s__, sprintf } from '~/locale';
import { GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
import { dateFormats } from '~/monitoring/constants';
const inputGroupText = {
diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue
index f75839c7c6b..eb8945c1a57 100644
--- a/app/assets/javascripts/monitoring/components/embed.vue
+++ b/app/assets/javascripts/monitoring/components/embed.vue
@@ -1,8 +1,8 @@
<script>
-import { mapActions, mapState } from 'vuex';
+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 GraphGroup from './graph_group.vue';
-import MonitorTimeSeriesChart from './charts/time_series.vue';
import { sidebarAnimationDuration } from '../constants';
import { getTimeDiff } from '../utils';
@@ -11,7 +11,7 @@ let sidebarMutationObserver;
export default {
components: {
GraphGroup,
- MonitorTimeSeriesChart,
+ PanelType,
},
props: {
dashboardUrl: {
@@ -35,13 +35,17 @@ export default {
};
},
computed: {
- ...mapState('monitoringDashboard', ['dashboard', 'metricsWithData']),
+ ...mapState('monitoringDashboard', ['dashboard']),
+ ...mapGetters('monitoringDashboard', ['metricsWithData']),
charts() {
+ if (!this.dashboard || !this.dashboard.panel_groups) {
+ return [];
+ }
const groupWithMetrics = this.dashboard.panel_groups.find(group =>
- group.metrics.find(chart => this.chartHasData(chart)),
- ) || { metrics: [] };
+ group.panels.find(chart => this.chartHasData(chart)),
+ ) || { panels: [] };
- return groupWithMetrics.metrics.filter(chart => this.chartHasData(chart));
+ return groupWithMetrics.panels.filter(chart => this.chartHasData(chart));
},
isSingleChart() {
return this.charts.length === 1;
@@ -70,7 +74,7 @@ export default {
'setShowErrorBanner',
]),
chartHasData(chart) {
- return chart.metrics.some(metric => this.metricsWithData.includes(metric.metric_id));
+ return chart.metrics.some(metric => this.metricsWithData().includes(metric.metric_id));
},
onSidebarMutation() {
setTimeout(() => {
@@ -89,16 +93,12 @@ export default {
<template>
<div class="metrics-embed" :class="{ 'd-inline-flex col-lg-6 p-0': isSingleChart }">
<div v-if="charts.length" class="row w-100 m-n2 pb-4">
- <monitor-time-series-chart
- v-for="graphData in charts"
- :key="graphData.title"
+ <panel-type
+ v-for="(graphData, graphIndex) in charts"
+ :key="`panel-type-${graphIndex}`"
class="w-100"
:graph-data="graphData"
- :container-width="elWidth"
:group-id="dashboardUrl"
- :project-path="null"
- :show-border="true"
- :single-embed="isSingleChart"
/>
</div>
</div>
diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue
index 1bb40447a3e..d3157b731b2 100644
--- a/app/assets/javascripts/monitoring/components/empty_state.vue
+++ b/app/assets/javascripts/monitoring/components/empty_state.vue
@@ -1,6 +1,6 @@
<script>
-import { __ } from '~/locale';
import { GlEmptyState } from '@gitlab/ui';
+import { __ } from '~/locale';
export default {
components: {
@@ -37,6 +37,10 @@ export default {
type: String,
required: true,
},
+ emptyNoDataSmallSvgPath: {
+ type: String,
+ required: true,
+ },
emptyUnableToConnectSvgPath: {
type: String,
required: true,
diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue
index 3cb6ccb64b1..5a7981b6534 100644
--- a/app/assets/javascripts/monitoring/components/graph_group.vue
+++ b/app/assets/javascripts/monitoring/components/graph_group.vue
@@ -15,34 +15,44 @@ export default {
required: false,
default: true,
},
+ /**
+ * Initial value of collapse on mount.
+ */
collapseGroup: {
type: Boolean,
- required: true,
+ required: false,
+ default: false,
},
},
data() {
return {
- showGroup: true,
+ isCollapsed: this.collapseGroup,
};
},
computed: {
caretIcon() {
- return this.collapseGroup && this.showGroup ? 'angle-down' : 'angle-right';
+ return this.isCollapsed ? 'angle-right' : 'angle-down';
},
},
- created() {
- this.showGroup = this.collapseGroup;
+ watch: {
+ collapseGroup(val) {
+ // Respond to changes in collapseGroup but do not
+ // collapse it once was opened by the user.
+ if (this.showPanels && !val) {
+ this.isCollapsed = false;
+ }
+ },
},
methods: {
collapse() {
- this.showGroup = !this.showGroup;
+ this.isCollapsed = !this.isCollapsed;
},
},
};
</script>
<template>
- <div v-if="showPanels" class="card prometheus-panel">
+ <div v-if="showPanels" ref="graph-group" class="card prometheus-panel">
<div class="card-header d-flex align-items-center">
<h4 class="flex-grow-1">{{ name }}</h4>
<a role="button" class="js-graph-group-toggle" @click="collapse">
@@ -50,12 +60,12 @@ export default {
</a>
</div>
<div
- v-if="collapseGroup"
- v-show="collapseGroup && showGroup"
+ v-show="!isCollapsed"
+ ref="graph-group-content"
class="card-body prometheus-graph-group p-0"
>
<slot></slot>
</div>
</div>
- <div v-else class="prometheus-graph-group"><slot></slot></div>
+ <div v-else ref="graph-group-content" class="prometheus-graph-group"><slot></slot></div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/group_empty_state.vue b/app/assets/javascripts/monitoring/components/group_empty_state.vue
new file mode 100644
index 00000000000..dee4e5998ee
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/group_empty_state.vue
@@ -0,0 +1,105 @@
+<script>
+import { __, sprintf } from '~/locale';
+import { GlEmptyState } from '@gitlab/ui';
+import { metricStates } from '../constants';
+
+export default {
+ components: {
+ GlEmptyState,
+ },
+ props: {
+ documentationPath: {
+ type: String,
+ required: true,
+ },
+ settingsPath: {
+ type: String,
+ required: true,
+ },
+ selectedState: {
+ type: String,
+ required: true,
+ },
+ svgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ const documentationLink = `<a href="${this.documentationPath}">${__('More information')}</a>`;
+ return {
+ states: {
+ [metricStates.NO_DATA]: {
+ title: __('No data to display'),
+ slottedDescription: sprintf(
+ __(
+ 'The data source is connected, but there is no data to display. %{documentationLink}',
+ ),
+ { documentationLink },
+ false,
+ ),
+ },
+ [metricStates.TIMEOUT]: {
+ title: __('Connection timed out'),
+ slottedDescription: sprintf(
+ __(
+ "Charts can't be displayed as the request for data has timed out. %{documentationLink}",
+ ),
+ { documentationLink },
+ false,
+ ),
+ },
+ [metricStates.CONNECTION_FAILED]: {
+ title: __('Connection failed'),
+ description: __(`We couldn't reach the Prometheus server.
+ Either the server no longer exists or the configuration details need updating.`),
+ buttonText: __('Verify configuration'),
+ buttonPath: this.settingsPath,
+ },
+ [metricStates.BAD_QUERY]: {
+ title: __('Query cannot be processed'),
+ slottedDescription: sprintf(
+ __(
+ `The Prometheus server responded with "bad request".
+ Please check your queries are correct and are supported in your Prometheus version. %{documentationLink}`,
+ ),
+ { documentationLink },
+ false,
+ ),
+ buttonText: __('Verify configuration'),
+ buttonPath: this.settingsPath,
+ },
+ [metricStates.LOADING]: {
+ title: __('Waiting for performance data'),
+ description: __(`Creating graphs uses the data from the Prometheus server.
+ If this takes a long time, ensure that data is available.`),
+ },
+ [metricStates.UNKNOWN_ERROR]: {
+ title: __('An error has occurred'),
+ description: __('An error occurred while loading the data. Please try again.'),
+ },
+ },
+ };
+ },
+ computed: {
+ currentState() {
+ return this.states[this.selectedState] || this.states[metricStates.UNKNOWN_ERROR];
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-empty-state
+ :title="currentState.title"
+ :primary-button-text="currentState.buttonText"
+ :primary-button-link="currentState.buttonPath"
+ :description="currentState.description"
+ :svg-path="svgPath"
+ :compact="true"
+ >
+ <template v-if="currentState.slottedDescription" #description>
+ <div v-html="currentState.slottedDescription"></div>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue
index cafb4b0b479..ec6a41d0540 100644
--- a/app/assets/javascripts/monitoring/components/panel_type.vue
+++ b/app/assets/javascripts/monitoring/components/panel_type.vue
@@ -1,7 +1,6 @@
<script>
import { mapState } from 'vuex';
import _ from 'underscore';
-import { __ } from '~/locale';
import {
GlDropdown,
GlDropdownItem,
@@ -9,6 +8,7 @@ import {
GlModalDirective,
GlTooltipDirective,
} from '@gitlab/ui';
+import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import MonitorTimeSeriesChart from './charts/time_series.vue';
import MonitorAnomalyChart from './charts/anomaly.vue';
@@ -36,7 +36,8 @@ export default {
props: {
clipboardText: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
graphData: {
type: Object,
@@ -47,6 +48,11 @@ export default {
required: false,
default: '',
},
+ groupId: {
+ type: String,
+ required: false,
+ default: 'panel-type-chart',
+ },
},
computed: {
...mapState('monitoringDashboard', ['deploymentData', 'projectPath']),
@@ -54,10 +60,14 @@ export default {
return IS_EE && this.prometheusAlertsAvailable && this.alertsEndpoint && this.graphData;
},
graphDataHasMetrics() {
- return this.graphData.queries[0].result.length > 0;
+ return (
+ this.graphData.metrics &&
+ this.graphData.metrics[0].result &&
+ this.graphData.metrics[0].result.length > 0
+ );
},
csvText() {
- const chartData = this.graphData.queries[0].result[0].values;
+ const chartData = this.graphData.metrics[0].result[0].values;
const yLabel = this.graphData.y_label;
const header = `timestamp,${yLabel}\r\n`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings
return chartData.reduce((csv, data) => {
@@ -112,21 +122,21 @@ export default {
:graph-data="graphData"
:deployment-data="deploymentData"
:project-path="projectPath"
- :thresholds="getGraphAlertValues(graphData.queries)"
- group-id="panel-type-chart"
+ :thresholds="getGraphAlertValues(graphData.metrics)"
+ :group-id="groupId"
>
<div class="d-flex align-items-center">
<alert-widget
v-if="alertWidgetAvailable && graphData"
:modal-id="`alert-modal-${index}`"
:alerts-endpoint="alertsEndpoint"
- :relevant-queries="graphData.queries"
- :alerts-to-manage="getGraphAlerts(graphData.queries)"
+ :relevant-queries="graphData.metrics"
+ :alerts-to-manage="getGraphAlerts(graphData.metrics)"
@setAlerts="setAlerts"
/>
<gl-dropdown
v-gl-tooltip
- class="mx-2"
+ class="ml-auto mx-3"
toggle-class="btn btn-transparent border-0"
:right="true"
:no-caret="true"
@@ -143,6 +153,7 @@ export default {
{{ __('Download CSV') }}
</gl-dropdown-item>
<gl-dropdown-item
+ v-if="clipboardText"
v-track-event="generateLinkToChartOptions(clipboardText)"
class="js-chart-link"
:data-clipboard-text="clipboardText"
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index 1a1fcdd0e66..398b45b9012 100644
--- a/app/assets/javascripts/monitoring/constants.js
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -1,5 +1,52 @@
import { __ } from '~/locale';
+export const PROMETHEUS_TIMEOUT = 120000; // TWO_MINUTES
+
+/**
+ * States and error states in Prometheus Queries (PromQL) for metrics
+ */
+export const metricStates = {
+ /**
+ * Metric data is available
+ */
+ OK: 'OK',
+
+ /**
+ * Metric data is being fetched
+ */
+ LOADING: 'LOADING',
+
+ /**
+ * Connection timed out to prometheus server
+ * the timeout is set to PROMETHEUS_TIMEOUT
+ *
+ */
+ TIMEOUT: 'TIMEOUT',
+
+ /**
+ * The prometheus server replies with an empty data set
+ */
+ NO_DATA: 'NO_DATA',
+
+ /**
+ * The prometheus server cannot be reached
+ */
+ CONNECTION_FAILED: 'CONNECTION_FAILED',
+
+ /**
+ * The prometheus server was reached but it cannot process
+ * the query. This can happen for several reasons:
+ * - PromQL syntax is incorrect
+ * - An operator is not supported
+ */
+ BAD_QUERY: 'BAD_QUERY',
+
+ /**
+ * No specific reason found for error
+ */
+ UNKNOWN_ERROR: 'UNKNOWN_ERROR',
+};
+
export const sidebarAnimationDuration = 300; // milliseconds.
export const chartHeight = 300;
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js
index a14145d480b..d296f5b7a66 100644
--- a/app/assets/javascripts/monitoring/monitoring_bundle.js
+++ b/app/assets/javascripts/monitoring/monitoring_bundle.js
@@ -1,8 +1,8 @@
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
+import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import { getParameterValues } from '~/lib/utils/url_utility';
-import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue';
import store from './stores';
Vue.use(GlToast);
diff --git a/app/assets/javascripts/monitoring/monitoring_tracking_helper.js b/app/assets/javascripts/monitoring/monitoring_tracking_helper.js
new file mode 100644
index 00000000000..5ae1eca10de
--- /dev/null
+++ b/app/assets/javascripts/monitoring/monitoring_tracking_helper.js
@@ -0,0 +1,10 @@
+import Tracking from '~/tracking';
+
+const trackDashboardLoad = ({ label, value }) =>
+ Tracking.event(document.body.dataset.page, 'dashboard_fetch', {
+ label,
+ property: 'count',
+ value,
+ });
+
+export default trackDashboardLoad;
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 6a8e3cc82f5..1cb82ce0083 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -1,30 +1,25 @@
import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
+import trackDashboardLoad from '../monitoring_tracking_helper';
import statusCodes from '../../lib/utils/http_status';
import { backOff } from '../../lib/utils/common_utils';
-import { s__, __ } from '../../locale';
+import { s__, sprintf } from '../../locale';
-const MAX_REQUESTS = 3;
+import { PROMETHEUS_TIMEOUT } from '../constants';
-export function backOffRequest(makeRequestCallback) {
- let requestCounter = 0;
+function backOffRequest(makeRequestCallback) {
return backOff((next, stop) => {
makeRequestCallback()
.then(resp => {
if (resp.status === statusCodes.NO_CONTENT) {
- requestCounter += 1;
- if (requestCounter < MAX_REQUESTS) {
- next();
- } else {
- stop(new Error(__('Failed to connect to the prometheus server')));
- }
+ next();
} else {
stop(resp);
}
})
.catch(stop);
- });
+ }, PROMETHEUS_TIMEOUT);
}
export const setGettingStartedEmptyState = ({ commit }) => {
@@ -45,17 +40,12 @@ 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.panel_groups);
- dispatch('fetchPrometheusMetrics', params);
+ return dispatch('fetchPrometheusMetrics', params);
};
export const receiveMetricsDashboardFailure = ({ commit }, error) => {
commit(types.RECEIVE_METRICS_DATA_FAILURE, error);
};
-export const requestMetricsData = ({ commit }) => commit(types.REQUEST_METRICS_DATA);
-export const receiveMetricsDataSuccess = ({ commit }, data) =>
- commit(types.RECEIVE_METRICS_DATA_SUCCESS, data);
-export const receiveMetricsDataFailure = ({ commit }, error) =>
- commit(types.RECEIVE_METRICS_DATA_FAILURE, error);
export const receiveDeploymentsDataSuccess = ({ commit }, data) =>
commit(types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS, data);
export const receiveDeploymentsDataFailure = ({ commit }) =>
@@ -83,16 +73,22 @@ export const fetchDashboard = ({ state, dispatch }, params) => {
return backOffRequest(() => axios.get(state.dashboardEndpoint, { params }))
.then(resp => resp.data)
- .then(response => {
- dispatch('receiveMetricsDashboardSuccess', {
- response,
- params,
- });
- })
- .catch(error => {
- dispatch('receiveMetricsDashboardFailure', error);
- if (state.setShowErrorBanner) {
- createFlash(s__('Metrics|There was an error while retrieving metrics'));
+ .then(response => dispatch('receiveMetricsDashboardSuccess', { response, params }))
+ .catch(e => {
+ dispatch('receiveMetricsDashboardFailure', e);
+ if (state.showErrorBanner) {
+ if (e.response.data && e.response.data.message) {
+ const { message } = e.response.data;
+ createFlash(
+ sprintf(
+ s__('Metrics|There was an error while retrieving metrics. %{message}'),
+ { message },
+ false,
+ ),
+ );
+ } else {
+ createFlash(s__('Metrics|There was an error while retrieving metrics'));
+ }
}
});
};
@@ -129,12 +125,20 @@ export const fetchPrometheusMetric = ({ commit }, { metric, params }) => {
step,
};
- return fetchPrometheusResult(metric.prometheus_endpoint_path, queryParams).then(result => {
- commit(types.SET_QUERY_RESULT, { metricId: metric.metric_id, result });
- });
+ commit(types.REQUEST_METRIC_RESULT, { metricId: metric.metric_id });
+
+ return fetchPrometheusResult(metric.prometheus_endpoint_path, queryParams)
+ .then(result => {
+ commit(types.RECEIVE_METRIC_RESULT_SUCCESS, { metricId: metric.metric_id, result });
+ })
+ .catch(error => {
+ commit(types.RECEIVE_METRIC_RESULT_FAILURE, { metricId: metric.metric_id, error });
+ // Continue to throw error so the dashboard can notify using createFlash
+ throw error;
+ });
};
-export const fetchPrometheusMetrics = ({ state, commit, dispatch }, params) => {
+export const fetchPrometheusMetrics = ({ state, commit, dispatch, getters }, params) => {
commit(types.REQUEST_METRICS_DATA);
const promises = [];
@@ -146,18 +150,25 @@ export const fetchPrometheusMetrics = ({ state, commit, dispatch }, params) => {
});
});
- return Promise.all(promises).then(() => {
- if (state.metricsWithData.length === 0) {
- commit(types.SET_NO_DATA_EMPTY_STATE);
- }
- });
+ return Promise.all(promises)
+ .then(() => {
+ const dashboardType = state.currentDashboard === '' ? 'default' : 'custom';
+ trackDashboardLoad({
+ label: `${dashboardType}_metrics_dashboard`,
+ value: getters.metricsWithData().length,
+ });
+ })
+ .catch(() => {
+ createFlash(s__(`Metrics|There was an error while retrieving metrics`), 'warning');
+ });
};
export const fetchDeploymentsData = ({ state, dispatch }) => {
if (!state.deploymentsEndpoint) {
return Promise.resolve([]);
}
- return backOffRequest(() => axios.get(state.deploymentsEndpoint))
+ return axios
+ .get(state.deploymentsEndpoint)
.then(resp => resp.data)
.then(response => {
if (!response || !response.deployments) {
diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js
new file mode 100644
index 00000000000..a13157c6f87
--- /dev/null
+++ b/app/assets/javascripts/monitoring/stores/getters.js
@@ -0,0 +1,62 @@
+const metricsIdsInPanel = panel =>
+ panel.metrics.filter(metric => metric.metricId && metric.result).map(metric => metric.metricId);
+
+/**
+ * Get all state for metric in the dashboard or a group. The
+ * states are not repeated so the dashboard or group can show
+ * a global state.
+ *
+ * @param {Object} state
+ * @returns {Function} A function that returns an array of
+ * states in all the metric in the dashboard or group.
+ */
+export const getMetricStates = state => groupKey => {
+ let groups = state.dashboard.panel_groups;
+ if (groupKey) {
+ groups = groups.filter(group => group.key === groupKey);
+ }
+
+ const metricStates = groups.reduce((acc, group) => {
+ group.panels.forEach(panel => {
+ panel.metrics.forEach(metric => {
+ if (metric.state) {
+ acc.push(metric.state);
+ }
+ });
+ });
+ return acc;
+ }, []);
+
+ // Deduplicate and sort array
+ return Array.from(new Set(metricStates)).sort();
+};
+
+/**
+ * Getter to obtain the list of metric ids that have data
+ *
+ * Useful to understand which parts of the dashboard should
+ * be displayed. It is a Vuex Method-Style Access getter.
+ *
+ * @param {Object} state
+ * @returns {Function} A function that returns an array of
+ * metrics in the dashboard that contain results, optionally
+ * filtered by group key.
+ */
+export const metricsWithData = state => groupKey => {
+ let groups = state.dashboard.panel_groups;
+ if (groupKey) {
+ groups = groups.filter(group => group.key === groupKey);
+ }
+
+ const res = [];
+ groups.forEach(group => {
+ group.panels.forEach(panel => {
+ res.push(...metricsIdsInPanel(panel));
+ });
+ });
+
+ return res;
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/monitoring/stores/index.js b/app/assets/javascripts/monitoring/stores/index.js
index d58398c54ae..c1c466b7cf0 100644
--- a/app/assets/javascripts/monitoring/stores/index.js
+++ b/app/assets/javascripts/monitoring/stores/index.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
+import * as getters from './getters';
import mutations from './mutations';
import state from './state';
@@ -12,6 +13,7 @@ export const createStore = () =>
monitoringDashboard: {
namespaced: true,
actions,
+ getters,
mutations,
state,
},
diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js
index fa15a2ba800..74068e1d846 100644
--- a/app/assets/javascripts/monitoring/stores/mutation_types.js
+++ b/app/assets/javascripts/monitoring/stores/mutation_types.js
@@ -1,13 +1,19 @@
export const REQUEST_METRICS_DATA = 'REQUEST_METRICS_DATA';
export const RECEIVE_METRICS_DATA_SUCCESS = 'RECEIVE_METRICS_DATA_SUCCESS';
export const RECEIVE_METRICS_DATA_FAILURE = 'RECEIVE_METRICS_DATA_FAILURE';
+
export const REQUEST_DEPLOYMENTS_DATA = 'REQUEST_DEPLOYMENTS_DATA';
export const RECEIVE_DEPLOYMENTS_DATA_SUCCESS = 'RECEIVE_DEPLOYMENTS_DATA_SUCCESS';
export const RECEIVE_DEPLOYMENTS_DATA_FAILURE = 'RECEIVE_DEPLOYMENTS_DATA_FAILURE';
+
export const REQUEST_ENVIRONMENTS_DATA = 'REQUEST_ENVIRONMENTS_DATA';
export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCCESS';
export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAILURE';
-export const SET_QUERY_RESULT = 'SET_QUERY_RESULT';
+
+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_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS';
export const SET_ENDPOINTS = 'SET_ENDPOINTS';
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
index 696af5aed75..16a34a6c026 100644
--- a/app/assets/javascripts/monitoring/stores/mutations.js
+++ b/app/assets/javascripts/monitoring/stores/mutations.js
@@ -1,11 +1,85 @@
import Vue from 'vue';
import { slugify } from '~/lib/utils/text_utility';
import * as types from './mutation_types';
-import { normalizeMetrics, normalizeMetric, normalizeQueryResult } from './utils';
+import { normalizeMetric, normalizeQueryResult } from './utils';
+import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils';
+import { metricStates } from '../constants';
+import httpStatusCodes from '~/lib/utils/http_status';
-const normalizePanel = panel => panel.metrics.map(normalizeMetric);
+const normalizePanelMetrics = (metrics, defaultLabel) =>
+ metrics.map(metric => ({
+ ...normalizeMetric(metric),
+ label: metric.label || defaultLabel,
+ }));
+
+/**
+ * Locate and return a metric in the dashboard by its id
+ * as generated by `uniqMetricsId()`.
+ * @param {String} metricId Unique id in the dashboard
+ * @param {Object} dashboard Full dashboard object
+ */
+const findMetricInDashboard = (metricId, dashboard) => {
+ let res = null;
+ dashboard.panel_groups.forEach(group => {
+ group.panels.forEach(panel => {
+ panel.metrics.forEach(metric => {
+ if (metric.metric_id === metricId) {
+ res = metric;
+ }
+ });
+ });
+ });
+ return res;
+};
+
+/**
+ * Set a new state for a metric.
+ *
+ * Initally metric data is not populated, so `Vue.set` is
+ * used to add new properties to the metric.
+ *
+ * @param {Object} metric - Metric object as defined in the dashboard
+ * @param {Object} state - New state
+ * @param {Array|null} state.result - Array of results
+ * @param {String} state.error - Error code from metricStates
+ * @param {Boolean} state.loading - True if the metric is loading
+ */
+const setMetricState = (metric, { result = null, loading = false, state = null }) => {
+ Vue.set(metric, 'result', result);
+ Vue.set(metric, 'loading', loading);
+ Vue.set(metric, 'state', state);
+};
+
+/**
+ * Maps a backened error state to a `metricStates` constant
+ * @param {Object} error - Error from backend response
+ */
+const emptyStateFromError = error => {
+ if (!error) {
+ return metricStates.UNKNOWN_ERROR;
+ }
+
+ // Special error responses
+ if (error.message === BACKOFF_TIMEOUT) {
+ return metricStates.TIMEOUT;
+ }
+
+ // Axios error responses
+ const { response } = error;
+ if (response && response.status === httpStatusCodes.SERVICE_UNAVAILABLE) {
+ return metricStates.CONNECTION_FAILED;
+ } else if (response && response.status === httpStatusCodes.BAD_REQUEST) {
+ // Note: "error.response.data.error" may contain Prometheus error information
+ return metricStates.BAD_QUERY;
+ }
+
+ return metricStates.UNKNOWN_ERROR;
+};
export default {
+ /**
+ * Dashboard panels structure and global state
+ */
[types.REQUEST_METRICS_DATA](state) {
state.emptyState = 'loading';
state.showEmptyState = true;
@@ -13,28 +87,18 @@ export default {
[types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) {
state.dashboard.panel_groups = groupData.map((group, i) => {
const key = `${slugify(group.group || 'default')}-${i}`;
- let { metrics = [], panels = [] } = group;
+ let { panels = [] } = group;
// each panel has metric information that needs to be normalized
-
panels = panels.map(panel => ({
...panel,
- metrics: normalizePanel(panel),
- }));
-
- // for backwards compatibility, and to limit Vue template changes:
- // for each group alias panels to metrics
- // for each panel alias metrics to queries
- metrics = panels.map(panel => ({
- ...panel,
- queries: panel.metrics,
+ metrics: normalizePanelMetrics(panel.metrics, panel.y_label),
}));
return {
...group,
panels,
key,
- metrics: normalizeMetrics(metrics),
};
});
@@ -46,6 +110,10 @@ export default {
state.emptyState = error ? 'unableToConnect' : 'noData';
state.showEmptyState = true;
},
+
+ /**
+ * Deployments and environments
+ */
[types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS](state, deployments) {
state.deploymentData = deployments;
},
@@ -58,26 +126,47 @@ export default {
[types.RECEIVE_ENVIRONMENTS_DATA_FAILURE](state) {
state.environments = [];
},
- [types.SET_QUERY_RESULT](state, { metricId, result }) {
- if (!metricId || !result || result.length === 0) {
+
+ /**
+ * Individual panel/metric results
+ */
+ [types.REQUEST_METRIC_RESULT](state, { metricId }) {
+ const metric = findMetricInDashboard(metricId, state.dashboard);
+ setMetricState(metric, {
+ loading: true,
+ state: metricStates.LOADING,
+ });
+ },
+ [types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, result }) {
+ if (!metricId) {
return;
}
state.showEmptyState = false;
- state.dashboard.panel_groups.forEach(group => {
- group.metrics.forEach(metric => {
- metric.queries.forEach(query => {
- if (query.metric_id === metricId) {
- state.metricsWithData.push(metricId);
- // ensure dates/numbers are correctly formatted for charts
- const normalizedResults = result.map(normalizeQueryResult);
- Vue.set(query, 'result', Object.freeze(normalizedResults));
- }
- });
+ const metric = findMetricInDashboard(metricId, state.dashboard);
+ if (!result || result.length === 0) {
+ setMetricState(metric, {
+ state: metricStates.NO_DATA,
});
+ } else {
+ const normalizedResults = result.map(normalizeQueryResult);
+ setMetricState(metric, {
+ result: Object.freeze(normalizedResults),
+ state: metricStates.OK,
+ });
+ }
+ },
+ [types.RECEIVE_METRIC_RESULT_FAILURE](state, { metricId, error }) {
+ if (!metricId) {
+ return;
+ }
+ const metric = findMetricInDashboard(metricId, state.dashboard);
+ setMetricState(metric, {
+ state: emptyStateFromError(error),
});
},
+
[types.SET_ENDPOINTS](state, endpoints) {
state.metricsEndpoint = endpoints.metricsEndpoint;
state.environmentsEndpoint = endpoints.environmentsEndpoint;
@@ -101,6 +190,6 @@ export default {
},
[types.SET_PANEL_GROUP_METRICS](state, payload) {
const panelGroup = state.dashboard.panel_groups.find(pg => payload.key === pg.key);
- panelGroup.metrics = payload.metrics;
+ panelGroup.panels = payload.panels;
},
};
diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js
index 87e94311176..ee8a85ea222 100644
--- a/app/assets/javascripts/monitoring/stores/state.js
+++ b/app/assets/javascripts/monitoring/stores/state.js
@@ -1,8 +1,6 @@
import invalidUrl from '~/lib/utils/invalid_url';
export default () => ({
- hasMetrics: false,
- showPanels: true,
metricsEndpoint: null,
environmentsEndpoint: null,
deploymentsEndpoint: null,
@@ -10,12 +8,13 @@ export default () => ({
emptyState: 'gettingStarted',
showEmptyState: true,
showErrorBanner: true,
+
dashboard: {
panel_groups: [],
},
+
deploymentData: [],
environments: [],
- metricsWithData: [],
allDashboards: [],
currentDashboard: null,
projectPath: null,
diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js
index 8a396b15a31..3300d2032d0 100644
--- a/app/assets/javascripts/monitoring/stores/utils.js
+++ b/app/assets/javascripts/monitoring/stores/utils.js
@@ -1,83 +1,21 @@
import _ from 'underscore';
-function checkQueryEmptyData(query) {
- return {
- ...query,
- result: query.result.filter(timeSeries => {
- const newTimeSeries = timeSeries;
- const hasValue = series =>
- !Number.isNaN(series[1]) && (series[1] !== null || series[1] !== undefined);
- const hasNonNullValue = timeSeries.values.find(hasValue);
-
- newTimeSeries.values = hasNonNullValue ? newTimeSeries.values : [];
-
- return newTimeSeries.values.length > 0;
- }),
- };
-}
-
-function removeTimeSeriesNoData(queries) {
- return queries.reduce((series, query) => series.concat(checkQueryEmptyData(query)), []);
-}
-
-// Metrics and queries are currently stored 1:1, so `queries` is an array of length one.
-// We want to group queries onto a single chart by title & y-axis label.
-// This function will no longer be required when metrics:queries are 1:many,
-// though there is no consequence if the function stays in use.
-// @param metrics [Array<Object>]
-// Ex) [
-// { id: 1, title: 'title', y_label: 'MB', queries: [{ ...query1Attrs }] },
-// { id: 2, title: 'title', y_label: 'MB', queries: [{ ...query2Attrs }] },
-// { id: 3, title: 'new title', y_label: 'MB', queries: [{ ...query3Attrs }] }
-// ]
-// @return [Array<Object>]
-// Ex) [
-// { title: 'title', y_label: 'MB', queries: [{ metricId: 1, ...query1Attrs },
-// { metricId: 2, ...query2Attrs }] },
-// { title: 'new title', y_label: 'MB', queries: [{ metricId: 3, ...query3Attrs }]}
-// ]
-export function groupQueriesByChartInfo(metrics) {
- const metricsByChart = metrics.reduce((accumulator, metric) => {
- const { queries, ...chart } = metric;
-
- const chartKey = `${chart.title}|${chart.y_label}`;
- accumulator[chartKey] = accumulator[chartKey] || { ...chart, queries: [] };
-
- queries.forEach(queryAttrs => {
- let metricId;
-
- if (chart.id) {
- metricId = chart.id.toString();
- } else if (queryAttrs.metric_id) {
- metricId = queryAttrs.metric_id.toString();
- } else {
- metricId = null;
- }
-
- accumulator[chartKey].queries.push({ metricId, ...queryAttrs });
- });
-
- return accumulator;
- }, {});
-
- return Object.values(metricsByChart);
-}
-
export const uniqMetricsId = metric => `${metric.metric_id}_${metric.id}`;
/**
- * Not to confuse with normalizeMetrics (plural)
* Metrics loaded from project-defined dashboards do not have a metric_id.
* This method creates a unique ID combining metric_id and id, if either is present.
* This is hopefully a temporary solution until BE processes metrics before passing to fE
* @param {Object} metric - metric
* @returns {Object} - normalized metric with a uniqueID
*/
+
export const normalizeMetric = (metric = {}) =>
_.omit(
{
...metric,
metric_id: uniqMetricsId(metric),
+ metricId: uniqMetricsId(metric),
},
'id',
);
@@ -93,6 +31,11 @@ export const normalizeQueryResult = timeSeries => {
Number(value),
]),
};
+ // Check result for empty data
+ normalizedResult.values = normalizedResult.values.filter(series => {
+ const hasValue = d => !Number.isNaN(d[1]) && (d[1] !== null || d[1] !== undefined);
+ return series.find(hasValue);
+ });
} else if (timeSeries.value) {
normalizedResult = {
...timeSeries,
@@ -102,21 +45,3 @@ export const normalizeQueryResult = timeSeries => {
return normalizedResult;
};
-
-export const normalizeMetrics = metrics => {
- const groupedMetrics = groupQueriesByChartInfo(metrics);
-
- return groupedMetrics.map(metric => {
- const queries = metric.queries.map(query => ({
- ...query,
- // custom metrics do not require a label, so we should ensure this attribute is defined
- label: query.label || metric.y_label,
- result: (query.result || []).map(normalizeQueryResult),
- }));
-
- return {
- ...metric,
- queries: removeTimeSeriesNoData(queries),
- };
- });
-};
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index 2ae1647011d..c824d6d4ddb 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -72,10 +72,9 @@ export const ISODateToString = date => dateformat(date, dateFormats.dateTimePick
*/
export const graphDataValidatorForValues = (isValues, graphData) => {
const responseValueKeyName = isValues ? 'value' : 'values';
-
return (
- Array.isArray(graphData.queries) &&
- graphData.queries.filter(query => {
+ Array.isArray(graphData.metrics) &&
+ graphData.metrics.filter(query => {
if (Array.isArray(query.result)) {
return (
query.result.filter(res => Array.isArray(res[responseValueKeyName])).length ===
@@ -83,7 +82,7 @@ export const graphDataValidatorForValues = (isValues, graphData) => {
);
}
return false;
- }).length === graphData.queries.length
+ }).length === graphData.metrics.filter(query => query.result).length
);
};
@@ -116,6 +115,7 @@ export const generateLinkToChartOptions = chartLink => {
/**
* Tracks snowplow event when user downloads CSV of cluster metric
* @param {String} chart title that will be sent as a property for the event
+ * @return {Object} config object for event tracking
*/
export const downloadCSVOptions = title => {
const isCLusterHealthBoard = isClusterHealthBoard();
@@ -131,7 +131,19 @@ export const downloadCSVOptions = title => {
};
/**
- * This function validates the graph data contains exactly 3 queries plus
+ * Generate options for snowplow to track adding a new metric via the dashboard
+ * custom metric modal
+ * @return {Object} config object for event tracking
+ */
+export const getAddMetricTrackingOptions = () => ({
+ category: document.body.dataset.page,
+ action: 'click_button',
+ label: 'add_new_metric',
+ property: 'modal',
+});
+
+/**
+ * This function validates the graph data contains exactly 3 metrics plus
* value validations from graphDataValidatorForValues.
* @param {Object} isValues
* @param {Object} graphData the graph data response from a prometheus request
@@ -140,8 +152,8 @@ export const downloadCSVOptions = title => {
export const graphDataValidatorForAnomalyValues = graphData => {
const anomalySeriesCount = 3; // metric, upper, lower
return (
- graphData.queries &&
- graphData.queries.length === anomalySeriesCount &&
+ graphData.metrics &&
+ graphData.metrics.length === anomalySeriesCount &&
graphDataValidatorForValues(false, graphData)
);
};
diff --git a/app/assets/javascripts/mr_popover/components/mr_popover.vue b/app/assets/javascripts/mr_popover/components/mr_popover.vue
index b81600660f6..ce08b0964a1 100644
--- a/app/assets/javascripts/mr_popover/components/mr_popover.vue
+++ b/app/assets/javascripts/mr_popover/components/mr_popover.vue
@@ -45,7 +45,7 @@ export default {
return this.mergeRequest.headPipeline && this.mergeRequest.headPipeline.detailedStatus;
},
formattedTime() {
- return this.timeFormated(this.mergeRequest.createdAt);
+ return this.timeFormatted(this.mergeRequest.createdAt);
},
statusBoxClass() {
switch (this.mergeRequest.state) {
diff --git a/app/assets/javascripts/mr_tabs_popover/components/popover.vue b/app/assets/javascripts/mr_tabs_popover/components/popover.vue
new file mode 100644
index 00000000000..da1e1e70993
--- /dev/null
+++ b/app/assets/javascripts/mr_tabs_popover/components/popover.vue
@@ -0,0 +1,64 @@
+<script>
+import { GlPopover, GlButton, GlLink } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+import axios from '~/lib/utils/axios_utils';
+
+export default {
+ components: {
+ GlPopover,
+ GlButton,
+ GlLink,
+ Icon,
+ },
+ props: {
+ dismissEndpoint: {
+ type: String,
+ required: true,
+ },
+ featureId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ showPopover: false,
+ };
+ },
+ mounted() {
+ setTimeout(() => {
+ this.showPopover = true;
+ }, 2000);
+ },
+ methods: {
+ onDismiss() {
+ this.showPopover = false;
+
+ axios.post(this.dismissEndpoint, {
+ feature_name: this.featureId,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-popover target="#diffs-tab" placement="bottom" :show="showPopover">
+ <p class="mb-2">
+ {{
+ __(
+ 'Now you can access the merge request navigation tabs at the top, where they’re easier to find.',
+ )
+ }}
+ </p>
+ <p>
+ <gl-link href="https://gitlab.com/gitlab-org/gitlab/issues/36125" target="_blank">
+ {{ __('More information and share feedback') }}
+ <icon name="external-link" :size="10" />
+ </gl-link>
+ </p>
+ <gl-button variant="primary" size="sm" @click="onDismiss">
+ {{ __('Got it') }}
+ </gl-button>
+ </gl-popover>
+</template>
diff --git a/app/assets/javascripts/mr_tabs_popover/index.js b/app/assets/javascripts/mr_tabs_popover/index.js
new file mode 100644
index 00000000000..9ee0ba046f0
--- /dev/null
+++ b/app/assets/javascripts/mr_tabs_popover/index.js
@@ -0,0 +1,12 @@
+import Vue from 'vue';
+import Popover from './components/popover.vue';
+
+export default el =>
+ new Vue({
+ el,
+ render(createElement) {
+ return createElement(Popover, {
+ props: { dismissEndpoint: el.dataset.dismissEndpoint, featureId: el.dataset.featureId },
+ });
+ },
+ });
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index 9e4a92426ee..753aa96bb55 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -1,7 +1,7 @@
<script>
-/* global katex */
import marked from 'marked';
import sanitize from 'sanitize-html';
+import katex from 'katex';
import Prompt from './prompt.vue';
const renderer = new marked.Renderer();
@@ -70,7 +70,6 @@ renderer.paragraph = t => {
};
marked.setOptions({
- sanitize: true,
renderer,
});
@@ -87,9 +86,66 @@ export default {
computed: {
markdown() {
return sanitize(marked(this.cell.source.join('').replace(/\\/g, '\\\\')), {
- allowedTags: false,
+ // allowedTags from GitLab's inline HTML guidelines
+ // https://docs.gitlab.com/ee/user/markdown.html#inline-html
+ allowedTags: [
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'h7',
+ 'h8',
+ 'br',
+ 'b',
+ 'i',
+ 'strong',
+ 'em',
+ 'a',
+ 'pre',
+ 'code',
+ 'img',
+ 'tt',
+ 'div',
+ 'ins',
+ 'del',
+ 'sup',
+ 'sub',
+ 'p',
+ 'ol',
+ 'ul',
+ 'table',
+ 'thead',
+ 'tbody',
+ 'tfoot',
+ 'blockquote',
+ 'dl',
+ 'dt',
+ 'dd',
+ 'kbd',
+ 'q',
+ 'samp',
+ 'var',
+ 'hr',
+ 'ruby',
+ 'rt',
+ 'rp',
+ 'li',
+ 'tr',
+ 'td',
+ 'th',
+ 's',
+ 'strike',
+ 'span',
+ 'abbr',
+ 'abbr',
+ 'summary',
+ ],
allowedAttributes: {
- '*': ['class'],
+ '*': ['class', 'style'],
+ a: ['href'],
+ img: ['src'],
},
});
},
@@ -105,6 +161,15 @@ export default {
</template>
<style>
+/*
+ Importing the necessary katex stylesheet from the node_module folder rather
+ than copying the stylesheet into `app/assets/stylesheets/vendors` for
+ automatic importing via `app/assets/stylesheets/application.scss`. The reason
+ is that the katex stylesheet depends on many fonts that are in node_module
+ subfolders - moving all these fonts would make updating katex difficult.
+ */
+@import '~katex/dist/katex.min.css';
+
.markdown .katex {
display: block;
text-align: center;
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index defa278c089..1a8f1c659a4 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -1,7 +1,7 @@
-/* eslint-disable no-restricted-properties, no-var, camelcase,
-no-unused-expressions, one-var, default-case,
+/* eslint-disable no-restricted-properties, camelcase,
+no-unused-expressions, default-case,
consistent-return, no-alert, no-param-reassign, no-else-return,
-vars-on-top, no-shadow, no-useless-escape,
+no-shadow, no-useless-escape,
class-methods-use-this */
/* global ResolveService */
@@ -16,10 +16,10 @@ import Cookies from 'js-cookie';
import Autosize from 'autosize';
import 'jquery.caret'; // required by at.js
import 'at.js';
-import AjaxCache from '~/lib/utils/ajax_cache';
import Vue from 'vue';
-import syntaxHighlight from '~/syntax_highlight';
import { GlSkeletonLoading } from '@gitlab/ui';
+import AjaxCache from '~/lib/utils/ajax_cache';
+import syntaxHighlight from '~/syntax_highlight';
import axios from './lib/utils/axios_utils';
import { getLocationHash } from './lib/utils/url_utility';
import Flash from './flash';
@@ -224,18 +224,18 @@ export default class Notes {
}
keydownNoteText(e) {
- var $textarea,
- discussionNoteForm,
- editNote,
- myLastNote,
- myLastNoteEditBtn,
- newText,
- originalText;
+ let discussionNoteForm;
+ let editNote;
+ let myLastNote;
+ let myLastNoteEditBtn;
+ let newText;
+ let originalText;
+
if (isMetaKey(e)) {
return;
}
- $textarea = $(e.target);
+ const $textarea = $(e.target);
// Edit previous note when UP arrow is hit
switch (e.which) {
case 38:
@@ -325,11 +325,10 @@ export default class Notes {
* if there aren't new notes coming from the server
*/
setPollingInterval(shouldReset) {
- var nthInterval;
if (shouldReset == null) {
shouldReset = true;
}
- nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1);
+ const nthInterval = this.basePollingInterval * Math.pow(2, this.maxPollingSteps - 1);
if (shouldReset) {
this.pollingInterval = this.basePollingInterval;
} else if (this.pollingInterval < nthInterval) {
@@ -339,7 +338,7 @@ export default class Notes {
}
handleQuickActions(noteEntity) {
- var votesBlock;
+ let votesBlock;
if (noteEntity.commands_changes) {
if ('merge' in noteEntity.commands_changes) {
Notes.checkMergeRequestStatus();
@@ -462,14 +461,16 @@ export default class Notes {
* Render note in discussion area. To render inline notes use renderDiscussionNote.
*/
renderDiscussionNote(noteEntity, $form) {
- var discussionContainer, form, row, lineType, diffAvatarContainer;
+ let discussionContainer;
+ let row;
if (!Notes.isNewNote(noteEntity, this.note_ids)) {
return;
}
this.note_ids.push(noteEntity.id);
- form = $form || $(`.js-discussion-note-form[data-discussion-id="${noteEntity.discussion_id}"]`);
+ const form =
+ $form || $(`.js-discussion-note-form[data-discussion-id="${noteEntity.discussion_id}"]`);
row =
form.length || !noteEntity.discussion_line_code
? form.closest('tr')
@@ -479,8 +480,8 @@ export default class Notes {
row = form;
}
- lineType = this.isParallelView() ? form.find('#line_type').val() : 'old';
- diffAvatarContainer = row
+ const lineType = this.isParallelView() ? form.find('#line_type').val() : 'old';
+ const diffAvatarContainer = row
.prevAll('.line_holder')
.first()
.find(`.js-avatar-container.${lineType}_line`);
@@ -491,15 +492,17 @@ export default class Notes {
}
if (discussionContainer.length === 0) {
if (noteEntity.diff_discussion_html) {
- var $discussion = $(noteEntity.diff_discussion_html).renderGFM();
+ const $discussion = $(noteEntity.diff_discussion_html).renderGFM();
if (!this.isParallelView() || row.hasClass('js-temp-notes-holder') || noteEntity.on_image) {
// insert the note and the reply button after the temp row
row.after($discussion);
} else {
// Merge new discussion HTML in
- var $notes = $discussion.find(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`);
- var contentContainerClass = $notes
+ const $notes = $discussion.find(
+ `.notes[data-discussion-id="${noteEntity.discussion_id}"]`,
+ );
+ const contentContainerClass = $notes
.closest('.notes-content')
.attr('class')
.split(' ')
@@ -537,7 +540,7 @@ export default class Notes {
}
renderDiscussionAvatar(diffAvatarContainer, noteEntity) {
- var avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders');
+ let avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders');
if (!avatarHolder.length) {
avatarHolder = document.createElement('diff-note-avatars');
@@ -557,8 +560,7 @@ export default class Notes {
* Resets buttons.
*/
resetMainTargetForm(e) {
- var form;
- form = $('.js-main-target-form');
+ const form = $('.js-main-target-form');
// remove validation errors
form.find('.js-errors').remove();
// reset text and preview
@@ -572,7 +574,7 @@ export default class Notes {
.data('autosave')
.reset();
- var event = document.createEvent('Event');
+ const event = document.createEvent('Event');
event.initEvent('autosize:update', true, false);
form.find('.js-autosize')[0].dispatchEvent(event);
@@ -580,8 +582,7 @@ export default class Notes {
}
reenableTargetFormSubmitButton() {
- var form;
- form = $('.js-main-target-form');
+ const form = $('.js-main-target-form');
return form.find('.js-note-text').trigger('input');
}
@@ -591,9 +592,8 @@ export default class Notes {
* Sets some hidden fields in the form.
*/
setupMainTargetNoteForm(enableGFM) {
- var form;
// find the form
- form = $('.js-new-note-form');
+ const form = $('.js-new-note-form');
// Set a global clone of the form for later cloning
this.formClone = form.clone();
// show the form
@@ -626,10 +626,9 @@ export default class Notes {
* show the form
*/
setupNoteForm(form, enableGFM = defaultAutocompleteConfig) {
- var textarea, key;
this.glForm = new GLForm(form, enableGFM);
- textarea = form.find('.js-note-text');
- key = [
+ const textarea = form.find('.js-note-text');
+ const key = [
s__('NoteForm|Note'),
form.find('#note_noteable_type').val(),
form.find('#note_noteable_id').val(),
@@ -686,8 +685,8 @@ export default class Notes {
*/
addDiscussionNote($form, note, isNewDiffComment) {
if ($form.attr('data-resolve-all') != null) {
- var discussionId = $form.data('discussionId');
- var mergeRequestId = $form.data('noteableIid');
+ const discussionId = $form.data('discussionId');
+ const mergeRequestId = $form.data('noteableIid');
if (ResolveService != null) {
ResolveService.toggleResolveForDiscussion(mergeRequestId, discussionId);
@@ -707,13 +706,12 @@ export default class Notes {
* Updates the current note field.
*/
updateNote(noteEntity, $targetNote) {
- var $noteEntityEl, $note_li;
// Convert returned HTML to a jQuery object so we can modify it further
- $noteEntityEl = $(noteEntity.html);
+ const $noteEntityEl = $(noteEntity.html);
this.revertNoteEditForm($targetNote);
$noteEntityEl.renderGFM();
// Find the note's `li` element by ID and replace it with the updated HTML
- $note_li = $(`.note-row-${noteEntity.id}`);
+ const $note_li = $(`.note-row-${noteEntity.id}`);
$note_li.replaceWith($noteEntityEl);
this.setupNewNote($noteEntityEl);
@@ -724,17 +722,17 @@ export default class Notes {
}
checkContentToAllowEditing($el) {
- var initialContent = $el
+ const initialContent = $el
.find('.original-note-content')
.text()
.trim();
- var currentContent = $el.find('.js-note-text').val();
- var isAllowed = true;
+ const currentContent = $el.find('.js-note-text').val();
+ let isAllowed = true;
if (currentContent === initialContent) {
this.removeNoteEditForm($el);
} else {
- var isWidgetVisible = isInViewport($el.get(0));
+ const isWidgetVisible = isInViewport($el.get(0));
if (!isWidgetVisible) {
scrollToElement($el);
@@ -756,13 +754,13 @@ export default class Notes {
showEditForm(e) {
e.preventDefault();
- var $target = $(e.target);
- var $editForm = $(this.getEditFormSelector($target));
- var $note = $target.closest('.note');
- var $currentlyEditing = $('.note.is-editing:visible');
+ const $target = $(e.target);
+ const $editForm = $(this.getEditFormSelector($target));
+ const $note = $target.closest('.note');
+ const $currentlyEditing = $('.note.is-editing:visible');
if ($currentlyEditing.length) {
- var isEditAllowed = this.checkContentToAllowEditing($currentlyEditing);
+ const isEditAllowed = this.checkContentToAllowEditing($currentlyEditing);
if (!isEditAllowed) {
return;
@@ -802,8 +800,8 @@ export default class Notes {
revertNoteEditForm($target) {
$target = $target || $('.note.is-editing:visible');
- var selector = this.getEditFormSelector($target);
- var $editForm = $(selector);
+ const selector = this.getEditFormSelector($target);
+ const $editForm = $(selector);
$editForm.insertBefore('.diffs');
$editForm.find('.js-comment-save-button').enable();
@@ -811,7 +809,7 @@ export default class Notes {
}
getEditFormSelector($el) {
- var selector = '.note-edit-form:not(.mr-note-edit-form)';
+ let selector = '.note-edit-form:not(.mr-note-edit-form)';
if ($el.parents('#diffs').length) {
selector = '.note-edit-form.mr-note-edit-form';
@@ -821,7 +819,7 @@ export default class Notes {
}
removeNoteEditForm($note) {
- var form = $note.find('.diffs .current-note-edit-form');
+ const form = $note.find('.diffs .current-note-edit-form');
$note.removeClass('is-editing');
form.removeClass('current-note-edit-form');
@@ -837,9 +835,8 @@ export default class Notes {
* Removes the whole discussion if the last note is being removed.
*/
removeNote(e) {
- var noteElId, $note;
- $note = $(e.currentTarget).closest('.note');
- noteElId = $note.attr('id');
+ const $note = $(e.currentTarget).closest('.note');
+ const noteElId = $note.attr('id');
$(`.note[id="${noteElId}"]`).each((i, el) => {
// A same note appears in the "Discussion" and in the "Changes" tab, we have
// to remove all. Using $('.note[id='noteId']') ensure we get all the notes,
@@ -915,9 +912,8 @@ export default class Notes {
}
replyToDiscussionNote(target) {
- var form, replyLink;
- form = this.cleanForm(this.formClone.clone());
- replyLink = $(target).closest('.js-discussion-reply-button');
+ const form = this.cleanForm(this.formClone.clone());
+ const replyLink = $(target).closest('.js-discussion-reply-button');
// insert the form after the button
replyLink
.closest('.discussion-reply-holder')
@@ -942,7 +938,7 @@ export default class Notes {
diffFileData = dataHolder.closest('.image');
}
- var discussionID = dataHolder.data('discussionId');
+ const discussionID = dataHolder.data('discussionId');
if (discussionID) {
form.attr('data-discussion-id', discussionID);
@@ -985,7 +981,7 @@ export default class Notes {
form.removeClass('js-main-target-form').addClass('discussion-form js-discussion-note-form');
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- var $commentBtn = form.find('comment-and-resolve-btn');
+ const $commentBtn = form.find('comment-and-resolve-btn');
$commentBtn.attr(':discussion-id', `'${discussionID}'`);
gl.diffNotesCompileComponents();
@@ -1042,16 +1038,20 @@ export default class Notes {
}
toggleDiffNote({ target, lineType, forceShow, showReplyInput = false }) {
- var $link, addForm, hasNotes, newForm, noteForm, replyButton, row, rowCssToAdd;
- $link = $(target);
- row = $link.closest('tr');
+ let addForm;
+ let newForm;
+ let noteForm;
+ let replyButton;
+ let rowCssToAdd;
+ const $link = $(target);
+ const row = $link.closest('tr');
const nextRow = row.next();
let targetRow = row;
if (nextRow.is('.notes_holder')) {
targetRow = nextRow;
}
- hasNotes = nextRow.is('.notes_holder');
+ const hasNotes = nextRow.is('.notes_holder');
addForm = false;
let lineTypeSelector = '';
rowCssToAdd =
@@ -1111,9 +1111,8 @@ export default class Notes {
* Removes the form and if necessary it's temporary row.
*/
removeDiscussionNoteForm(form) {
- var glForm, row;
- row = form.closest('tr');
- glForm = form.data('glForm');
+ const row = form.closest('tr');
+ const glForm = form.data('glForm');
glForm.destroy();
form
.find('.js-note-text')
@@ -1158,10 +1157,9 @@ export default class Notes {
* Updates the file name for the selected attachment.
*/
updateFormAttachment() {
- var filename, form;
- form = $(this).closest('form');
+ const form = $(this).closest('form');
// get only the basename
- filename = $(this)
+ const filename = $(this)
.val()
.replace(/^.*[\\\/]/, '');
return form.find('.js-attachment-filename').text(filename);
@@ -1175,11 +1173,12 @@ export default class Notes {
}
updateTargetButtons(e) {
- var closebtn, closetext, form, reopenbtn, reopentext, textarea;
- textarea = $(e.target);
- form = textarea.parents('form');
- reopenbtn = form.find('.js-note-target-reopen');
- closebtn = form.find('.js-note-target-close');
+ let closetext;
+ let reopentext;
+ const textarea = $(e.target);
+ const form = textarea.parents('form');
+ const reopenbtn = form.find('.js-note-target-reopen');
+ const closebtn = form.find('.js-note-target-close');
if (textarea.val().trim().length > 0) {
reopentext = reopenbtn.attr('data-alternative-text');
@@ -1215,16 +1214,16 @@ export default class Notes {
}
putEditFormInPlace($el) {
- var $editForm = $(this.getEditFormSelector($el));
- var $note = $el.closest('.note');
+ const $editForm = $(this.getEditFormSelector($el));
+ const $note = $el.closest('.note');
$editForm.insertAfter($note.find('.note-text'));
- var $originalContentEl = $note.find('.original-note-content');
- var originalContent = $originalContentEl.text().trim();
- var postUrl = $originalContentEl.data('postUrl');
- var targetId = $originalContentEl.data('targetId');
- var targetType = $originalContentEl.data('targetType');
+ const $originalContentEl = $note.find('.original-note-content');
+ const originalContent = $originalContentEl.text().trim();
+ const postUrl = $originalContentEl.data('postUrl');
+ const targetId = $originalContentEl.data('targetId');
+ const targetType = $originalContentEl.data('targetType');
this.glForm = new GLForm($editForm.find('form'), this.enableGFM);
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index fda494fec07..492d8de3802 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -193,23 +193,10 @@ export default {
this.stopPolling();
this.saveNote(noteData)
- .then(res => {
+ .then(() => {
this.enableButton();
this.restartPolling();
-
- if (res.errors) {
- if (res.errors.commands_only) {
- this.discard();
- } else {
- Flash(
- __('Something went wrong while adding your comment. Please try again.'),
- 'alert',
- this.$refs.commentForm,
- );
- }
- } else {
- this.discard();
- }
+ this.discard();
if (withIssueAction) {
this.toggleIssueState();
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
index df537ba1ed2..fe22737c7fc 100644
--- a/app/assets/javascripts/notes/components/diff_with_note.vue
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -1,10 +1,10 @@
<script>
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { mapState, mapActions } from 'vuex';
+import { GlSkeletonLoading } from '@gitlab/ui';
import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue';
-import { GlSkeletonLoading } from '@gitlab/ui';
import { getDiffMode } from '~/diffs/store/utils';
import { diffViewerModes } from '~/ide/constants';
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index d7ffa0abb79..98f1f385e9b 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -19,6 +19,7 @@ export default {
'resolvableDiscussionsCount',
'firstUnresolvedDiscussionId',
'unresolvedDiscussionsCount',
+ 'getDiscussion',
]),
isLoggedIn() {
return this.getUserData.id;
@@ -40,9 +41,10 @@ export default {
...mapActions(['expandDiscussion']),
jumpToFirstUnresolvedDiscussion() {
const diffTab = window.mrTabs.currentAction === 'diffs';
- const discussionId = this.firstUnresolvedDiscussionId(diffTab);
-
- this.jumpToDiscussion(discussionId);
+ const discussionId =
+ this.firstUnresolvedDiscussionId(diffTab) || this.firstUnresolvedDiscussionId();
+ const firstDiscussion = this.getDiscussion(discussionId);
+ this.jumpToDiscussion(firstDiscussion);
},
},
};
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 07a5bda6bcb..f87ca097b40 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,6 @@
<script>
-import icon from '~/vue_shared/components/icon.vue';
import { GlTooltipDirective } from '@gitlab/ui';
+import icon from '~/vue_shared/components/icon.vue';
export default {
name: 'JumpToNextDiscussionButton',
diff --git a/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue b/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue
index 7fbfe8eebb2..7d742fbfeee 100644
--- a/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue
+++ b/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue
@@ -19,7 +19,11 @@ export default {
};
},
computed: {
- ...mapGetters(['nextUnresolvedDiscussionId', 'previousUnresolvedDiscussionId']),
+ ...mapGetters([
+ 'nextUnresolvedDiscussionId',
+ 'previousUnresolvedDiscussionId',
+ 'getDiscussion',
+ ]),
},
mounted() {
Mousetrap.bind('n', () => this.jumpToNextDiscussion());
@@ -33,14 +37,14 @@ export default {
...mapActions(['expandDiscussion']),
jumpToNextDiscussion() {
const nextId = this.nextUnresolvedDiscussionId(this.currentDiscussionId, this.isDiffView);
-
- this.jumpToDiscussion(nextId);
+ const nextDiscussion = this.getDiscussion(nextId);
+ this.jumpToDiscussion(nextDiscussion);
this.currentDiscussionId = nextId;
},
jumpToPreviousDiscussion() {
const prevId = this.previousUnresolvedDiscussionId(this.currentDiscussionId, this.isDiffView);
-
- this.jumpToDiscussion(prevId);
+ const prevDiscussion = this.getDiscussion(prevId);
+ this.jumpToDiscussion(prevDiscussion);
this.currentDiscussionId = prevId;
},
},
diff --git a/app/assets/javascripts/notes/components/discussion_locked_widget.vue b/app/assets/javascripts/notes/components/discussion_locked_widget.vue
index 53f509185a8..8636984c6af 100644
--- a/app/assets/javascripts/notes/components/discussion_locked_widget.vue
+++ b/app/assets/javascripts/notes/components/discussion_locked_widget.vue
@@ -12,6 +12,9 @@ export default {
},
mixins: [Issuable, issuableStateMixin],
computed: {
+ projectArchivedWarning() {
+ return __('This project is archived and cannot be commented on.');
+ },
lockedIssueWarning() {
return sprintf(
__('This %{issuableDisplayName} is locked. Only project members can comment.'),
@@ -26,9 +29,15 @@ export default {
<div class="disabled-comment text-center">
<span class="issuable-note-warning inline">
<icon :size="16" name="lock" class="icon" />
- <span>
- {{ lockedIssueWarning }}
+ <span v-if="isProjectArchived">
+ {{ projectArchivedWarning }}
+ <gl-link :href="archivedProjectDocsPath" target="_blank" class="learn-more">
+ {{ __('Learn more') }}
+ </gl-link>
+ </span>
+ <span v-else>
+ {{ lockedIssueWarning }}
<gl-link :href="lockedIssueDocsPath" target="_blank" class="learn-more">
{{ __('Learn more') }}
</gl-link>
diff --git a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
index f03e6fd73d7..1d1529bfa5b 100644
--- a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
+++ b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
@@ -1,6 +1,6 @@
<script>
-import Icon from '~/vue_shared/components/icon.vue';
import { GlTooltipDirective, GlButton } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
export default {
name: 'ResolveWithIssueButton',
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index 89d434a60ba..dc514f00801 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -1,8 +1,8 @@
<script>
import { mapGetters } from 'vuex';
-import Icon from '~/vue_shared/components/icon.vue';
import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import resolvedStatusMixin from 'ee_else_ce/batch_comments/mixins/resolved_status';
+import Icon from '~/vue_shared/components/icon.vue';
import ReplyButton from './note_actions/reply_button.vue';
export default {
diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue
index d4a57d5d58d..df62e379017 100644
--- a/app/assets/javascripts/notes/components/note_awards_list.vue
+++ b/app/assets/javascripts/notes/components/note_awards_list.vue
@@ -116,16 +116,20 @@ export default {
// We have 10+ awarded user, join them with comma and add `and x more`.
if (remainingAwardList.length) {
- title = sprintf(__(`%{listToShow}, and %{awardsListLength} more.`), {
- listToShow: namesToShow.join(', '),
- awardsListLength: remainingAwardList.length,
- });
+ title = sprintf(
+ __(`%{listToShow}, and %{awardsListLength} more.`),
+ {
+ listToShow: namesToShow.join(', '),
+ awardsListLength: remainingAwardList.length,
+ },
+ false,
+ );
} else if (namesToShow.length > 1) {
// Join all names with comma but not the last one, it will be added with and text.
title = namesToShow.slice(0, namesToShow.length - 1).join(', ');
// If we have more than 2 users we need an extra comma before and text.
title += namesToShow.length > 2 ? ',' : '';
- title += sprintf(__(` and %{sliced}`), { sliced: namesToShow.slice(-1) }); // Append and text
+ title += sprintf(__(` and %{sliced}`), { sliced: namesToShow.slice(-1) }, false); // Append and text
} else {
// We have only 2 users so join them with and.
title = namesToShow.join(__(' and '));
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 222badf70d1..b024884bea0 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -1,7 +1,7 @@
<script>
-import { mergeUrlParams } from '~/lib/utils/url_utility';
import { mapGetters, mapActions } from 'vuex';
import noteFormMixin from 'ee_else_ce/notes/mixins/note_form';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue';
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 47ec740b63a..1f31720ff40 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -1,10 +1,10 @@
<script>
import { mapActions, mapGetters } from 'vuex';
import { GlTooltipDirective } from '@gitlab/ui';
+import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form';
import { s__, __ } from '~/locale';
import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave';
import icon from '~/vue_shared/components/icon.vue';
-import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import Flash from '../../flash';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
@@ -84,6 +84,7 @@ export default {
'hasUnresolvedDiscussions',
'showJumpToNextDiscussion',
'getUserData',
+ 'getDiscussion',
]),
currentUser() {
return this.getUserData;
@@ -197,23 +198,22 @@ export default {
data: postData,
};
- this.isReplying = false;
this.saveNote(replyData)
- .then(() => {
- clearDraft(this.autosaveKey);
+ .then(res => {
+ if (res.hasFlash !== true) {
+ this.isReplying = false;
+ clearDraft(this.autosaveKey);
+ }
callback();
})
.catch(err => {
this.removePlaceholderNotes();
- this.isReplying = true;
- this.$nextTick(() => {
- const msg = __(
- 'Your comment could not be submitted! Please check your network connection and try again.',
- );
- Flash(msg, 'alert', this.$el);
- this.$refs.noteForm.note = noteText;
- callback(err);
- });
+ const msg = __(
+ 'Your comment could not be submitted! Please check your network connection and try again.',
+ );
+ Flash(msg, 'alert', this.$el);
+ this.$refs.noteForm.note = noteText;
+ callback(err);
});
},
jumpToNextDiscussion() {
@@ -221,8 +221,9 @@ export default {
this.discussion.id,
this.discussionsByDiffOrder,
);
+ const nextDiscussion = this.getDiscussion(nextId);
- this.jumpToDiscussion(nextId);
+ this.jumpToDiscussion(nextDiscussion);
},
deleteNoteHandler(note) {
this.$emit('noteDeleted', this.discussion, note);
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index fa8fc7d02e4..b3dae69d0bc 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -2,9 +2,9 @@
import $ from 'jquery';
import { mapGetters, mapActions } from 'vuex';
import { escape } from 'underscore';
+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';
-import draftMixin from 'ee_else_ce/notes/mixins/draft';
import { __, s__, sprintf } from '../../locale';
import Flash from '../../flash';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 9d1de4ef8a0..be2adb07526 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -1,5 +1,4 @@
<script>
-import { __ } from '~/locale';
import { mapGetters, mapActions } from 'vuex';
import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility';
import Flash from '../../flash';
@@ -14,6 +13,7 @@ import placeholderNote from '../../vue_shared/components/notes/placeholder_note.
import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
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';
export default {
@@ -71,6 +71,9 @@ export default {
'userCanReply',
'discussionTabCounter',
]),
+ discussionTabCounterText() {
+ return this.isLoading ? '' : this.discussionTabCounter;
+ },
noteableType() {
return this.noteableData.noteableType;
},
@@ -95,9 +98,9 @@ export default {
this.fetchNotes();
}
},
- allDiscussions() {
- if (this.discussionsCount && !this.isLoading) {
- this.discussionsCount.textContent = this.discussionTabCounter;
+ discussionTabCounterText(val) {
+ if (this.discussionsCount) {
+ this.discussionsCount.textContent = val;
}
},
},
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index 3d89d907777..94ca01e44cc 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -35,20 +35,26 @@ export default {
return false;
},
- jumpToDiscussion(id) {
+
+ switchToDiscussionsTabAndJumpTo(id) {
+ window.mrTabs.eventHub.$once('MergeRequestTabChange', () => {
+ setTimeout(() => this.discussionJump(id), 0);
+ });
+
+ window.mrTabs.tabShown('show');
+ },
+
+ jumpToDiscussion(discussion) {
+ const { id, diff_discussion: isDiffDiscussion } = discussion;
if (id) {
const activeTab = window.mrTabs.currentAction;
- if (activeTab === 'diffs') {
+ if (activeTab === 'diffs' && isDiffDiscussion) {
this.diffsJump(id);
- } else if (activeTab === 'commits' || activeTab === 'pipelines') {
- window.mrTabs.eventHub.$once('MergeRequestTabChange', () => {
- setTimeout(() => this.discussionJump(id), 0);
- });
-
- window.mrTabs.tabShown('show');
- } else {
+ } else if (activeTab === 'show') {
this.discussionJump(id);
+ } else {
+ this.switchToDiscussionsTabAndJumpTo(id);
}
}
},
diff --git a/app/assets/javascripts/notes/mixins/issuable_state.js b/app/assets/javascripts/notes/mixins/issuable_state.js
index d97d9f6850a..0ca8c8c98a3 100644
--- a/app/assets/javascripts/notes/mixins/issuable_state.js
+++ b/app/assets/javascripts/notes/mixins/issuable_state.js
@@ -3,6 +3,12 @@ import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters(['getNoteableDataByProp']),
+ isProjectArchived() {
+ return this.getNoteableDataByProp('is_project_archived');
+ },
+ archivedProjectDocsPath() {
+ return this.getNoteableDataByProp('archived_project_docs_path');
+ },
lockedIssueDocsPath() {
return this.getNoteableDataByProp('locked_discussion_docs_path');
},
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 82c291379ec..9bd245c094d 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import $ from 'jquery';
-import axios from '~/lib/utils/axios_utils';
import Visibility from 'visibilityjs';
+import axios from '~/lib/utils/axios_utils';
import TaskList from '../../task_list';
import Flash from '../../flash';
import Poll from '../../lib/utils/poll';
@@ -14,7 +14,7 @@ import sidebarTimeTrackingEventHub from '../../sidebar/event_hub';
import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils';
import { mergeUrlParams } from '../../lib/utils/url_utility';
import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub';
-import { __ } from '~/locale';
+import { __, sprintf } from '~/locale';
import Api from '~/api';
let eTagPoll;
@@ -252,29 +252,22 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
}
}
- const processErrors = res => {
- const { errors } = res;
- if (!errors || !Object.keys(errors).length) {
- return res;
- }
-
+ const processQuickActions = res => {
+ const { errors: { commands_only: message } = { commands_only: null } } = res;
/*
The following reply means that quick actions have been successfully applied:
{"commands_changes":{},"valid":false,"errors":{"commands_only":["Commands applied"]}}
*/
- if (hasQuickActions) {
+ if (hasQuickActions && message) {
eTagPoll.makeRequest();
$('.js-gfm-input').trigger('clear-commands-cache.atwho');
- const { commands_only: message } = errors;
Flash(message || __('Commands applied'), 'notice', noteData.flashContainer);
-
- return res;
}
- throw new Error(__('Failed to save comment!'));
+ return res;
};
const processEmojiAward = res => {
@@ -321,11 +314,33 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
return res;
};
+ const processErrors = error => {
+ if (error.response) {
+ const {
+ response: { data = {} },
+ } = error;
+ const { errors = {} } = data;
+ const { base = [] } = errors;
+
+ // we handle only errors.base for now
+ if (base.length > 0) {
+ const errorMsg = sprintf(__('Your comment could not be submitted because %{error}'), {
+ error: base[0].toLowerCase(),
+ });
+ Flash(errorMsg, 'alert', noteData.flashContainer);
+ return { ...data, hasFlash: true };
+ }
+ }
+
+ throw error;
+ };
+
return dispatch(methodToDispatch, postData, { root: true })
- .then(processErrors)
+ .then(processQuickActions)
.then(processEmojiAward)
.then(processTimeTracking)
- .then(removePlaceholder);
+ .then(removePlaceholder)
+ .catch(processErrors);
};
const pollSuccessCallBack = (resp, commit, state, getters, dispatch) => {
diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
index 78aaa9df0ec..b43d6ba17d7 100644
--- a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
+++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
@@ -109,7 +109,7 @@ export default {
<template>
<p v-html="text"></p>
<p v-html="confirmationTextLabel"></p>
- <form ref="form" :action="deleteUserUrl" method="post">
+ <form ref="form" :action="deleteUserUrl" method="post" @submit.prevent>
<input ref="method" type="hidden" name="_method" value="delete" />
<input :value="csrfToken" type="hidden" name="authenticity_token" />
<gl-form-input
diff --git a/app/assets/javascripts/pages/dashboard/merge_requests/index.js b/app/assets/javascripts/pages/dashboard/merge_requests/index.js
index ff758fcb4fe..24d7b592948 100644
--- a/app/assets/javascripts/pages/dashboard/merge_requests/index.js
+++ b/app/assets/javascripts/pages/dashboard/merge_requests/index.js
@@ -1,6 +1,6 @@
+import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
-import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/pages/constants';
diff --git a/app/assets/javascripts/pages/groups/group_members/index/index.js b/app/assets/javascripts/pages/groups/group_members/index/index.js
index e4f4c3b574e..e77a7cf8e0a 100644
--- a/app/assets/javascripts/pages/groups/group_members/index/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index/index.js
@@ -1,7 +1,7 @@
/* eslint-disable no-new */
-import memberExpirationDate from '~/member_expiration_date';
import Members from 'ee_else_ce/members';
+import memberExpirationDate from '~/member_expiration_date';
import UsersSelect from '~/users_select';
document.addEventListener('DOMContentLoaded', () => {
diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js
index 090e1a2bc6d..4f15f5ec58c 100644
--- a/app/assets/javascripts/pages/groups/issues/index.js
+++ b/app/assets/javascripts/pages/groups/issues/index.js
@@ -1,9 +1,9 @@
+import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import initIssuablesList from '~/issuables_list';
import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar';
import { FILTERED_SEARCH } from '~/pages/constants';
-import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import initManualOrdering from '~/manual_ordering';
const ISSUE_BULK_UPDATE_PREFIX = 'issue_';
diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js
index 7520cfb6da0..13c5c350c24 100644
--- a/app/assets/javascripts/pages/groups/merge_requests/index.js
+++ b/app/assets/javascripts/pages/groups/merge_requests/index.js
@@ -1,8 +1,8 @@
+import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
-import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import { FILTERED_SEARCH } from '~/pages/constants';
const ISSUABLE_BULK_UPDATE_PREFIX = 'merge_request_';
diff --git a/app/assets/javascripts/pages/groups/new/group_path_validator.js b/app/assets/javascripts/pages/groups/new/group_path_validator.js
index 2021ad117e8..f1e7ff87e5a 100644
--- a/app/assets/javascripts/pages/groups/new/group_path_validator.js
+++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js
@@ -1,6 +1,6 @@
+import _ from 'underscore';
import InputValidator from '~/validators/input_validator';
-import _ from 'underscore';
import fetchGroupPathAvailability from './fetch_group_path_availability';
import flash from '~/flash';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/pages/groups/registry/repositories/index.js b/app/assets/javascripts/pages/groups/registry/repositories/index.js
index b663defad0e..635513afd95 100644
--- a/app/assets/javascripts/pages/groups/registry/repositories/index.js
+++ b/app/assets/javascripts/pages/groups/registry/repositories/index.js
@@ -1,3 +1,3 @@
-import initRegistryImages from '~/registry';
+import initRegistryImages from '~/registry/list';
document.addEventListener('DOMContentLoaded', initRegistryImages);
diff --git a/app/assets/javascripts/pages/instance_statistics/conversational_development_index/index.js b/app/assets/javascripts/pages/instance_statistics/dev_ops_score/index.js
index c1056537f90..c1056537f90 100644
--- a/app/assets/javascripts/pages/instance_statistics/conversational_development_index/index.js
+++ b/app/assets/javascripts/pages/instance_statistics/dev_ops_score/index.js
diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js
index 13cb0d6f74b..ad003181728 100644
--- a/app/assets/javascripts/pages/profiles/show/index.js
+++ b/app/assets/javascripts/pages/profiles/show/index.js
@@ -1,7 +1,7 @@
import $ from 'jquery';
-import createFlash from '~/flash';
import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
import emojiRegex from 'emoji-regex';
+import createFlash from '~/flash';
import EmojiMenu from './emoji_menu';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js
index 96e47187fed..34c7ee2e603 100644
--- a/app/assets/javascripts/pages/projects/issues/form.js
+++ b/app/assets/javascripts/pages/projects/issues/form.js
@@ -1,8 +1,8 @@
/* eslint-disable no-new */
import $ from 'jquery';
-import GLForm from '~/gl_form';
import IssuableForm from 'ee_else_ce/issuable_form';
+import GLForm from '~/gl_form';
import LabelsSelect from '~/labels_select';
import MilestoneSelect from '~/milestone_select';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js
index c73ebb31eb3..bf54ca972b2 100644
--- a/app/assets/javascripts/pages/projects/issues/index/index.js
+++ b/app/assets/javascripts/pages/projects/issues/index/index.js
@@ -1,12 +1,12 @@
/* eslint-disable no-new */
+import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import IssuableIndex from '~/issuable_index';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import UsersSelect from '~/users_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants';
-import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import initManualOrdering from '~/manual_ordering';
document.addEventListener('DOMContentLoaded', () => {
diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
index 0bcca22e40f..8f93cbb2a42 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
@@ -1,8 +1,8 @@
+import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import IssuableIndex from '~/issuable_index';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import UsersSelect from '~/users_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
-import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants';
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
index e51ab79a51d..76d72efb11b 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
@@ -1,10 +1,10 @@
/* eslint-disable no-new */
import $ from 'jquery';
+import IssuableForm from 'ee_else_ce/issuable_form';
import Diff from '~/diff';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import GLForm from '~/gl_form';
-import IssuableForm from 'ee_else_ce/issuable_form';
import LabelsSelect from '~/labels_select';
import MilestoneSelect from '~/milestone_select';
import IssuableTemplateSelectors from '~/templates/issuable_template_selectors';
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 16034313af2..1f8befc07c8 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
@@ -6,6 +6,7 @@ import howToMerge from '~/how_to_merge';
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() {
@@ -21,4 +22,10 @@ export default function() {
howToMerge();
initWidget();
initSourcegraph();
+
+ const tabHighlightEl = document.querySelector('.js-tabs-feature-highlight');
+
+ if (tabHighlightEl) {
+ initPopover(tabHighlightEl);
+ }
}
diff --git a/app/assets/javascripts/pages/projects/pages_domains/edit/index.js b/app/assets/javascripts/pages/projects/pages_domains/show/index.js
index 27e4433ad4d..27e4433ad4d 100644
--- a/app/assets/javascripts/pages/projects/pages_domains/edit/index.js
+++ b/app/assets/javascripts/pages/projects/pages_domains/show/index.js
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index 01acfca158f..739ae1cea16 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, no-return-assign */
+/* eslint-disable func-names, no-return-assign */
import $ from 'jquery';
import Cookies from 'js-cookie';
@@ -90,19 +90,19 @@ export default class Project {
}
static initRefSwitcher() {
- var refListItem = document.createElement('li');
- var refLink = document.createElement('a');
+ const refListItem = document.createElement('li');
+ const refLink = document.createElement('a');
refLink.href = '#';
return $('.js-project-refs-dropdown').each(function() {
- var $dropdown = $(this);
- var selected = $dropdown.data('selected');
- var fieldName = $dropdown.data('fieldName');
- var shouldVisit = Boolean($dropdown.data('visit'));
- var $form = $dropdown.closest('form');
- var action = $form.attr('action');
- var linkTarget = mergeUrlParams(serializeForm($form[0]), action);
+ const $dropdown = $(this);
+ const selected = $dropdown.data('selected');
+ const fieldName = $dropdown.data('fieldName');
+ const shouldVisit = Boolean($dropdown.data('visit'));
+ const $form = $dropdown.closest('form');
+ const action = $form.attr('action');
+ const linkTarget = mergeUrlParams(serializeForm($form[0]), action);
return $dropdown.glDropdown({
data(term, callback) {
@@ -123,9 +123,9 @@ export default class Project {
inputFieldName: $dropdown.data('inputFieldName'),
fieldName,
renderRow(ref) {
- var li = refListItem.cloneNode(false);
+ const li = refListItem.cloneNode(false);
- var link = refLink.cloneNode(false);
+ const link = refLink.cloneNode(false);
if (ref === selected) {
link.className = 'is-active';
diff --git a/app/assets/javascripts/pages/projects/registry/repositories/index.js b/app/assets/javascripts/pages/projects/registry/repositories/index.js
index 35564754ee0..59310b3f76f 100644
--- a/app/assets/javascripts/pages/projects/registry/repositories/index.js
+++ b/app/assets/javascripts/pages/projects/registry/repositories/index.js
@@ -1,3 +1,3 @@
-import initRegistryImages from '~/registry/index';
+import initRegistryImages from '~/registry/list/index';
document.addEventListener('DOMContentLoaded', initRegistryImages);
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
index 885247335a4..b4aac8eea2b 100644
--- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -1,6 +1,7 @@
import initSettingsPanels from '~/settings_panels';
import SecretValues from '~/behaviors/secret_values';
import AjaxVariableList from '~/ci_variable_list/ajax_variable_list';
+import registrySettingsApp from '~/registry/settings/registry_settings_bundle';
document.addEventListener('DOMContentLoaded', () => {
// Initialize expandable settings panels
@@ -32,4 +33,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (instanceDefaultBadge) instanceDefaultBadge.style.display = 'none';
autoDevOpsExtraSettings.classList.toggle('hidden', !target.checked);
});
+
+ registrySettingsApp();
});
diff --git a/app/assets/javascripts/pages/projects/snippets/show/index.js b/app/assets/javascripts/pages/projects/snippets/show/index.js
index c35b9c30058..738bf08f1bf 100644
--- a/app/assets/javascripts/pages/projects/snippets/show/index.js
+++ b/app/assets/javascripts/pages/projects/snippets/show/index.js
@@ -3,11 +3,16 @@ import ZenMode from '~/zen_mode';
import LineHighlighter from '~/line_highlighter';
import BlobViewer from '~/blob/viewer';
import snippetEmbed from '~/snippet/snippet_embed';
+import initSnippetsApp from '~/snippets';
document.addEventListener('DOMContentLoaded', () => {
- new LineHighlighter(); // eslint-disable-line no-new
- new BlobViewer(); // eslint-disable-line no-new
- initNotes();
- new ZenMode(); // eslint-disable-line no-new
- snippetEmbed();
+ if (!gon.features.snippetsVue) {
+ new LineHighlighter(); // eslint-disable-line no-new
+ new BlobViewer(); // eslint-disable-line no-new
+ initNotes();
+ new ZenMode(); // eslint-disable-line no-new
+ snippetEmbed();
+ } else {
+ initSnippetsApp();
+ }
});
diff --git a/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue b/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue
index b0c9ca3ec0d..2176309ac84 100644
--- a/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue
+++ b/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue
@@ -1,7 +1,7 @@
<script>
import _ from 'underscore';
-import { s__, sprintf } from '~/locale';
import { GlModal, GlModalDirective } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
export default {
components: {
diff --git a/app/assets/javascripts/pages/sessions/new/username_validator.js b/app/assets/javascripts/pages/sessions/new/username_validator.js
index 36d1e773134..25be71d9ed4 100644
--- a/app/assets/javascripts/pages/sessions/new/username_validator.js
+++ b/app/assets/javascripts/pages/sessions/new/username_validator.js
@@ -1,6 +1,6 @@
+import _ from 'underscore';
import InputValidator from '~/validators/input_validator';
-import _ from 'underscore';
import axios from '~/lib/utils/axios_utils';
import flash from '~/flash';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/pages/snippets/show/index.js b/app/assets/javascripts/pages/snippets/show/index.js
index 26936110402..6e00c14f43e 100644
--- a/app/assets/javascripts/pages/snippets/show/index.js
+++ b/app/assets/javascripts/pages/snippets/show/index.js
@@ -3,11 +3,16 @@ import BlobViewer from '~/blob/viewer';
import ZenMode from '~/zen_mode';
import initNotes from '~/init_notes';
import snippetEmbed from '~/snippet/snippet_embed';
+import initSnippetsApp from '~/snippets';
document.addEventListener('DOMContentLoaded', () => {
- new LineHighlighter(); // eslint-disable-line no-new
- new BlobViewer(); // eslint-disable-line no-new
- initNotes();
- new ZenMode(); // eslint-disable-line no-new
- snippetEmbed();
+ if (!gon.features.snippetsVue) {
+ new LineHighlighter(); // eslint-disable-line no-new
+ new BlobViewer(); // eslint-disable-line no-new
+ initNotes();
+ new ZenMode(); // eslint-disable-line no-new
+ snippetEmbed();
+ } else {
+ initSnippetsApp();
+ }
});
diff --git a/app/assets/javascripts/pages/users/index.js b/app/assets/javascripts/pages/users/index.js
index a191df00dfa..cfc6dc61f9f 100644
--- a/app/assets/javascripts/pages/users/index.js
+++ b/app/assets/javascripts/pages/users/index.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
-import UserCallout from '~/user_callout';
import Cookies from 'js-cookie';
+import UserCallout from '~/user_callout';
import UserTabs from './user_tabs';
function initUserProfile(action) {
diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
index 8ce653bf1fb..d17c2f33adc 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -52,6 +52,11 @@ export default {
header: s__('PerformanceBar|Redis calls'),
keys: ['cmd'],
},
+ {
+ metric: 'total',
+ header: s__('PerformanceBar|Frontend resources'),
+ keys: ['name', 'size'],
+ },
],
data() {
return { currentRequestId: '' };
@@ -80,6 +85,15 @@ export default {
}
return '';
},
+ downloadPath() {
+ const data = JSON.stringify(this.requests);
+ const blob = new Blob([data], { type: 'text/plain' });
+ return window.URL.createObjectURL(blob);
+ },
+ downloadName() {
+ const fileName = this.requests[0].truncatedUrl;
+ return `${fileName}_perf_bar_${Date.now()}.json`;
+ },
},
mounted() {
this.currentRequest = this.requestId;
@@ -121,6 +135,9 @@ export default {
<a :href="currentRequest.details.tracing.tracing_url">{{ s__('PerformanceBar|trace') }}</a>
</div>
<add-request v-on="$listeners" />
+ <div v-if="currentRequest.details" id="peek-download" class="view">
+ <a :download="downloadName" :href="downloadPath">{{ s__('PerformanceBar|Download') }}</a>
+ </div>
<request-selector
v-if="currentRequest"
:current-request="currentRequest"
diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue
index 793aba3189b..1610534ae0d 100644
--- a/app/assets/javascripts/performance_bar/components/request_selector.vue
+++ b/app/assets/javascripts/performance_bar/components/request_selector.vue
@@ -1,7 +1,7 @@
<script>
+import { GlPopover } from '@gitlab/ui';
import { glEmojiTag } from '~/emoji';
import { n__ } from '~/locale';
-import { GlPopover } from '@gitlab/ui';
export default {
components: {
@@ -40,16 +40,6 @@ export default {
},
},
methods: {
- truncatedUrl(requestUrl) {
- const components = requestUrl.replace(/\/$/, '').split('/');
- let truncated = components[components.length - 1];
-
- if (truncated.match(/^\d+$/)) {
- truncated = `${components[components.length - 2]}/${truncated}`;
- }
-
- return truncated;
- },
glEmojiTag,
},
};
@@ -63,7 +53,7 @@ export default {
:value="request.id"
class="qa-performance-bar-request"
>
- {{ truncatedUrl(request.url) }}
+ {{ request.truncatedUrl }}
<span v-if="request.hasWarnings">(!)</span>
</option>
</select>
diff --git a/app/assets/javascripts/performance_bar/components/request_warning.vue b/app/assets/javascripts/performance_bar/components/request_warning.vue
index 0da3c271214..0128d5bd733 100644
--- a/app/assets/javascripts/performance_bar/components/request_warning.vue
+++ b/app/assets/javascripts/performance_bar/components/request_warning.vue
@@ -1,6 +1,6 @@
<script>
-import { glEmojiTag } from '~/emoji';
import { GlPopover } from '@gitlab/ui';
+import { glEmojiTag } from '~/emoji';
export default {
components: {
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
index 735c9d804ee..7b373a8ce22 100644
--- a/app/assets/javascripts/performance_bar/index.js
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -1,3 +1,4 @@
+/* eslint-disable @gitlab/i18n/no-non-i18n-strings */
import Vue from 'vue';
import axios from '~/lib/utils/axios_utils';
@@ -53,12 +54,57 @@ export default ({ container }) =>
PerformanceBarService.fetchRequestDetails(this.peekUrl, requestId)
.then(res => {
this.store.addRequestDetails(requestId, res.data);
+
+ if (this.requestId === requestId) this.collectFrontendPerformanceMetrics();
})
.catch(() =>
// eslint-disable-next-line no-console
console.warn(`Error getting performance bar results for ${requestId}`),
);
},
+ collectFrontendPerformanceMetrics() {
+ if (performance) {
+ const navigationEntries = performance.getEntriesByType('navigation');
+ const paintEntries = performance.getEntriesByType('paint');
+ const resourceEntries = performance.getEntriesByType('resource');
+
+ let durationString = '';
+ if (navigationEntries.length > 0) {
+ durationString = `${Math.round(navigationEntries[0].responseEnd)} | `;
+ durationString += `${Math.round(paintEntries[1].startTime)} | `;
+ durationString += ` ${Math.round(navigationEntries[0].domContentLoadedEventEnd)}`;
+ }
+
+ let newEntries = resourceEntries.map(this.transformResourceEntry);
+
+ this.updateFrontendPerformanceMetrics(durationString, newEntries);
+
+ if ('PerformanceObserver' in window) {
+ // We start observing for more incoming timings
+ const observer = new PerformanceObserver(list => {
+ newEntries = newEntries.concat(list.getEntries().map(this.transformResourceEntry));
+ this.updateFrontendPerformanceMetrics(durationString, newEntries);
+ });
+
+ observer.observe({ entryTypes: ['resource'] });
+ }
+ }
+ },
+ updateFrontendPerformanceMetrics(durationString, requestEntries) {
+ this.store.setRequestDetailsData(this.requestId, 'total', {
+ duration: durationString,
+ calls: requestEntries.length,
+ details: requestEntries,
+ });
+ },
+ transformResourceEntry(entry) {
+ const nf = new Intl.NumberFormat();
+ return {
+ name: entry.name.replace(document.location.origin, ''),
+ duration: Math.round(entry.duration),
+ size: entry.transferSize ? `${nf.format(entry.transferSize)} bytes` : 'cached',
+ };
+ },
},
render(createElement) {
return createElement('performance-bar-app', {
diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
index 64f4f5e0c76..6f443db47ed 100644
--- a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
+++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
@@ -5,9 +5,12 @@ export default class PerformanceBarStore {
addRequest(requestId, requestUrl) {
if (!this.findRequest(requestId)) {
+ const shortUrl = PerformanceBarStore.truncateUrl(requestUrl);
+
this.requests.push({
id: requestId,
url: requestUrl,
+ truncatedUrl: shortUrl,
details: {},
hasWarnings: false,
});
@@ -29,6 +32,16 @@ export default class PerformanceBarStore {
return request;
}
+ setRequestDetailsData(requestId, metricKey, requestDetailsData) {
+ const selectedRequest = this.findRequest(requestId);
+ if (selectedRequest) {
+ selectedRequest.details = {
+ ...selectedRequest.details,
+ [metricKey]: requestDetailsData,
+ };
+ }
+ }
+
requestsWithDetails() {
return this.requests.filter(request => request.details);
}
@@ -36,4 +49,20 @@ export default class PerformanceBarStore {
canTrackRequest(requestUrl) {
return this.requests.filter(request => request.url === requestUrl).length < 2;
}
+
+ static truncateUrl(requestUrl) {
+ const [rootAndQuery] = requestUrl.split('#');
+ const [root, query] = rootAndQuery.split('?');
+ const components = root.replace(/\/$/, '').split('/');
+
+ let truncated = components[components.length - 1];
+ if (truncated.match(/^\d+$/)) {
+ truncated = `${components[components.length - 2]}/${truncated}`;
+ }
+ if (query) {
+ truncated += `?${query}`;
+ }
+
+ return truncated;
+ }
}
diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js
index 8d6a3781048..4598626718c 100644
--- a/app/assets/javascripts/persistent_user_callout.js
+++ b/app/assets/javascripts/persistent_user_callout.js
@@ -6,8 +6,8 @@ import Flash from './flash';
const DEFERRED_LINK_CLASS = 'deferred-link';
export default class PersistentUserCallout {
- constructor(container) {
- const { dismissEndpoint, featureId, deferLinks } = container.dataset;
+ constructor(container, options = container.dataset) {
+ const { dismissEndpoint, featureId, deferLinks } = options;
this.container = container;
this.dismissEndpoint = dismissEndpoint;
this.featureId = featureId;
@@ -53,11 +53,11 @@ export default class PersistentUserCallout {
});
}
- static factory(container) {
+ static factory(container, options) {
if (!container) {
return undefined;
}
- return new PersistentUserCallout(container);
+ return new PersistentUserCallout(container, options);
}
}
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index e29509ce074..429122c8083 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -100,9 +100,6 @@ export default {
hasOnlyOneJob(stage) {
return stage.groups.length === 1;
},
- hasDownstream(index, length) {
- return index === length - 1 && this.hasTriggered;
- },
hasUpstream(index) {
return index === 0 && this.hasTriggeredBy;
},
@@ -160,7 +157,6 @@ export default {
:key="stage.name"
:class="{
'has-upstream prepend-left-64': hasUpstream(index),
- 'has-downstream': hasDownstream(index, graph.length),
'has-only-one-job': hasOnlyOneJob(stage),
'append-right-46': shouldAddRightMargin(index),
}"
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 0d5afe04e8e..bfd314e0439 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -1,7 +1,7 @@
<script>
+import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import ActionComponent from './action_component.vue';
import JobNameComponent from './job_name_component.vue';
-import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import { sprintf } from '~/locale';
import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
@@ -111,7 +111,7 @@ export default {
:href="status.details_path"
:title="tooltipText"
:class="cssClassJobName"
- class="js-pipeline-graph-job-link qa-job-link"
+ class="js-pipeline-graph-job-link qa-job-link menu-item"
>
<job-name-component :name="job.name" :status="job.status" />
</gl-link>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
index 4e7d77863b9..82335e71403 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
@@ -42,7 +42,6 @@ export default {
<template>
<li class="linked-pipeline build">
- <div class="curve"></div>
<gl-button
:id="buttonId"
v-gl-tooltip
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
index 6efdde2b17e..998519f9df1 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
@@ -1,5 +1,6 @@
<script>
import LinkedPipeline from './linked_pipeline.vue';
+import { __ } from '~/locale';
export default {
components: {
@@ -27,6 +28,9 @@ export default {
};
return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`;
},
+ isUpstream() {
+ return this.columnTitle === __('Upstream');
+ },
},
};
</script>
@@ -34,13 +38,12 @@ export default {
<template>
<div :class="columnClass" class="stage-column linked-pipelines-column">
<div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div>
- <div class="cross-project-triangle"></div>
+ <div v-if="isUpstream" class="cross-project-triangle"></div>
<ul>
<linked-pipeline
v-for="(pipeline, index) in linkedPipelines"
:key="pipeline.id"
:class="{
- 'flat-connector-before': index === 0 && graphPosition === 'right',
active: pipeline.isExpanded,
'left-connector': pipeline.isExpanded && graphPosition === 'left',
}"
diff --git a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue
index 2e71b3c637b..7c4e651373f 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue
@@ -1,7 +1,7 @@
<script>
import _ from 'underscore';
-import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { GlLink } from '@gitlab/ui';
+import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { s__, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
index dce8b020d6f..1bac7ce9ac5 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
@@ -28,7 +28,9 @@ export default {
return this.report.name || __('Summary');
},
successPercentage() {
- return Math.round((this.report.success_count / this.report.total_count) * 100) || 0;
+ // Returns a full number when the decimals equal .00.
+ // Otherwise returns a float to two decimal points
+ return Number(((this.report.success_count / this.report.total_count) * 100 || 0).toFixed(2));
},
formattedDuration() {
return formatTime(secondsToMilliseconds(this.report.total_time));
diff --git a/app/assets/javascripts/pipelines/components/time_ago.vue b/app/assets/javascripts/pipelines/components/time_ago.vue
index 2ed0c24825c..2a23a0f6744 100644
--- a/app/assets/javascripts/pipelines/components/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/time_ago.vue
@@ -31,7 +31,7 @@ export default {
hasFinishedTime() {
return this.finishedTime !== '';
},
- durationFormated() {
+ durationFormatted() {
const date = new Date(this.duration * 1000);
let hh = date.getUTCHours();
@@ -59,7 +59,7 @@ export default {
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Duration') }}</div>
<div class="table-mobile-content">
<p v-if="hasDuration" class="duration">
- <span v-html="iconTimerSvg"> </span> {{ durationFormated }}
+ <span v-html="iconTimerSvg"> </span> {{ durationFormatted }}
</p>
<p v-if="hasFinishedTime" class="finished-at d-none d-sm-none d-md-block">
@@ -71,7 +71,7 @@ export default {
data-placement="top"
data-container="body"
>
- {{ timeFormated(finishedTime) }}
+ {{ timeFormatted(finishedTime) }}
</time>
</p>
</div>
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/utils.js b/app/assets/javascripts/pipelines/stores/test_reports/utils.js
index 95466587d6b..16fa6935cbe 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/utils.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/utils.js
@@ -1,7 +1,7 @@
import { TestStatus } from '~/pipelines/constants';
import { formatTime, secondsToMilliseconds } from '~/lib/utils/datetime_utility';
-function iconForTestStatus(status) {
+export function iconForTestStatus(status) {
switch (status) {
case 'success':
return 'status_success_borderless';
diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js
index 031c54d2336..d6cdd37a2c3 100644
--- a/app/assets/javascripts/project_find_file.js
+++ b/app/assets/javascripts/project_find_file.js
@@ -2,10 +2,11 @@
import $ from 'jquery';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import sanitize from 'sanitize-html';
import axios from '~/lib/utils/axios_utils';
+import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
import flash from '~/flash';
import { __ } from '~/locale';
-import sanitize from 'sanitize-html';
// highlight text(awefwbwgtc -> <b>a</b>wefw<b>b</b>wgt<b>c</b> )
const highlighter = function(element, text, matches) {
@@ -116,7 +117,7 @@ export default class ProjectFindFile {
if (searchText) {
matches = fuzzaldrinPlus.match(filePath, searchText);
}
- const blobItemUrl = `${this.options.blobUrlTemplate}/${encodeURIComponent(filePath)}`;
+ const blobItemUrl = joinPaths(this.options.blobUrlTemplate, escapeFileUrl(filePath));
const html = ProjectFindFile.makeHtml(filePath, matches, blobItemUrl);
results.push(this.element.find('.tree-table > tbody').append(html));
}
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index 2429da9c061..92c4c05bd87 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -128,15 +128,15 @@ const bindEvents = () => {
},
iosswift: {
text: s__('ProjectTemplates|iOS (Swift)'),
- icon: '.template-option svg.icon-gitlab',
+ icon: '.template-option .icon-iosswift',
},
dotnetcore: {
text: s__('ProjectTemplates|.NET Core'),
- icon: '.template-option .icon-dotnet',
+ icon: '.template-option .icon-dotnetcore',
},
android: {
text: s__('ProjectTemplates|Android'),
- icon: '.template-option svg.icon-android',
+ icon: '.template-option .icon-android',
},
gomicro: {
text: s__('ProjectTemplates|Go Micro'),
@@ -164,23 +164,27 @@ const bindEvents = () => {
},
nfhugo: {
text: s__('ProjectTemplates|Netlify/Hugo'),
- icon: '.template-option .icon-netlify',
+ icon: '.template-option .icon-nfhugo',
},
nfjekyll: {
text: s__('ProjectTemplates|Netlify/Jekyll'),
- icon: '.template-option .icon-netlify',
+ icon: '.template-option .icon-nfjekyll',
},
nfplainhtml: {
text: s__('ProjectTemplates|Netlify/Plain HTML'),
- icon: '.template-option .icon-netlify',
+ icon: '.template-option .icon-nfplainhtml',
},
nfgitbook: {
text: s__('ProjectTemplates|Netlify/GitBook'),
- icon: '.template-option .icon-netlify',
+ icon: '.template-option .icon-nfgitbook',
},
nfhexo: {
text: s__('ProjectTemplates|Netlify/Hexo'),
- icon: '.template-option .icon-netlify',
+ icon: '.template-option .icon-nfhexo',
+ },
+ salesforcedx: {
+ text: s__('ProjectTemplates|SalesforceDX'),
+ icon: '.template-option .icon-salesforcedx',
},
serverless_framework: {
text: s__('ProjectTemplates|Serverless Framework/JS'),
diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
index 60fd3ed5ea7..f1106dc6ae9 100644
--- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
+++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
@@ -1,11 +1,11 @@
<script>
import Visibility from 'visibilityjs';
+import { GlLoadingIcon } from '@gitlab/ui';
import ciIcon from '~/vue_shared/components/ci_icon.vue';
import Poll from '~/lib/utils/poll';
import Flash from '~/flash';
import { __, s__, sprintf } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
-import { GlLoadingIcon } from '@gitlab/ui';
import CommitPipelineService from '../services/commit_pipeline_service';
export default {
diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/list/components/app.vue
index 11b2c3b7016..c555c2b04d1 100644
--- a/app/assets/javascripts/registry/components/app.vue
+++ b/app/assets/javascripts/registry/list/components/app.vue
@@ -5,7 +5,7 @@ import store from '../stores';
import CollapsibleContainer from './collapsible_container.vue';
import ProjectEmptyState from './project_empty_state.vue';
import GroupEmptyState from './group_empty_state.vue';
-import { s__, sprintf } from '../../locale';
+import { s__, sprintf } from '~/locale';
export default {
name: 'RegistryListApp',
diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/list/components/collapsible_container.vue
index 5a6f9370564..86bb2d8092e 100644
--- a/app/assets/javascripts/registry/components/collapsible_container.vue
+++ b/app/assets/javascripts/registry/list/components/collapsible_container.vue
@@ -31,7 +31,7 @@ export default {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
- mixins: [Tracking.mixin({})],
+ mixins: [Tracking.mixin()],
props: {
repo: {
type: Object,
@@ -43,7 +43,6 @@ export default {
isOpen: false,
modalId: `confirm-repo-deletion-modal-${this.repo.id}`,
tracking: {
- category: document.body.dataset.page,
label: 'registry_repository_delete',
},
};
@@ -67,7 +66,7 @@ export default {
}
},
handleDeleteRepository() {
- this.track('confirm_delete', {});
+ this.track('confirm_delete');
return this.deleteItem(this.repo)
.then(() => {
createFlash(__('This container registry has been scheduled for deletion.'), 'notice');
@@ -103,7 +102,7 @@ export default {
:aria-label="s__('ContainerRegistry|Remove repository')"
class="js-remove-repo btn-inverted"
variant="danger"
- @click="track('click_button', {})"
+ @click="track('click_button')"
>
<icon name="remove" />
</gl-button>
@@ -132,7 +131,7 @@ export default {
:modal-id="modalId"
ok-variant="danger"
@ok="handleDeleteRepository"
- @cancel="track('cancel_delete', {})"
+ @cancel="track('cancel_delete')"
>
<template v-slot:modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template>
<p
diff --git a/app/assets/javascripts/registry/components/group_empty_state.vue b/app/assets/javascripts/registry/list/components/group_empty_state.vue
index 7885fd2146d..7885fd2146d 100644
--- a/app/assets/javascripts/registry/components/group_empty_state.vue
+++ b/app/assets/javascripts/registry/list/components/group_empty_state.vue
diff --git a/app/assets/javascripts/registry/components/project_empty_state.vue b/app/assets/javascripts/registry/list/components/project_empty_state.vue
index 80ef31004c8..80ef31004c8 100644
--- a/app/assets/javascripts/registry/components/project_empty_state.vue
+++ b/app/assets/javascripts/registry/list/components/project_empty_state.vue
diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/list/components/table_registry.vue
index caa5fd4ff4e..4e14db7f578 100644
--- a/app/assets/javascripts/registry/components/table_registry.vue
+++ b/app/assets/javascripts/registry/list/components/table_registry.vue
@@ -23,7 +23,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [timeagoMixin],
+ mixins: [timeagoMixin, Tracking.mixin()],
props: {
repo: {
type: Object,
@@ -71,9 +71,6 @@ export default {
},
methods: {
...mapActions(['fetchList', 'deleteItem', 'multiDeleteItems']),
- track(action) {
- Tracking.event(document.body.dataset.page, action, this.tracking);
- },
setModalDescription(itemIndex = -1) {
if (itemIndex === -1) {
this.modalDescription = sprintf(
@@ -196,7 +193,7 @@ export default {
/>
</th>
<th>{{ s__('ContainerRegistry|Tag') }}</th>
- <th>{{ s__('ContainerRegistry|Tag ID') }}</th>
+ <th ref="imageId">{{ s__('ContainerRegistry|Image ID') }}</th>
<th>{{ s__('ContainerRegistry|Size') }}</th>
<th>{{ s__('ContainerRegistry|Last Updated') }}</th>
<th>
@@ -250,7 +247,7 @@ export default {
<td>
<span v-gl-tooltip.bottom :title="tooltipTitle(item.createdAt)">{{
- timeFormated(item.createdAt)
+ timeFormatted(item.createdAt)
}}</span>
</td>
diff --git a/app/assets/javascripts/registry/constants.js b/app/assets/javascripts/registry/list/constants.js
index db798fb88ac..e55ea9cc9d9 100644
--- a/app/assets/javascripts/registry/constants.js
+++ b/app/assets/javascripts/registry/list/constants.js
@@ -1,4 +1,4 @@
-import { __ } from '../locale';
+import { __ } from '~/locale';
export const FETCH_REGISTRY_ERROR_MESSAGE = __(
'Something went wrong while fetching the registry list.',
diff --git a/app/assets/javascripts/registry/index.js b/app/assets/javascripts/registry/list/index.js
index 18fd360f586..3d0ff327b42 100644
--- a/app/assets/javascripts/registry/index.js
+++ b/app/assets/javascripts/registry/list/index.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import registryApp from './components/app.vue';
-import Translate from '../vue_shared/translate';
+import Translate from '~/vue_shared/translate';
Vue.use(Translate);
diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/list/stores/actions.js
index 6afba618486..6afba618486 100644
--- a/app/assets/javascripts/registry/stores/actions.js
+++ b/app/assets/javascripts/registry/list/stores/actions.js
diff --git a/app/assets/javascripts/registry/stores/getters.js b/app/assets/javascripts/registry/list/stores/getters.js
index ac90bde1b2a..ac90bde1b2a 100644
--- a/app/assets/javascripts/registry/stores/getters.js
+++ b/app/assets/javascripts/registry/list/stores/getters.js
diff --git a/app/assets/javascripts/registry/stores/index.js b/app/assets/javascripts/registry/list/stores/index.js
index 1bb06bd6e81..1bb06bd6e81 100644
--- a/app/assets/javascripts/registry/stores/index.js
+++ b/app/assets/javascripts/registry/list/stores/index.js
diff --git a/app/assets/javascripts/registry/stores/mutation_types.js b/app/assets/javascripts/registry/list/stores/mutation_types.js
index 6740bfede1a..6740bfede1a 100644
--- a/app/assets/javascripts/registry/stores/mutation_types.js
+++ b/app/assets/javascripts/registry/list/stores/mutation_types.js
diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/list/stores/mutations.js
index 419de848883..419de848883 100644
--- a/app/assets/javascripts/registry/stores/mutations.js
+++ b/app/assets/javascripts/registry/list/stores/mutations.js
diff --git a/app/assets/javascripts/registry/stores/state.js b/app/assets/javascripts/registry/list/stores/state.js
index 724c64b4994..724c64b4994 100644
--- a/app/assets/javascripts/registry/stores/state.js
+++ b/app/assets/javascripts/registry/list/stores/state.js
diff --git a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue
new file mode 100644
index 00000000000..b2c700b817c
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue
@@ -0,0 +1,43 @@
+<script>
+import { mapState } from 'vuex';
+import { s__, sprintf } from '~/locale';
+
+export default {
+ components: {},
+ computed: {
+ ...mapState({
+ helpPagePath: 'helpPagePath',
+ }),
+
+ helpText() {
+ return sprintf(
+ s__(
+ 'PackageRegistry|Read more about the %{helpLinkStart}Container Registry tag retention policies%{helpLinkEnd}',
+ ),
+ {
+ helpLinkStart: `<a href="${this.helpPagePath}" target="_blank">`,
+ helpLinkEnd: '</a>',
+ },
+ false,
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <p>
+ {{ s__('PackageRegistry|Tag retention policies are designed to:') }}
+ </p>
+ <ul>
+ <li>{{ s__('PackageRegistry|Keep and protect the images that matter most.') }}</li>
+ <li>
+ {{
+ s__("PackageRegistry|Automatically remove extra images that aren't designed to be kept.")
+ }}
+ </li>
+ </ul>
+ <p ref="help-link" v-html="helpText"></p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js
new file mode 100644
index 00000000000..2938178ea86
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import store from './stores/';
+import RegistrySettingsApp from './components/registry_settings_app.vue';
+
+Vue.use(Translate);
+
+export default () => {
+ const el = document.getElementById('js-registry-settings');
+ if (!el) {
+ return null;
+ }
+ store.dispatch('setInitialState', el.dataset);
+ return new Vue({
+ el,
+ store,
+ components: {
+ RegistrySettingsApp,
+ },
+ render(createElement) {
+ return createElement('registry-settings-app', {});
+ },
+ });
+};
diff --git a/app/assets/javascripts/registry/settings/stores/actions.js b/app/assets/javascripts/registry/settings/stores/actions.js
new file mode 100644
index 00000000000..f2c469d4edb
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/stores/actions.js
@@ -0,0 +1,6 @@
+import * as types from './mutation_types';
+
+export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data);
+
+// to avoid eslint error until more actions are added to the store
+export default () => {};
diff --git a/app/assets/javascripts/registry/settings/stores/index.js b/app/assets/javascripts/registry/settings/stores/index.js
new file mode 100644
index 00000000000..91a35aac149
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/stores/index.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import mutations from './mutations';
+import state from './state';
+
+Vue.use(Vuex);
+
+export const createStore = () =>
+ new Vuex.Store({
+ state,
+ actions,
+ mutations,
+ });
+
+export default createStore();
diff --git a/app/assets/javascripts/registry/settings/stores/mutation_types.js b/app/assets/javascripts/registry/settings/stores/mutation_types.js
new file mode 100644
index 00000000000..8a0f519eabd
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/stores/mutation_types.js
@@ -0,0 +1,4 @@
+export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
+
+// to avoid eslint error until more actions are added to the store
+export default () => {};
diff --git a/app/assets/javascripts/registry/settings/stores/mutations.js b/app/assets/javascripts/registry/settings/stores/mutations.js
new file mode 100644
index 00000000000..4f32e11ed52
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/stores/mutations.js
@@ -0,0 +1,8 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_INITIAL_STATE](state, initialState) {
+ state.helpPagePath = initialState.helpPagePath;
+ state.registrySettingsEndpoint = initialState.registrySettingsEndpoint;
+ },
+};
diff --git a/app/assets/javascripts/registry/settings/stores/state.js b/app/assets/javascripts/registry/settings/stores/state.js
new file mode 100644
index 00000000000..4c0439458b6
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/stores/state.js
@@ -0,0 +1,10 @@
+export default () => ({
+ /*
+ * Help page path to generate the link
+ */
+ helpPagePath: '',
+ /*
+ * Settings endpoint to call to fetch and update the settings
+ */
+ registrySettingsEndpoint: '',
+});
diff --git a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
index f0112a5a623..dc7c9d9f174 100644
--- a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
+++ b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
@@ -72,7 +72,7 @@ export default {
{{ __('Related merge requests') }}
</span>
<div v-if="totalCount" class="d-inline-flex lh-100 align-middle">
- <div class="mr-count-badge">
+ <div class="mr-count-badge border-width-1px border-style-solid border-color-default">
<div class="mr-count-badge-count">
<svg class="s16 mr-1 text-secondary">
<icon name="merge-request" class="mr-1 text-secondary" />
diff --git a/app/assets/javascripts/releases/list/components/app.vue b/app/assets/javascripts/releases/list/components/app.vue
index 5a06c4fec58..a414b3ccd4e 100644
--- a/app/assets/javascripts/releases/list/components/app.vue
+++ b/app/assets/javascripts/releases/list/components/app.vue
@@ -1,6 +1,12 @@
<script>
import { mapState, mapActions } from 'vuex';
import { GlSkeletonLoading, GlEmptyState } from '@gitlab/ui';
+import {
+ getParameterByName,
+ historyPushState,
+ buildUrlWithCurrentLocation,
+} from '~/lib/utils/common_utils';
+import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import ReleaseBlock from './release_block.vue';
export default {
@@ -9,6 +15,7 @@ export default {
GlSkeletonLoading,
GlEmptyState,
ReleaseBlock,
+ TablePagination,
},
props: {
projectId: {
@@ -25,7 +32,7 @@ export default {
},
},
computed: {
- ...mapState(['isLoading', 'releases', 'hasError']),
+ ...mapState(['isLoading', 'releases', 'hasError', 'pageInfo']),
shouldRenderEmptyState() {
return !this.releases.length && !this.hasError && !this.isLoading;
},
@@ -34,10 +41,17 @@ export default {
},
},
created() {
- this.fetchReleases(this.projectId);
+ this.fetchReleases({
+ page: getParameterByName('page'),
+ projectId: this.projectId,
+ });
},
methods: {
...mapActions(['fetchReleases']),
+ onChangePage(page) {
+ historyPushState(buildUrlWithCurrentLocation(`?page=${page}`));
+ this.fetchReleases({ page, projectId: this.projectId });
+ },
},
};
</script>
@@ -67,6 +81,8 @@ export default {
:class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
/>
</div>
+
+ <table-pagination v-if="!isLoading" :change="onChangePage" :page-info="pageInfo" />
</div>
</template>
<style>
diff --git a/app/assets/javascripts/releases/list/components/evidence_block.vue b/app/assets/javascripts/releases/list/components/evidence_block.vue
new file mode 100644
index 00000000000..d9abd195fee
--- /dev/null
+++ b/app/assets/javascripts/releases/list/components/evidence_block.vue
@@ -0,0 +1,76 @@
+<script>
+import { GlLink, GlTooltipDirective } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import { truncateSha } from '~/lib/utils/text_utility';
+import Icon from '~/vue_shared/components/icon.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import ExpandButton from '~/vue_shared/components/expand_button.vue';
+
+export default {
+ name: 'EvidenceBlock',
+ components: {
+ ClipboardButton,
+ ExpandButton,
+ GlLink,
+ Icon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ release: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ evidenceTitle() {
+ return sprintf(__('%{tag}-evidence.json'), { tag: this.release.tag_name });
+ },
+ evidenceUrl() {
+ return this.release.assets && this.release.assets.evidence_file_path;
+ },
+ shortSha() {
+ return truncateSha(this.sha);
+ },
+ sha() {
+ return this.release.evidence_sha;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="card-text prepend-top-default">
+ <b>
+ {{ __('Evidence collection') }}
+ </b>
+ </div>
+ <div class="d-flex align-items-baseline">
+ <gl-link
+ v-gl-tooltip
+ class="monospace"
+ :title="__('Download evidence JSON')"
+ :download="evidenceTitle"
+ :href="evidenceUrl"
+ >
+ <icon name="review-list" class="align-top append-right-4" /><span>{{ evidenceTitle }}</span>
+ </gl-link>
+
+ <expand-button>
+ <template slot="short">
+ <span class="js-short monospace">{{ shortSha }}</span>
+ </template>
+ <template slot="expanded">
+ <span class="js-expanded monospace gl-pl-1">{{ sha }}</span>
+ </template>
+ </expand-button>
+ <clipboard-button
+ :title="__('Copy commit SHA')"
+ :text="sha"
+ css-class="btn-default btn-transparent btn-clipboard"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/releases/list/components/release_block.vue b/app/assets/javascripts/releases/list/components/release_block.vue
index 2b6aa6aeff9..4d8d8682401 100644
--- a/app/assets/javascripts/releases/list/components/release_block.vue
+++ b/app/assets/javascripts/releases/list/components/release_block.vue
@@ -11,16 +11,20 @@ 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 ReleaseBlockFooter from './release_block_footer.vue';
+import EvidenceBlock from './evidence_block.vue';
+import ReleaseBlockMilestoneInfo from './release_block_milestone_info.vue';
export default {
name: 'ReleaseBlock',
components: {
+ EvidenceBlock,
GlLink,
GlBadge,
GlButton,
Icon,
UserAvatarLink,
ReleaseBlockFooter,
+ ReleaseBlockMilestoneInfo,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -44,7 +48,7 @@ export default {
},
releasedTimeAgo() {
return sprintf(__('released %{time}'), {
- time: this.timeFormated(this.release.released_at),
+ time: this.timeFormatted(this.release.released_at),
});
},
userImageAltDescription() {
@@ -70,6 +74,9 @@ export default {
hasAuthor() {
return !_.isEmpty(this.author);
},
+ hasEvidence() {
+ return Boolean(this.release.evidence_sha);
+ },
shouldRenderMilestones() {
return !_.isEmpty(this.release.milestones);
},
@@ -77,13 +84,20 @@ export default {
return n__('Milestone', 'Milestones', this.release.milestones.length);
},
shouldShowEditButton() {
- return Boolean(
- this.glFeatures.releaseEditPage && this.release._links && this.release._links.edit_url,
- );
+ return Boolean(this.release._links && this.release._links.edit_url);
+ },
+ shouldShowEvidence() {
+ return this.glFeatures.releaseEvidenceCollection;
},
shouldShowFooter() {
return this.glFeatures.releaseIssueSummary;
},
+ shouldRenderReleaseMetaData() {
+ return !this.glFeatures.releaseIssueSummary;
+ },
+ shouldRenderMilestoneInfo() {
+ return Boolean(this.glFeatures.releaseIssueSummary && !_.isEmpty(this.release.milestones));
+ },
},
mounted() {
const hash = getLocationHash();
@@ -100,26 +114,30 @@ export default {
</script>
<template>
<div :id="id" :class="{ 'bg-line-target-blue': isHighlighted }" class="card release-block">
+ <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-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{
+ __('Upcoming Release')
+ }}</gl-badge>
+ </h2>
+ <gl-link
+ v-if="shouldShowEditButton"
+ v-gl-tooltip
+ class="btn btn-default append-right-10 js-edit-button ml-2"
+ :title="__('Edit this release')"
+ :href="release._links.edit_url"
+ >
+ <icon name="pencil" />
+ </gl-link>
+ </div>
<div class="card-body">
- <div class="d-flex align-items-start">
- <h2 class="card-title mt-0 mr-auto">
- {{ release.name }}
- <gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{
- __('Upcoming Release')
- }}</gl-badge>
- </h2>
- <gl-link
- v-if="shouldShowEditButton"
- v-gl-tooltip
- class="btn btn-default js-edit-button ml-2"
- :title="__('Edit this release')"
- :href="release._links.edit_url"
- >
- <icon name="pencil" />
- </gl-link>
+ <div v-if="shouldRenderMilestoneInfo">
+ <release-block-milestone-info :milestones="release.milestones" />
+ <hr class="mb-3 mt-0" />
</div>
- <div class="card-subtitle d-flex flex-wrap text-secondary">
+ <div v-if="shouldRenderReleaseMetaData" class="card-subtitle d-flex flex-wrap text-secondary">
<div class="append-right-8">
<icon name="commit" class="align-middle" />
<gl-link v-if="commitUrl" v-gl-tooltip.bottom :title="commit.title" :href="commitUrl">
@@ -217,6 +235,8 @@ export default {
</div>
</div>
+ <evidence-block v-if="hasEvidence && shouldShowEvidence" :release="release" />
+
<div class="card-text prepend-top-default">
<div v-html="release.description_html"></div>
</div>
diff --git a/app/assets/javascripts/releases/list/components/release_block_footer.vue b/app/assets/javascripts/releases/list/components/release_block_footer.vue
index 5659f0e530b..8533fc17ffd 100644
--- a/app/assets/javascripts/releases/list/components/release_block_footer.vue
+++ b/app/assets/javascripts/releases/list/components/release_block_footer.vue
@@ -1,6 +1,6 @@
<script>
-import Icon from '~/vue_shared/components/icon.vue';
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { __, sprintf } from '~/locale';
@@ -50,7 +50,7 @@ export default {
},
computed: {
releasedAtTimeAgo() {
- return this.timeFormated(this.releasedAt);
+ return this.timeFormatted(this.releasedAt);
},
userImageAltDescription() {
return this.author && this.author.username
diff --git a/app/assets/javascripts/releases/list/components/release_block_milestone_info.vue b/app/assets/javascripts/releases/list/components/release_block_milestone_info.vue
new file mode 100644
index 00000000000..d3e354d6157
--- /dev/null
+++ b/app/assets/javascripts/releases/list/components/release_block_milestone_info.vue
@@ -0,0 +1,136 @@
+<script>
+import { GlProgressBar, GlLink, GlBadge, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { __, n__, sprintf } from '~/locale';
+import { MAX_MILESTONES_TO_DISPLAY } from '../constants';
+
+/** Sums the values of an array. For use with Array.reduce. */
+const sumReducer = (acc, curr) => acc + curr;
+
+export default {
+ name: 'ReleaseBlockMilestoneInfo',
+ components: {
+ GlProgressBar,
+ GlLink,
+ GlBadge,
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ milestones: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ showAllMilestones: false,
+ };
+ },
+ computed: {
+ percentCompleteText() {
+ return sprintf(__('%{percent}%{percentSymbol} complete'), {
+ percent: this.percentComplete,
+ percentSymbol: '%',
+ });
+ },
+ percentComplete() {
+ const percent = Math.round((this.closedIssuesCount / this.totalIssuesCount) * 100);
+ return Number.isNaN(percent) ? 0 : percent;
+ },
+ allIssueStats() {
+ return this.milestones.map(m => m.issue_stats || {});
+ },
+ openIssuesCount() {
+ return this.allIssueStats.map(stats => stats.opened || 0).reduce(sumReducer);
+ },
+ closedIssuesCount() {
+ return this.allIssueStats.map(stats => stats.closed || 0).reduce(sumReducer);
+ },
+ totalIssuesCount() {
+ return this.openIssuesCount + this.closedIssuesCount;
+ },
+ milestoneLabelText() {
+ return n__('Milestone', 'Milestones', this.milestones.length);
+ },
+ issueCountsText() {
+ return sprintf(__('Open: %{open} • Closed: %{closed}'), {
+ open: this.openIssuesCount,
+ closed: this.closedIssuesCount,
+ });
+ },
+ milestonesToDisplay() {
+ return this.showAllMilestones
+ ? this.milestones
+ : this.milestones.slice(0, MAX_MILESTONES_TO_DISPLAY);
+ },
+ showMoreLink() {
+ return this.milestones.length > MAX_MILESTONES_TO_DISPLAY;
+ },
+ moreText() {
+ return this.showAllMilestones
+ ? __('show fewer')
+ : sprintf(__('show %{count} more'), {
+ count: this.milestones.length - MAX_MILESTONES_TO_DISPLAY,
+ });
+ },
+ },
+ methods: {
+ toggleShowAll() {
+ this.showAllMilestones = !this.showAllMilestones;
+ },
+ shouldRenderBullet(milestoneIndex) {
+ return Boolean(milestoneIndex !== this.milestonesToDisplay.length - 1 || this.showMoreLink);
+ },
+ shouldRenderShowMoreLink(milestoneIndex) {
+ return Boolean(milestoneIndex === this.milestonesToDisplay.length - 1 && this.showMoreLink);
+ },
+ },
+};
+</script>
+<template>
+ <div class="release-block-milestone-info d-flex align-items-start flex-wrap">
+ <div
+ v-gl-tooltip
+ class="milestone-progress-bar-container js-milestone-progress-bar-container d-flex flex-column align-items-start flex-shrink-1 mr-4 mb-3"
+ :title="__('Closed issues')"
+ >
+ <span class="mb-2">{{ percentCompleteText }}</span>
+ <span class="w-100">
+ <gl-progress-bar :value="closedIssuesCount" :max="totalIssuesCount" variant="success" />
+ </span>
+ </div>
+ <div class="d-flex flex-column align-items-start mr-4 mb-3 js-milestone-list-container">
+ <span class="mb-1">{{ milestoneLabelText }}</span>
+ <div class="d-flex flex-wrap align-items-end">
+ <template v-for="(milestone, index) in milestonesToDisplay">
+ <gl-link
+ :key="milestone.id"
+ v-gl-tooltip
+ :title="milestone.description"
+ :href="milestone.web_url"
+ class="append-right-4"
+ >
+ {{ milestone.title }}
+ </gl-link>
+ <template v-if="shouldRenderBullet(index)">
+ <span :key="'bullet-' + milestone.id" class="append-right-4">&bull;</span>
+ </template>
+ <template v-if="shouldRenderShowMoreLink(index)">
+ <gl-button :key="'more-button-' + milestone.id" variant="link" @click="toggleShowAll">
+ {{ moreText }}
+ </gl-button>
+ </template>
+ </template>
+ </div>
+ </div>
+ <div class="d-flex flex-column align-items-start flex-shrink-0 mr-4 mb-3 js-issues-container">
+ <span class="mb-1">
+ {{ __('Issues') }}
+ <gl-badge pill variant="light" class="font-weight-bold">{{ totalIssuesCount }}</gl-badge>
+ </span>
+ {{ issueCountsText }}
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/releases/list/constants.js b/app/assets/javascripts/releases/list/constants.js
new file mode 100644
index 00000000000..defcd917465
--- /dev/null
+++ b/app/assets/javascripts/releases/list/constants.js
@@ -0,0 +1,7 @@
+/* eslint-disable import/prefer-default-export */
+// This eslint-disable ^^^ can be removed when at least
+// one more constant is added to this file. Currently
+// constants.js files with only a single constant
+// are flagged by this rule.
+
+export const MAX_MILESTONES_TO_DISPLAY = 5;
diff --git a/app/assets/javascripts/releases/list/store/actions.js b/app/assets/javascripts/releases/list/store/actions.js
index e0a922d5ef6..b15fb69226f 100644
--- a/app/assets/javascripts/releases/list/store/actions.js
+++ b/app/assets/javascripts/releases/list/store/actions.js
@@ -2,6 +2,7 @@ import * as types from './mutation_types';
import createFlash from '~/flash';
import { __ } from '~/locale';
import api from '~/api';
+import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
/**
* Commits a mutation to update the state while the main endpoint is being requested.
@@ -16,17 +17,19 @@ export const requestReleases = ({ commit }) => commit(types.REQUEST_RELEASES);
*
* @param {String} projectId
*/
-export const fetchReleases = ({ dispatch }, projectId) => {
+export const fetchReleases = ({ dispatch }, { page = '1', projectId }) => {
dispatch('requestReleases');
api
- .releases(projectId)
- .then(({ data }) => dispatch('receiveReleasesSuccess', data))
+ .releases(projectId, { page })
+ .then(response => dispatch('receiveReleasesSuccess', response))
.catch(() => dispatch('receiveReleasesError'));
};
-export const receiveReleasesSuccess = ({ commit }, data) =>
- commit(types.RECEIVE_RELEASES_SUCCESS, data);
+export const receiveReleasesSuccess = ({ commit }, { data, headers }) => {
+ const pageInfo = parseIntPagination(normalizeHeaders(headers));
+ commit(types.RECEIVE_RELEASES_SUCCESS, { data, pageInfo });
+};
export const receiveReleasesError = ({ commit }) => {
commit(types.RECEIVE_RELEASES_ERROR);
diff --git a/app/assets/javascripts/releases/list/store/mutations.js b/app/assets/javascripts/releases/list/store/mutations.js
index b97dc6cb0ab..99fc096264a 100644
--- a/app/assets/javascripts/releases/list/store/mutations.js
+++ b/app/assets/javascripts/releases/list/store/mutations.js
@@ -13,13 +13,15 @@ export default {
* Sets isLoading to false.
* Sets hasError to false.
* Sets the received data
+ * Sets the received pagination information
* @param {Object} state
- * @param {Object} data
+ * @param {Object} resp
*/
- [types.RECEIVE_RELEASES_SUCCESS](state, data) {
+ [types.RECEIVE_RELEASES_SUCCESS](state, { data, pageInfo }) {
state.hasError = false;
state.isLoading = false;
state.releases = data;
+ state.pageInfo = pageInfo;
},
/**
diff --git a/app/assets/javascripts/releases/list/store/state.js b/app/assets/javascripts/releases/list/store/state.js
index bf25e651c99..c251f56c9c5 100644
--- a/app/assets/javascripts/releases/list/store/state.js
+++ b/app/assets/javascripts/releases/list/store/state.js
@@ -2,4 +2,5 @@ export default () => ({
isLoading: false,
hasError: false,
releases: [],
+ pageInfo: {},
});
diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/reports/components/report_item.vue
index 3c8a9e6ebef..51062cd7928 100644
--- a/app/assets/javascripts/reports/components/report_item.vue
+++ b/app/assets/javascripts/reports/components/report_item.vue
@@ -1,6 +1,6 @@
<script>
-import IssueStatusIcon from '~/reports/components/issue_status_icon.vue';
import { components, componentNames } from 'ee_else_ce/reports/components/issue_body';
+import IssueStatusIcon from '~/reports/components/issue_status_icon.vue';
export default {
name: 'ReportItem',
diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue
index aba798e63d0..1191e43d0d9 100644
--- a/app/assets/javascripts/reports/components/summary_row.vue
+++ b/app/assets/javascripts/reports/components/summary_row.vue
@@ -1,7 +1,7 @@
<script>
+import { GlLoadingIcon } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import Popover from '~/vue_shared/components/help_popover.vue';
-import { GlLoadingIcon } from '@gitlab/ui';
/**
* Renders the summary row for each report
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index afb58a60155..f6b9ea5d30d 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -124,7 +124,7 @@ export default {
},
{
attrs: {
- href: this.newBlobPath,
+ href: `${this.newBlobPath}${this.currentPath}`,
class: 'qa-new-file-option',
},
text: __('New file'),
diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue
index 7f974838359..6b3822151ff 100644
--- a/app/assets/javascripts/repository/components/preview/index.vue
+++ b/app/assets/javascripts/repository/components/preview/index.vue
@@ -35,11 +35,13 @@ export default {
<template>
<article class="file-holder limited-width-container readme-holder">
- <div class="file-title">
- <i aria-hidden="true" class="fa fa-file-text-o fa-fw"></i>
- <gl-link :href="blob.webUrl">
- <strong>{{ blob.name }}</strong>
- </gl-link>
+ <div class="js-file-title file-title-flex-parent">
+ <div class="file-header-content">
+ <i aria-hidden="true" class="fa fa-file-text-o fa-fw"></i>
+ <gl-link :href="blob.webUrl">
+ <strong>{{ blob.name }}</strong>
+ </gl-link>
+ </div>
</div>
<div class="blob-viewer">
<gl-loading-icon v-if="loading > 0" size="md" class="my-4 mx-auto" />
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index d826f209815..ae6409a0ac9 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -7,6 +7,7 @@ import TreeActionLink from './components/tree_action_link.vue';
import DirectoryDownloadLinks from './components/directory_download_links.vue';
import apolloProvider from './graphql';
import { setTitle } from './utils/title';
+import { updateFormAction } from './utils/dom';
import { parseBoolean } from '../lib/utils/common_utils';
import { webIDEUrl } from '../lib/utils/url_utility';
import { __ } from '../locale';
@@ -42,8 +43,15 @@ export default function setupVueRepositoryList() {
forkNewBlobPath,
forkNewDirectoryPath,
forkUploadBlobPath,
+ uploadPath,
+ newDirPath,
} = breadcrumbEl.dataset;
+ router.afterEach(({ params: { pathMatch = '/' } }) => {
+ updateFormAction('.js-upload-blob-form', uploadPath, pathMatch);
+ updateFormAction('.js-create-dir-form', newDirPath, pathMatch);
+ });
+
// eslint-disable-next-line no-new
new Vue({
el: breadcrumbEl,
diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js
index 5bf30e625a0..6498725adb6 100644
--- a/app/assets/javascripts/repository/log_tree.js
+++ b/app/assets/javascripts/repository/log_tree.js
@@ -1,5 +1,5 @@
-import axios from '~/lib/utils/axios_utils';
import { normalizeData } from 'ee_else_ce/repository/utils/commit';
+import axios from '~/lib/utils/axios_utils';
import getCommits from './queries/getCommits.query.graphql';
import getProjectPath from './queries/getProjectPath.query.graphql';
import getRef from './queries/getRef.query.graphql';
@@ -7,8 +7,8 @@ import getRef from './queries/getRef.query.graphql';
let fetchpromise;
let resolvers = [];
-export function resolveCommit(commits, { resolve, entry }) {
- const commit = commits.find(c => c.fileName === entry.name && c.type === entry.type);
+export function resolveCommit(commits, path, { resolve, entry }) {
+ const commit = commits.find(c => c.filePath === `${path}/${entry.name}` && c.type === entry.type);
if (commit) {
resolve(commit);
@@ -35,13 +35,13 @@ export function fetchLogsTree(client, path, offset, resolver = null) {
.then(({ data, headers }) => {
const headerLogsOffset = headers['more-logs-offset'];
const { commits } = client.readQuery({ query: getCommits });
- const newCommitData = [...commits, ...normalizeData(data)];
+ const newCommitData = [...commits, ...normalizeData(data, path)];
client.writeQuery({
query: getCommits,
data: { commits: newCommitData },
});
- resolvers.forEach(r => resolveCommit(newCommitData, r));
+ resolvers.forEach(r => resolveCommit(newCommitData, path, r));
fetchpromise = null;
diff --git a/app/assets/javascripts/repository/utils/commit.js b/app/assets/javascripts/repository/utils/commit.js
index 6c204b57b37..3973798605d 100644
--- a/app/assets/javascripts/repository/utils/commit.js
+++ b/app/assets/javascripts/repository/utils/commit.js
@@ -1,11 +1,12 @@
// eslint-disable-next-line import/prefer-default-export
-export function normalizeData(data, extra = () => {}) {
+export function normalizeData(data, path, extra = () => {}) {
return data.map(d => ({
sha: d.commit.id,
message: d.commit.message,
committedDate: d.commit.committed_date,
commitPath: d.commit_path,
fileName: d.file_name,
+ filePath: `${path}/${d.file_name}`,
type: d.type,
__typename: 'LogTreeCommit',
...extra(d),
diff --git a/app/assets/javascripts/repository/utils/dom.js b/app/assets/javascripts/repository/utils/dom.js
index 963e6fc0bc4..81565a00d82 100644
--- a/app/assets/javascripts/repository/utils/dom.js
+++ b/app/assets/javascripts/repository/utils/dom.js
@@ -1,4 +1,11 @@
-// eslint-disable-next-line import/prefer-default-export
export const updateElementsVisibility = (selector, isVisible) => {
document.querySelectorAll(selector).forEach(elem => elem.classList.toggle('hidden', !isVisible));
};
+
+export const updateFormAction = (selector, basePath, path) => {
+ const form = document.querySelector(selector);
+
+ if (form) {
+ form.action = `${basePath}${path}`;
+ }
+};
diff --git a/app/assets/javascripts/serverless/components/area.vue b/app/assets/javascripts/serverless/components/area.vue
index a1a8cd3acbd..272c0bd5614 100644
--- a/app/assets/javascripts/serverless/components/area.vue
+++ b/app/assets/javascripts/serverless/components/area.vue
@@ -1,7 +1,7 @@
<script>
import { GlAreaChart } from '@gitlab/ui/dist/charts';
-import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
import dateFormat from 'dateformat';
+import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
import { X_INTERVAL } from '../constants';
import { validateGraphData } from '../utils';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue
index 9e66869515c..308bc4a2ddd 100644
--- a/app/assets/javascripts/serverless/components/functions.vue
+++ b/app/assets/javascripts/serverless/components/functions.vue
@@ -1,7 +1,7 @@
<script>
-import { sprintf, s__ } from '~/locale';
import { mapState, mapActions, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
+import { sprintf, s__ } from '~/locale';
import FunctionRow from './function_row.vue';
import EnvironmentRow from './environment_row.vue';
import EmptyState from './empty_state.vue';
@@ -44,12 +44,14 @@ export default {
'Serverless|Your repository does not have a corresponding %{startTag}serverless.yml%{endTag} file.',
),
{ startTag: '<code>', endTag: '</code>' },
+ false,
);
},
noGitlabYamlConfigured() {
return sprintf(
s__('Serverless|Your %{startTag}.gitlab-ci.yml%{endTag} file is not properly configured.'),
{ startTag: '<code>', endTag: '</code>' },
+ false,
);
},
mismatchedServerlessFunctions() {
@@ -58,6 +60,7 @@ export default {
"Serverless|The functions listed in the %{startTag}serverless.yml%{endTag} file don't match the namespace of your cluster.",
),
{ startTag: '<code>', endTag: '</code>' },
+ false,
);
},
},
@@ -111,15 +114,9 @@ export default {
}}
</p>
<ul>
- <li>
- {{ noServerlessConfigFile }}
- </li>
- <li>
- {{ noGitlabYamlConfigured }}
- </li>
- <li>
- {{ mismatchedServerlessFunctions }}
- </li>
+ <li v-html="noServerlessConfigFile"></li>
+ <li v-html="noGitlabYamlConfigured"></li>
+ <li v-html="mismatchedServerlessFunctions"></li>
<li>{{ s__('Serverless|The deploy job has not finished.') }}</li>
</ul>
diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
index df950e79690..2757d64bd7d 100644
--- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
+++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
@@ -1,11 +1,11 @@
<script>
import $ from 'jquery';
+import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
+import { GlModal, GlTooltipDirective } from '@gitlab/ui';
import createFlash from '~/flash';
import Icon from '~/vue_shared/components/icon.vue';
-import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
import { __, s__ } from '~/locale';
import Api from '~/api';
-import { GlModal, GlTooltipDirective } from '@gitlab/ui';
import eventHub from './event_hub';
import EmojiMenuInModal from './emoji_menu_in_modal';
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
index 6633a63d046..9a60172db2e 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
@@ -1,7 +1,6 @@
<script>
-import { __, sprintf } from '~/locale';
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
-import { joinPaths } from '~/lib/utils/url_utility';
+import { __, sprintf } from '~/locale';
import AssigneeAvatar from './assignee_avatar.vue';
export default {
@@ -60,7 +59,7 @@ export default {
};
},
assigneeUrl() {
- return joinPaths(`${this.rootPath}`, `${this.user.username}`);
+ return this.user.web_url;
},
},
};
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
index b107e9789a7..f4dac38b9e1 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
@@ -32,14 +32,13 @@ export default {
};
</script>
<template>
- <div class="title hide-collapsed" data-qa-selector="assignee_title">
+ <div class="title hide-collapsed">
{{ assigneeTitle }}
<i v-if="loading" aria-hidden="true" class="fa fa-spinner fa-spin block-loading"></i>
<a
v-if="editable"
class="js-sidebar-dropdown-toggle edit-link float-right"
href="#"
- data-qa-selector="assignee_edit_link"
data-track-event="click_edit_button"
data-track-label="right_sidebar"
data-track-property="assignee"
diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
index 5b4a43399ca..7375855f899 100644
--- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
@@ -1,6 +1,6 @@
<script>
-import { __, sprintf } from '~/locale';
import { GlTooltipDirective } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
import CollapsedAssignee from './collapsed_assignee.vue';
const DEFAULT_MAX_COUNTER = 99;
diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
index 902aeb9b8e4..f88bde624b4 100644
--- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
+++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
@@ -1,6 +1,6 @@
<script>
-import { __ } from '~/locale';
import $ from 'jquery';
+import { __ } from '~/locale';
import eventHub from '../../event_hub';
export default {
diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
index 4bfc8fa7eec..38b19d66163 100644
--- a/app/assets/javascripts/sidebar/components/participants/participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -1,8 +1,8 @@
<script>
+import { GlLoadingIcon } from '@gitlab/ui';
import { __, n__, sprintf } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
-import { GlLoadingIcon } from '@gitlab/ui';
export default {
directives: {
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
index 0e489b28593..3b92ead8859 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
@@ -48,6 +48,12 @@ export default {
},
},
computed: {
+ tracking() {
+ return {
+ // eslint-disable-next-line no-underscore-dangle
+ category: this.$options._componentTag,
+ };
+ },
showLoadingState() {
return this.subscribed === null;
},
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
index 06aca547183..4cb8d9ebd62 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
@@ -1,7 +1,7 @@
<script>
+import { GlProgressBar } from '@gitlab/ui';
import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility';
import tooltip from '../../../vue_shared/directives/tooltip';
-import { GlProgressBar } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
export default {
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
index 3d96405896d..3b7df369237 100644
--- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
@@ -1,7 +1,7 @@
<script>
+import { GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
-import { GlLoadingIcon } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
@@ -59,6 +59,9 @@ export default {
collapsedButtonIcon() {
return this.isTodo ? 'todo-done' : 'todo-add';
},
+ collapsedButtonIconVisible() {
+ return this.collapsed && !this.isActionActive;
+ },
},
methods: {
handleButtonClick() {
@@ -82,8 +85,12 @@ export default {
data-boundary="viewport"
@click="handleButtonClick"
>
- <icon v-show="collapsed" :class="collapsedButtonIconClasses" :name="collapsedButtonIcon" />
- <span v-show="!collapsed" class="issuable-todo-inner"> {{ buttonLabel }} </span>
+ <icon
+ v-show="collapsedButtonIconVisible"
+ :class="collapsedButtonIconClasses"
+ :name="collapsedButtonIcon"
+ />
+ <span v-show="!collapsed" class="issuable-todo-inner">{{ buttonLabel }}</span>
<gl-loading-icon v-show="isActionActive" :inline="true" />
</button>
</template>
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index 4a7000cbbda..ce869a625bf 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -1,7 +1,7 @@
+import Store from 'ee_else_ce/sidebar/stores/sidebar_store';
import { visitUrl } from '../lib/utils/url_utility';
import Flash from '../flash';
import Service from './services/sidebar_service';
-import Store from 'ee_else_ce/sidebar/stores/sidebar_store';
import { __ } from '~/locale';
export default class SidebarMediator {
diff --git a/app/assets/javascripts/snippet/snippet_embed.js b/app/assets/javascripts/snippet/snippet_embed.js
index 6606271c4fa..65dd62f6af9 100644
--- a/app/assets/javascripts/snippet/snippet_embed.js
+++ b/app/assets/javascripts/snippet/snippet_embed.js
@@ -1,28 +1,35 @@
import { __ } from '~/locale';
+import { parseUrlPathname, parseUrl } from '../lib/utils/common_utils';
+
+function swapActiveState(activateBtn, deactivateBtn) {
+ activateBtn.classList.add('is-active');
+ deactivateBtn.classList.remove('is-active');
+}
export default () => {
const shareBtn = document.querySelector('.js-share-btn');
if (shareBtn) {
- const { protocol, host, pathname } = window.location;
-
const embedBtn = document.querySelector('.js-embed-btn');
-
const snippetUrlArea = document.querySelector('.js-snippet-url-area');
const embedAction = document.querySelector('.js-embed-action');
- const url = `${protocol}//${host + pathname}`;
+ const dataUrl = snippetUrlArea.getAttribute('data-url');
+
+ snippetUrlArea.addEventListener('click', () => snippetUrlArea.select());
shareBtn.addEventListener('click', () => {
- shareBtn.classList.add('is-active');
- embedBtn.classList.remove('is-active');
- snippetUrlArea.value = url;
+ swapActiveState(shareBtn, embedBtn);
+ snippetUrlArea.value = dataUrl;
embedAction.innerText = __('Share');
});
embedBtn.addEventListener('click', () => {
- embedBtn.classList.add('is-active');
- shareBtn.classList.remove('is-active');
- const scriptTag = `<script src="${url}.js"></script>`;
+ const parser = parseUrl(dataUrl);
+ const url = `${parser.origin + parseUrlPathname(dataUrl)}`;
+ const params = parser.search;
+ const scriptTag = `<script src="${url}.js${params}"></script>`;
+
+ swapActiveState(embedBtn, shareBtn);
snippetUrlArea.value = scriptTag;
embedAction.innerText = __('Embed');
});
diff --git a/app/assets/javascripts/snippets/components/app.vue b/app/assets/javascripts/snippets/components/app.vue
new file mode 100644
index 00000000000..bd2cb8e4595
--- /dev/null
+++ b/app/assets/javascripts/snippets/components/app.vue
@@ -0,0 +1,50 @@
+<script>
+import GetSnippetQuery from '../queries/snippet.query.graphql';
+import SnippetHeader from './snippet_header.vue';
+import { GlLoadingIcon } from '@gitlab/ui';
+
+export default {
+ components: {
+ SnippetHeader,
+ GlLoadingIcon,
+ },
+ apollo: {
+ snippet: {
+ query: GetSnippetQuery,
+ variables() {
+ return {
+ ids: this.snippetGid,
+ };
+ },
+ update: data => data.snippets.edges[0].node,
+ },
+ },
+ props: {
+ snippetGid: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ snippet: {},
+ };
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.snippet.loading;
+ },
+ },
+};
+</script>
+<template>
+ <div class="js-snippet-view">
+ <gl-loading-icon
+ v-if="isLoading"
+ :label="__('Loading snippet')"
+ :size="2"
+ class="loading-animation prepend-top-20 append-bottom-20"
+ />
+ <snippet-header v-else :snippet="snippet" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue
new file mode 100644
index 00000000000..e8f1bfeaf43
--- /dev/null
+++ b/app/assets/javascripts/snippets/components/snippet_header.vue
@@ -0,0 +1,241 @@
+<script>
+import { __ } from '~/locale';
+import {
+ GlAvatar,
+ GlIcon,
+ GlSprintf,
+ GlButton,
+ GlModal,
+ GlAlert,
+ GlLoadingIcon,
+ GlDropdown,
+ GlDropdownItem,
+} from '@gitlab/ui';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql';
+import CanCreatePersonalSnippet from '../queries/userPermissions.query.graphql';
+import CanCreateProjectSnippet from '../queries/projectPermissions.query.graphql';
+
+export default {
+ components: {
+ GlAvatar,
+ GlIcon,
+ GlSprintf,
+ GlButton,
+ GlModal,
+ GlAlert,
+ GlLoadingIcon,
+ GlDropdown,
+ GlDropdownItem,
+ TimeAgoTooltip,
+ },
+ apollo: {
+ canCreateSnippet: {
+ query() {
+ return this.snippet.project ? CanCreateProjectSnippet : CanCreatePersonalSnippet;
+ },
+ variables() {
+ return {
+ fullPath: this.snippet.project ? this.snippet.project.fullPath : undefined,
+ };
+ },
+ update(data) {
+ return this.snippet.project
+ ? data.project.userPermissions.createSnippet
+ : data.currentUser.userPermissions.createSnippet;
+ },
+ },
+ },
+ props: {
+ snippet: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isDeleting: false,
+ errorMessage: '',
+ canCreateSnippet: false,
+ };
+ },
+ computed: {
+ personalSnippetActions() {
+ return [
+ {
+ condition: this.snippet.userPermissions.updateSnippet,
+ text: __('Edit'),
+ href: this.editLink,
+ click: undefined,
+ variant: 'outline-info',
+ cssClass: undefined,
+ },
+ {
+ condition: this.snippet.userPermissions.adminSnippet,
+ text: __('Delete'),
+ href: undefined,
+ click: this.showDeleteModal,
+ variant: 'outline-danger',
+ cssClass: 'btn-inverted btn-danger ml-2',
+ },
+ {
+ condition: this.canCreateSnippet,
+ text: __('New snippet'),
+ href: this.snippet.project
+ ? `${this.snippet.project.webUrl}/snippets/new`
+ : '/snippets/new',
+ click: undefined,
+ variant: 'outline-success',
+ cssClass: 'btn-inverted btn-success ml-2',
+ },
+ ];
+ },
+ editLink() {
+ return `${this.snippet.webUrl}/edit`;
+ },
+ visibility() {
+ return this.snippet.visibilityLevel;
+ },
+ snippetVisibilityLevelDescription() {
+ switch (this.visibility) {
+ case 'private':
+ return this.snippet.project !== null
+ ? __('The snippet is visible only to project members.')
+ : __('The snippet is visible only to me.');
+ case 'internal':
+ return __('The snippet is visible to any logged in user.');
+ default:
+ return __('The snippet can be accessed without any authentication.');
+ }
+ },
+ visibilityLevelIcon() {
+ switch (this.visibility) {
+ case 'private':
+ return 'lock';
+ case 'internal':
+ return 'shield';
+ default:
+ return 'earth';
+ }
+ },
+ },
+ methods: {
+ redirectToSnippets() {
+ window.location.pathname = 'dashboard/snippets';
+ },
+ closeDeleteModal() {
+ this.$refs.deleteModal.hide();
+ },
+ showDeleteModal() {
+ this.$refs.deleteModal.show();
+ },
+ deleteSnippet() {
+ this.isDeleting = true;
+ this.$apollo
+ .mutate({
+ mutation: DeleteSnippetMutation,
+ variables: { id: this.snippet.id },
+ })
+ .then(() => {
+ this.isDeleting = false;
+ this.errorMessage = undefined;
+ this.closeDeleteModal();
+ this.redirectToSnippets();
+ })
+ .catch(err => {
+ this.isDeleting = false;
+ this.errorMessage = err.message;
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div class="detail-page-header">
+ <div class="detail-page-header-body">
+ <div
+ class="snippet-box qa-snippet-box has-tooltip d-flex align-items-center append-right-5 mb-1"
+ :title="snippetVisibilityLevelDescription"
+ data-container="body"
+ >
+ <span class="sr-only">
+ {{ s__(`VisibilityLevel|${visibility}`) }}
+ </span>
+ <gl-icon :name="visibilityLevelIcon" :size="14" />
+ </div>
+ <div class="creator">
+ <gl-sprintf message="Authored %{timeago} by %{author}">
+ <template #timeago>
+ <time-ago-tooltip
+ :time="snippet.createdAt"
+ tooltip-placement="bottom"
+ css-class="snippet_updated_ago"
+ />
+ </template>
+ <template #author>
+ <a :href="snippet.author.webUrl" class="d-inline">
+ <gl-avatar :size="24" :src="snippet.author.avatarUrl" />
+ <span class="bold">{{ snippet.author.name }}</span>
+ </a>
+ </template>
+ </gl-sprintf>
+ </div>
+ </div>
+
+ <div class="detail-page-header-actions">
+ <div class="d-none d-sm-block">
+ <template v-for="(action, index) in personalSnippetActions">
+ <gl-button
+ v-if="action.condition"
+ :key="index"
+ :variant="action.variant"
+ :class="action.cssClass"
+ :href="action.href || undefined"
+ @click="action.click ? action.click() : undefined"
+ >
+ {{ action.text }}
+ </gl-button>
+ </template>
+ </div>
+ <div class="d-block d-sm-none dropdown">
+ <gl-dropdown :text="__('Options')" class="w-100" toggle-class="text-center">
+ <gl-dropdown-item
+ v-for="(action, index) in personalSnippetActions"
+ :key="index"
+ :href="action.href || undefined"
+ @click="action.click ? action.click() : undefined"
+ >{{ action.text }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ </div>
+ </div>
+
+ <gl-modal ref="deleteModal" modal-id="delete-modal" title="Example title">
+ <template #modal-title>{{ __('Delete snippet?') }}</template>
+
+ <gl-alert v-if="errorMessage" variant="danger" class="mb-2" @dismiss="errorMessage = ''">{{
+ errorMessage
+ }}</gl-alert>
+
+ <gl-sprintf message="Are you sure you want to delete %{name}?">
+ <template #name
+ ><strong>{{ snippet.title }}</strong></template
+ >
+ </gl-sprintf>
+
+ <template #modal-footer>
+ <gl-button @click="closeDeleteModal">{{ __('Cancel') }}</gl-button>
+ <gl-button
+ variant="danger"
+ :disabled="isDeleting"
+ data-qa-selector="delete_snippet_button"
+ @click="deleteSnippet"
+ >
+ <gl-loading-icon v-if="isDeleting" inline />
+ {{ __('Delete snippet') }}
+ </gl-button>
+ </template>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/snippets/fragments/author.fragment.graphql b/app/assets/javascripts/snippets/fragments/author.fragment.graphql
new file mode 100644
index 00000000000..2684bd0fa37
--- /dev/null
+++ b/app/assets/javascripts/snippets/fragments/author.fragment.graphql
@@ -0,0 +1,8 @@
+fragment Author on Snippet {
+ author {
+ name,
+ avatarUrl,
+ username,
+ webUrl
+ }
+} \ No newline at end of file
diff --git a/app/assets/javascripts/snippets/fragments/project.fragment.graphql b/app/assets/javascripts/snippets/fragments/project.fragment.graphql
new file mode 100644
index 00000000000..7d65789c67b
--- /dev/null
+++ b/app/assets/javascripts/snippets/fragments/project.fragment.graphql
@@ -0,0 +1,6 @@
+fragment Project on Snippet {
+ project {
+ fullPath
+ webUrl
+ }
+} \ No newline at end of file
diff --git a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql
new file mode 100644
index 00000000000..57348a422ec
--- /dev/null
+++ b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql
@@ -0,0 +1,13 @@
+fragment SnippetBase on Snippet {
+ id
+ title
+ description
+ createdAt
+ updatedAt
+ visibilityLevel
+ webUrl
+ userPermissions {
+ adminSnippet
+ updateSnippet
+ }
+} \ No newline at end of file
diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js
new file mode 100644
index 00000000000..654856f8d14
--- /dev/null
+++ b/app/assets/javascripts/snippets/index.js
@@ -0,0 +1,34 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+
+import SnippetsApp from './components/app.vue';
+
+Vue.use(VueApollo);
+Vue.use(Translate);
+
+export default () => {
+ const el = document.getElementById('js-snippet-view');
+
+ if (!el) {
+ return false;
+ }
+
+ const { snippetGid } = el.dataset;
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(createElement) {
+ return createElement(SnippetsApp, {
+ props: {
+ snippetGid,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql
new file mode 100644
index 00000000000..0c829cbdee6
--- /dev/null
+++ b/app/assets/javascripts/snippets/mutations/deleteSnippet.mutation.graphql
@@ -0,0 +1,5 @@
+mutation DeleteSnippet($id: ID!) {
+ destroySnippet(input: {id: $id}) {
+ errors
+ }
+} \ No newline at end of file
diff --git a/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql b/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql
new file mode 100644
index 00000000000..288bd0889bf
--- /dev/null
+++ b/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql
@@ -0,0 +1,7 @@
+query CanCreateProjectSnippet($fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ userPermissions {
+ createSnippet
+ }
+ }
+} \ No newline at end of file
diff --git a/app/assets/javascripts/snippets/queries/snippet.query.graphql b/app/assets/javascripts/snippets/queries/snippet.query.graphql
new file mode 100644
index 00000000000..1cb2c86c4d8
--- /dev/null
+++ b/app/assets/javascripts/snippets/queries/snippet.query.graphql
@@ -0,0 +1,15 @@
+#import '../fragments/snippetBase.fragment.graphql'
+#import '../fragments/project.fragment.graphql'
+#import '../fragments/author.fragment.graphql'
+
+query GetSnippetQuery($ids: [ID!]) {
+ snippets(ids: $ids) {
+ edges {
+ node {
+ ...SnippetBase
+ ...Project
+ ...Author
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/snippets/queries/userPermissions.query.graphql b/app/assets/javascripts/snippets/queries/userPermissions.query.graphql
new file mode 100644
index 00000000000..f5b97b3d0f0
--- /dev/null
+++ b/app/assets/javascripts/snippets/queries/userPermissions.query.graphql
@@ -0,0 +1,7 @@
+query CanCreatePersonalSnippet {
+ currentUser {
+ userPermissions {
+ createSnippet
+ }
+ }
+} \ No newline at end of file
diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js
index 7c0097fbe37..a17b8a047c0 100644
--- a/app/assets/javascripts/tracking.js
+++ b/app/assets/javascripts/tracking.js
@@ -73,20 +73,25 @@ export default class Tracking {
return handlers;
}
- static mixin(opts) {
+ static mixin(opts = {}) {
return {
- data() {
- return {
- tracking: {
- // eslint-disable-next-line no-underscore-dangle
- category: this.$options.name || this.$options._componentTag,
- },
- };
+ computed: {
+ trackingCategory() {
+ const localCategory = this.tracking ? this.tracking.category : null;
+ return localCategory || opts.category;
+ },
+ trackingOptions() {
+ return { ...opts, ...this.tracking };
+ },
},
methods: {
- track(action, data) {
- const category = opts.category || data.category || this.tracking.category;
- Tracking.event(category || 'unspecified', action, { ...opts, ...this.tracking, ...data });
+ track(action, data = {}) {
+ const category = data.category || this.trackingCategory;
+ const options = {
+ ...this.trackingOptions,
+ ...data,
+ };
+ Tracking.event(category, action, options);
},
},
};
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index 7d6a725b30f..157d89a3a40 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -17,6 +17,7 @@ const handleUserPopoverMouseOut = event => {
renderedPopover.$destroy();
renderedPopover = null;
}
+ target.removeAttribute('aria-describedby');
};
/**
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index da1a7c290f8..57fbb88ca2e 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, one-var, no-var, prefer-rest-params, vars-on-top, 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, camelcase, no-param-reassign */
/* global Issuable */
/* global emitSidebarEvent */
@@ -13,7 +13,7 @@ import { parseBoolean } from './lib/utils/common_utils';
window.emitSidebarEvent = window.emitSidebarEvent || $.noop;
function UsersSelect(currentUser, els, options = {}) {
- var $els;
+ const $els = $(els || '.js-user-search');
this.users = this.users.bind(this);
this.user = this.user.bind(this);
this.usersPath = '/autocomplete/users.json';
@@ -28,36 +28,11 @@ function UsersSelect(currentUser, els, options = {}) {
const { handleClick } = options;
- $els = $(els);
-
- if (!els) {
- $els = $('.js-user-search');
- }
-
$els.each(
(function(_this) {
return function(i, dropdown) {
- var options = {};
- var $block,
- $collapsedSidebar,
- $dropdown,
- $loading,
- $selectbox,
- $value,
- abilityName,
- assignTo,
- assigneeTemplate,
- collapsedAssigneeTemplate,
- defaultLabel,
- defaultNullUser,
- firstUser,
- issueURL,
- selectedId,
- selectedIdDefault,
- showAnyUser,
- showNullUser,
- showMenuAbove;
- $dropdown = $(dropdown);
+ const options = {};
+ const $dropdown = $(dropdown);
options.projectId = $dropdown.data('projectId');
options.groupId = $dropdown.data('groupId');
options.showCurrentUser = $dropdown.data('currentUser');
@@ -65,22 +40,25 @@ function UsersSelect(currentUser, els, options = {}) {
options.todoStateFilter = $dropdown.data('todoStateFilter');
options.iid = $dropdown.data('iid');
options.issuableType = $dropdown.data('issuableType');
- showNullUser = $dropdown.data('nullUser');
- defaultNullUser = $dropdown.data('nullUserDefault');
- showMenuAbove = $dropdown.data('showMenuAbove');
- showAnyUser = $dropdown.data('anyUser');
- firstUser = $dropdown.data('firstUser');
+ const showNullUser = $dropdown.data('nullUser');
+ const defaultNullUser = $dropdown.data('nullUserDefault');
+ const showMenuAbove = $dropdown.data('showMenuAbove');
+ const showAnyUser = $dropdown.data('anyUser');
+ const firstUser = $dropdown.data('firstUser');
options.authorId = $dropdown.data('authorId');
- defaultLabel = $dropdown.data('defaultLabel');
- issueURL = $dropdown.data('issueUpdate');
- $selectbox = $dropdown.closest('.selectbox');
- $block = $selectbox.closest('.block');
- abilityName = $dropdown.data('abilityName');
- $value = $block.find('.value');
- $collapsedSidebar = $block.find('.sidebar-collapsed-user');
- $loading = $block.find('.block-loading').fadeOut();
- selectedIdDefault = defaultNullUser && showNullUser ? 0 : null;
- selectedId = $dropdown.data('selected');
+ const defaultLabel = $dropdown.data('defaultLabel');
+ const issueURL = $dropdown.data('issueUpdate');
+ const $selectbox = $dropdown.closest('.selectbox');
+ let $block = $selectbox.closest('.block');
+ const abilityName = $dropdown.data('abilityName');
+ let $value = $block.find('.value');
+ const $collapsedSidebar = $block.find('.sidebar-collapsed-user');
+ const $loading = $block.find('.block-loading').fadeOut();
+ const selectedIdDefault = defaultNullUser && showNullUser ? 0 : null;
+ let selectedId = $dropdown.data('selected');
+ let assignTo;
+ let assigneeTemplate;
+ let collapsedAssigneeTemplate;
if (selectedId === undefined) {
selectedId = selectedIdDefault;
@@ -207,15 +185,15 @@ function UsersSelect(currentUser, els, options = {}) {
});
assignTo = function(selected) {
- var data;
- data = {};
+ const data = {};
data[abilityName] = {};
data[abilityName].assignee_id = selected != null ? selected : null;
$loading.removeClass('hidden').fadeIn();
$dropdown.trigger('loading.gl.dropdown');
return axios.put(issueURL, data).then(({ data }) => {
- var user, tooltipTitle;
+ let user = {};
+ let tooltipTitle = user.name;
$dropdown.trigger('loaded.gl.dropdown');
$loading.fadeOut();
if (data.assignee) {
@@ -471,10 +449,9 @@ function UsersSelect(currentUser, els, options = {}) {
}
}
- var isIssueIndex, isMRIndex, page, selected;
- page = $('body').attr('data-page');
- isIssueIndex = page === 'projects:issues:index';
- isMRIndex = page === page && page === 'projects:merge_requests:index';
+ const page = $('body').attr('data-page');
+ const isIssueIndex = page === 'projects:issues:index';
+ const isMRIndex = page === page && page === 'projects:merge_requests:index';
if (
$dropdown.hasClass('js-filter-bulk-update') ||
$dropdown.hasClass('js-issuable-form-dropdown')
@@ -501,7 +478,7 @@ function UsersSelect(currentUser, els, options = {}) {
} else if ($dropdown.hasClass('js-filter-submit')) {
return $dropdown.closest('form').submit();
} else if (!$dropdown.hasClass('js-multiselect')) {
- selected = $dropdown
+ const selected = $dropdown
.closest('.selectbox')
.find(`input[name='${$dropdown.data('fieldName')}']`)
.val();
@@ -544,9 +521,8 @@ function UsersSelect(currentUser, els, options = {}) {
},
updateLabel: $dropdown.data('dropdownTitle'),
renderRow(user) {
- var avatar, img, username;
- username = user.username ? `@${user.username}` : '';
- avatar = user.avatar_url ? user.avatar_url : gon.default_avatar_url;
+ const username = user.username ? `@${user.username}` : '';
+ const avatar = user.avatar_url ? user.avatar_url : gon.default_avatar_url;
let selected = false;
@@ -565,7 +541,7 @@ function UsersSelect(currentUser, els, options = {}) {
selected = user.id === selectedId;
}
- img = '';
+ let img = '';
if (user.beforeDivider != null) {
`<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${_.escape(
user.name,
@@ -586,35 +562,34 @@ function UsersSelect(currentUser, els, options = {}) {
$('.ajax-users-select').each(
(function(_this) {
return function(i, select) {
- var firstUser, showAnyUser, showEmailUser, showNullUser;
- var options = {};
+ const options = {};
options.skipLdap = $(select).hasClass('skip_ldap');
options.projectId = $(select).data('projectId');
options.groupId = $(select).data('groupId');
options.showCurrentUser = $(select).data('currentUser');
options.authorId = $(select).data('authorId');
options.skipUsers = $(select).data('skipUsers');
- showNullUser = $(select).data('nullUser');
- showAnyUser = $(select).data('anyUser');
- showEmailUser = $(select).data('emailUser');
- firstUser = $(select).data('firstUser');
+ const showNullUser = $(select).data('nullUser');
+ const showAnyUser = $(select).data('anyUser');
+ const showEmailUser = $(select).data('emailUser');
+ const firstUser = $(select).data('firstUser');
return $(select).select2({
placeholder: __('Search for a user'),
multiple: $(select).hasClass('multiselect'),
minimumInputLength: 0,
query(query) {
return _this.users(query.term, options, users => {
- var anyUser, data, emailUser, index, len, name, nullUser, obj, ref;
- data = {
+ let name;
+ const data = {
results: users,
};
if (query.term.length === 0) {
if (firstUser) {
// Move current user to the front of the list
- ref = data.results;
+ const ref = data.results;
- for (index = 0, len = ref.length; index < len; index += 1) {
- obj = ref[index];
+ for (let index = 0, len = ref.length; index < len; index += 1) {
+ const obj = ref[index];
if (obj.username === firstUser) {
data.results.splice(index, 1);
data.results.unshift(obj);
@@ -623,7 +598,7 @@ function UsersSelect(currentUser, els, options = {}) {
}
}
if (showNullUser) {
- nullUser = {
+ const nullUser = {
name: s__('UsersSelect|Unassigned'),
id: 0,
};
@@ -634,7 +609,7 @@ function UsersSelect(currentUser, els, options = {}) {
if (name === true) {
name = s__('UsersSelect|Any User');
}
- anyUser = {
+ const anyUser = {
name,
id: null,
};
@@ -646,8 +621,8 @@ function UsersSelect(currentUser, els, options = {}) {
data.results.length === 0 &&
query.term.match(/^[^@]+@[^@]+$/)
) {
- var trimmed = query.term.trim();
- emailUser = {
+ const trimmed = query.term.trim();
+ const emailUser = {
name: sprintf(__('Invite "%{trimmed}" by email'), { trimmed }),
username: trimmed,
id: trimmed,
@@ -659,18 +634,15 @@ function UsersSelect(currentUser, els, options = {}) {
});
},
initSelection() {
- var args;
- args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
return _this.initSelection.apply(_this, args);
},
formatResult() {
- var args;
- args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
return _this.formatResult.apply(_this, args);
},
formatSelection() {
- var args;
- args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
return _this.formatSelection.apply(_this, args);
},
dropdownCssClass: 'ajax-users-dropdown',
@@ -687,10 +659,9 @@ function UsersSelect(currentUser, els, options = {}) {
}
UsersSelect.prototype.initSelection = function(element, callback) {
- var id, nullUser;
- id = $(element).val();
+ const id = $(element).val();
if (id === '0') {
- nullUser = {
+ const nullUser = {
name: s__('UsersSelect|Unassigned'),
};
return callback(nullUser);
@@ -700,11 +671,9 @@ UsersSelect.prototype.initSelection = function(element, callback) {
};
UsersSelect.prototype.formatResult = function(user) {
- var avatar;
+ let avatar = gon.default_avatar_url;
if (user.avatar_url) {
avatar = user.avatar_url;
- } else {
- avatar = gon.default_avatar_url;
}
return `
<div class='user-result'>
@@ -732,8 +701,7 @@ UsersSelect.prototype.user = function(user_id, callback) {
return false;
}
- var url;
- url = this.buildUrl(this.userPath);
+ let url = this.buildUrl(this.userPath);
url = url.replace(':id', user_id);
return axios.get(url).then(({ data }) => {
callback(data);
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
deleted file mode 100644
index 1873e09c370..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
+++ /dev/null
@@ -1,245 +0,0 @@
-<script>
-import { GlTooltipDirective } from '@gitlab/ui';
-import Icon from '~/vue_shared/components/icon.vue';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
-import FilteredSearchDropdown from '~/vue_shared/components/filtered_search_dropdown.vue';
-import { __ } from '~/locale';
-import timeagoMixin from '../../vue_shared/mixins/timeago';
-import LoadingButton from '../../vue_shared/components/loading_button.vue';
-import { visitUrl } from '../../lib/utils/url_utility';
-import createFlash from '../../flash';
-import MemoryUsage from './memory_usage.vue';
-import StatusIcon from './mr_widget_status_icon.vue';
-import ReviewAppLink from './review_app_link.vue';
-import MRWidgetService from '../services/mr_widget_service';
-
-export default {
- // name: 'Deployment' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives
- // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
- name: 'Deployment',
- components: {
- LoadingButton,
- MemoryUsage,
- StatusIcon,
- Icon,
- TooltipOnTruncate,
- FilteredSearchDropdown,
- ReviewAppLink,
- VisualReviewAppLink: () =>
- import('ee_component/vue_merge_request_widget/components/visual_review_app_link.vue'),
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- mixins: [timeagoMixin],
- props: {
- deployment: {
- type: Object,
- required: true,
- },
- showMetrics: {
- type: Boolean,
- required: true,
- },
- showVisualReviewApp: {
- type: Boolean,
- required: false,
- default: false,
- },
- visualReviewAppMeta: {
- type: Object,
- required: false,
- default: () => ({
- sourceProjectId: '',
- sourceProjectPath: '',
- mergeRequestId: '',
- appUrl: '',
- }),
- },
- },
- deployedTextMap: {
- running: __('Deploying to'),
- success: __('Deployed to'),
- failed: __('Failed to deploy to'),
- created: __('Will deploy to'),
- canceled: __('Failed to deploy to'),
- },
- data() {
- return {
- isStopping: false,
- };
- },
- computed: {
- deployTimeago() {
- return this.timeFormated(this.deployment.deployed_at);
- },
- deploymentExternalUrl() {
- if (this.deployment.changes && this.deployment.changes.length === 1) {
- return this.deployment.changes[0].external_url;
- }
- return this.deployment.external_url;
- },
- hasExternalUrls() {
- return Boolean(this.deployment.external_url && this.deployment.external_url_formatted);
- },
- hasDeploymentTime() {
- return Boolean(this.deployment.deployed_at && this.deployment.deployed_at_formatted);
- },
- hasDeploymentMeta() {
- return Boolean(this.deployment.url && this.deployment.name);
- },
- hasMetrics() {
- return Boolean(this.deployment.metrics_url);
- },
- deployedText() {
- return this.$options.deployedTextMap[this.deployment.status];
- },
- isDeployInProgress() {
- return this.deployment.status === 'running';
- },
- deployInProgressTooltip() {
- return this.isDeployInProgress
- ? __('Stopping this environment is currently not possible as a deployment is in progress')
- : '';
- },
- shouldRenderDropdown() {
- return this.deployment.changes && this.deployment.changes.length > 1;
- },
- showMemoryUsage() {
- return this.hasMetrics && this.showMetrics;
- },
- },
- methods: {
- stopEnvironment() {
- const msg = __('Are you sure you want to stop this environment?');
- const isConfirmed = confirm(msg); // eslint-disable-line
-
- if (isConfirmed) {
- this.isStopping = true;
-
- MRWidgetService.stopEnvironment(this.deployment.stop_url)
- .then(res => res.data)
- .then(data => {
- if (data.redirect_url) {
- visitUrl(data.redirect_url);
- }
-
- this.isStopping = false;
- })
- .catch(() => {
- createFlash(
- __('Something went wrong while stopping this environment. Please try again.'),
- );
- this.isStopping = false;
- });
- }
- },
- },
-};
-</script>
-
-<template>
- <div class="deploy-heading">
- <div class="ci-widget media">
- <div class="media-body">
- <div class="deploy-body">
- <div class="js-deployment-info deployment-info">
- <template v-if="hasDeploymentMeta">
- <span> {{ deployedText }} </span>
- <tooltip-on-truncate
- :title="deployment.name"
- truncate-target="child"
- class="deploy-link label-truncate"
- >
- <a
- :href="deployment.url"
- target="_blank"
- rel="noopener noreferrer nofollow"
- class="js-deploy-meta"
- >
- {{ deployment.name }}
- </a>
- </tooltip-on-truncate>
- </template>
- <span
- v-if="hasDeploymentTime"
- v-gl-tooltip
- :title="deployment.deployed_at_formatted"
- class="js-deploy-time"
- >
- {{ deployTimeago }}
- </span>
- <memory-usage
- v-if="showMemoryUsage"
- :metrics-url="deployment.metrics_url"
- :metrics-monitoring-url="deployment.metrics_monitoring_url"
- />
- </div>
- <div>
- <template v-if="hasExternalUrls">
- <filtered-search-dropdown
- v-if="shouldRenderDropdown"
- class="js-mr-wigdet-deployment-dropdown inline"
- :items="deployment.changes"
- :main-action-link="deploymentExternalUrl"
- filter-key="path"
- >
- <template slot="mainAction" slot-scope="slotProps">
- <review-app-link
- :link="deploymentExternalUrl"
- :css-class="`deploy-link js-deploy-url inline ${slotProps.className}`"
- />
- </template>
-
- <template slot="result" slot-scope="slotProps">
- <a
- :href="slotProps.result.external_url"
- target="_blank"
- rel="noopener noreferrer nofollow"
- class="menu-item"
- >
- <strong class="str-truncated-100 append-bottom-0 d-block">
- {{ slotProps.result.path }}
- </strong>
-
- <p class="text-secondary str-truncated-100 append-bottom-0 d-block">
- {{ slotProps.result.external_url }}
- </p>
- </a>
- </template>
- </filtered-search-dropdown>
- <template v-else>
- <review-app-link
- :link="deploymentExternalUrl"
- css-class="js-deploy-url deploy-link btn btn-default btn-sm inline"
- />
- </template>
- <visual-review-app-link
- v-if="showVisualReviewApp"
- :link="deploymentExternalUrl"
- :app-metadata="visualReviewAppMeta"
- />
- </template>
- <span
- v-if="deployment.stop_url"
- v-gl-tooltip
- :title="deployInProgressTooltip"
- class="d-inline-block"
- tabindex="0"
- >
- <loading-button
- :loading="isStopping"
- :disabled="isDeployInProgress"
- :title="__('Stop environment')"
- container-class="js-stop-env btn btn-default btn-sm inline prepend-left-4"
- @click="stopEnvironment"
- >
- <icon name="stop" />
- </loading-button>
- </span>
- </div>
- </div>
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js b/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js
new file mode 100644
index 00000000000..90741e3aa44
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js
@@ -0,0 +1,8 @@
+// DEPLOYMENT STATUSES
+export const CREATED = 'created';
+export const MANUAL_DEPLOY = 'manual_deploy';
+export const WILL_DEPLOY = 'will_deploy';
+export const RUNNING = 'running';
+export const SUCCESS = 'success';
+export const FAILED = 'failed';
+export const CANCELED = 'canceled';
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue
new file mode 100644
index 00000000000..e03b1e6d6a6
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue
@@ -0,0 +1,108 @@
+<script>
+import { GlTooltipDirective } from '@gitlab/ui';
+import DeploymentInfo from './deployment_info.vue';
+import DeploymentViewButton from './deployment_view_button.vue';
+import DeploymentStopButton from './deployment_stop_button.vue';
+import { MANUAL_DEPLOY, WILL_DEPLOY, CREATED, RUNNING, SUCCESS } from './constants';
+
+export default {
+ // name: 'Deployment' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
+ name: 'Deployment',
+ components: {
+ DeploymentInfo,
+ DeploymentStopButton,
+ DeploymentViewButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ deployment: {
+ type: Object,
+ required: true,
+ },
+ showMetrics: {
+ type: Boolean,
+ required: true,
+ },
+ showVisualReviewApp: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ visualReviewAppMeta: {
+ type: Object,
+ required: false,
+ default: () => ({
+ sourceProjectId: '',
+ sourceProjectPath: '',
+ mergeRequestId: '',
+ appUrl: '',
+ }),
+ },
+ },
+ computed: {
+ canBeManuallyDeployed() {
+ return this.computedDeploymentStatus === MANUAL_DEPLOY;
+ },
+ computedDeploymentStatus() {
+ if (this.deployment.status === CREATED) {
+ return this.isManual ? MANUAL_DEPLOY : WILL_DEPLOY;
+ }
+ return this.deployment.status;
+ },
+ hasExternalUrls() {
+ return Boolean(this.deployment.external_url && this.deployment.external_url_formatted);
+ },
+ hasPreviousDeployment() {
+ return Boolean(!this.isCurrent && this.deployment.deployed_at);
+ },
+ isCurrent() {
+ return this.computedDeploymentStatus === SUCCESS;
+ },
+ isManual() {
+ return Boolean(
+ this.deployment.details &&
+ this.deployment.details.playable_build &&
+ this.deployment.details.playable_build.play_path,
+ );
+ },
+ isDeployInProgress() {
+ return this.deployment.status === RUNNING;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="deploy-heading">
+ <div class="ci-widget media">
+ <div class="media-body">
+ <div class="deploy-body">
+ <deployment-info
+ :computed-deployment-status="computedDeploymentStatus"
+ :deployment="deployment"
+ :show-metrics="showMetrics"
+ />
+ <div>
+ <!-- show appropriate version of review app button -->
+ <deployment-view-button
+ v-if="hasExternalUrls"
+ :is-current="isCurrent"
+ :deployment="deployment"
+ :show-visual-review-app="showVisualReviewApp"
+ :visual-review-app-metadata="visualReviewAppMeta"
+ />
+ <!-- if it is stoppable, show stop -->
+ <deployment-stop-button
+ v-if="deployment.stop_url"
+ :is-deploy-in-progress="isDeployInProgress"
+ :stop-url="deployment.stop_url"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
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
new file mode 100644
index 00000000000..db4a4ece002
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
@@ -0,0 +1,98 @@
+<script>
+import { GlLink, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import MemoryUsage from './memory_usage.vue';
+import { MANUAL_DEPLOY, WILL_DEPLOY, RUNNING, SUCCESS, FAILED, CANCELED } from './constants';
+
+export default {
+ name: 'DeploymentInfo',
+ components: {
+ GlLink,
+ MemoryUsage,
+ TooltipOnTruncate,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ computedDeploymentStatus: {
+ type: String,
+ required: true,
+ },
+ deployment: {
+ type: Object,
+ required: true,
+ },
+ showMetrics: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ deployedTextMap: {
+ [MANUAL_DEPLOY]: __('Can deploy manually to'),
+ [WILL_DEPLOY]: __('Will deploy to'),
+ [RUNNING]: __('Deploying to'),
+ [SUCCESS]: __('Deployed to'),
+ [FAILED]: __('Failed to deploy to'),
+ [CANCELED]: __('Canceled deploy to'),
+ },
+ computed: {
+ deployTimeago() {
+ return this.timeFormatted(this.deployment.deployed_at);
+ },
+ deployedText() {
+ return this.$options.deployedTextMap[this.computedDeploymentStatus];
+ },
+ hasDeploymentTime() {
+ return Boolean(this.deployment.deployed_at && this.deployment.deployed_at_formatted);
+ },
+ hasDeploymentMeta() {
+ return Boolean(this.deployment.url && this.deployment.name);
+ },
+ hasMetrics() {
+ return Boolean(this.deployment.metrics_url);
+ },
+ showMemoryUsage() {
+ return this.hasMetrics && this.showMetrics;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="js-deployment-info deployment-info">
+ <template v-if="hasDeploymentMeta">
+ <span>{{ deployedText }}</span>
+ <tooltip-on-truncate
+ :title="deployment.name"
+ truncate-target="child"
+ class="deploy-link label-truncate"
+ >
+ <gl-link
+ :href="deployment.url"
+ target="_blank"
+ rel="noopener noreferrer nofollow"
+ class="js-deploy-meta gl-font-size-12"
+ >
+ {{ deployment.name }}
+ </gl-link>
+ </tooltip-on-truncate>
+ </template>
+ <span
+ v-if="hasDeploymentTime"
+ v-gl-tooltip
+ :title="deployment.deployed_at_formatted"
+ class="js-deploy-time"
+ >
+ {{ deployTimeago }}
+ </span>
+ <memory-usage
+ v-if="showMemoryUsage"
+ :metrics-url="deployment.metrics_url"
+ :metrics-monitoring-url="deployment.metrics_monitoring_url"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_stop_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_stop_button.vue
new file mode 100644
index 00000000000..e20296c41a2
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_stop_button.vue
@@ -0,0 +1,83 @@
+<script>
+import { GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import { visitUrl } from '~/lib/utils/url_utility';
+import createFlash from '~/flash';
+import MRWidgetService from '../../services/mr_widget_service';
+
+export default {
+ name: 'DeploymentStopButton',
+ components: {
+ LoadingButton,
+ Icon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ isDeployInProgress: {
+ type: Boolean,
+ required: true,
+ },
+ stopUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isStopping: false,
+ };
+ },
+ computed: {
+ deployInProgressTooltip() {
+ return this.isDeployInProgress
+ ? __('Stopping this environment is currently not possible as a deployment is in progress')
+ : '';
+ },
+ },
+ methods: {
+ stopEnvironment() {
+ const msg = __('Are you sure you want to stop this environment?');
+ const isConfirmed = confirm(msg); // eslint-disable-line
+
+ if (isConfirmed) {
+ this.isStopping = true;
+
+ MRWidgetService.stopEnvironment(this.stopUrl)
+ .then(res => res.data)
+ .then(data => {
+ if (data.redirect_url) {
+ visitUrl(data.redirect_url);
+ }
+
+ this.isStopping = false;
+ })
+ .catch(() => {
+ createFlash(
+ __('Something went wrong while stopping this environment. Please try again.'),
+ );
+ this.isStopping = false;
+ });
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <span v-gl-tooltip :title="deployInProgressTooltip" class="d-inline-block" tabindex="0">
+ <loading-button
+ v-gl-tooltip
+ :loading="isStopping"
+ :disabled="isDeployInProgress"
+ :title="__('Stop environment')"
+ container-class="js-stop-env btn btn-default btn-sm inline prepend-left-4"
+ @click="stopEnvironment"
+ >
+ <icon name="stop" />
+ </loading-button>
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
new file mode 100644
index 00000000000..9965e3d5203
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
@@ -0,0 +1,99 @@
+<script>
+import FilteredSearchDropdown from '~/vue_shared/components/filtered_search_dropdown.vue';
+import ReviewAppLink from '../review_app_link.vue';
+
+export default {
+ name: 'DeploymentViewButton',
+ components: {
+ FilteredSearchDropdown,
+ ReviewAppLink,
+ VisualReviewAppLink: () =>
+ import('ee_component/vue_merge_request_widget/components/visual_review_app_link.vue'),
+ },
+ props: {
+ deployment: {
+ type: Object,
+ required: true,
+ },
+ isCurrent: {
+ type: Boolean,
+ required: true,
+ },
+ showVisualReviewApp: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ visualReviewAppMeta: {
+ type: Object,
+ required: false,
+ default: () => ({
+ sourceProjectId: '',
+ sourceProjectPath: '',
+ mergeRequestId: '',
+ appUrl: '',
+ }),
+ },
+ },
+ computed: {
+ deploymentExternalUrl() {
+ if (this.deployment.changes && this.deployment.changes.length === 1) {
+ return this.deployment.changes[0].external_url;
+ }
+ return this.deployment.external_url;
+ },
+ shouldRenderDropdown() {
+ return this.deployment.changes && this.deployment.changes.length > 1;
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <filtered-search-dropdown
+ v-if="shouldRenderDropdown"
+ class="js-mr-wigdet-deployment-dropdown inline"
+ :items="deployment.changes"
+ :main-action-link="deploymentExternalUrl"
+ filter-key="path"
+ >
+ <template slot="mainAction" slot-scope="slotProps">
+ <review-app-link
+ :is-current="isCurrent"
+ :link="deploymentExternalUrl"
+ :css-class="`deploy-link js-deploy-url inline ${slotProps.className}`"
+ />
+ </template>
+
+ <template slot="result" slot-scope="slotProps">
+ <a
+ :href="slotProps.result.external_url"
+ target="_blank"
+ rel="noopener noreferrer nofollow"
+ class="js-deploy-url-menu-item menu-item"
+ >
+ <strong class="str-truncated-100 append-bottom-0 d-block">
+ {{ slotProps.result.path }}
+ </strong>
+
+ <p class="text-secondary str-truncated-100 append-bottom-0 d-block">
+ {{ slotProps.result.external_url }}
+ </p>
+ </a>
+ </template>
+ </filtered-search-dropdown>
+ <template v-else>
+ <review-app-link
+ :is-current="isCurrent"
+ :link="deploymentExternalUrl"
+ css-class="js-deploy-url deploy-link btn btn-default btn-sm inline"
+ />
+ </template>
+ <visual-review-app-link
+ v-if="showVisualReviewApp"
+ :link="deploymentExternalUrl"
+ :app-metadata="visualReviewAppMeta"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue
index 7ce454b7338..fe41a15979e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue
@@ -1,10 +1,10 @@
<script>
import { sprintf, s__ } from '~/locale';
-import statusCodes from '../../lib/utils/http_status';
-import { bytesToMiB } from '../../lib/utils/number_utils';
-import { backOff } from '../../lib/utils/common_utils';
-import MemoryGraph from '../../vue_shared/components/memory_graph.vue';
-import MRWidgetService from '../services/mr_widget_service';
+import statusCodes from '~/lib/utils/http_status';
+import { bytesToMiB } from '~/lib/utils/number_utils';
+import { backOff } from '~/lib/utils/common_utils';
+import MemoryGraph from '~/vue_shared/components/memory_graph.vue';
+import MRWidgetService from '../../services/mr_widget_service';
export default {
name: 'MemoryUsage',
@@ -169,12 +169,6 @@ export default {
<p v-if="shouldShowMetricsUnavailable" class="usage-info js-usage-info usage-info-unavailable">
{{ s__('mrWidget|Deployment statistics are not available currently') }}
</p>
- <memory-graph
- v-if="shouldShowMemoryGraph"
- :metrics="memoryMetrics"
- :deployment-time="deploymentTime"
- height="25"
- width="100"
- />
+ <memory-graph v-if="shouldShowMemoryGraph" :metrics="memoryMetrics" :height="25" :width="110" />
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/loading.vue b/app/assets/javascripts/vue_merge_request_widget/components/loading.vue
new file mode 100644
index 00000000000..78dc28ee92b
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/loading.vue
@@ -0,0 +1,29 @@
+<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlSkeletonLoader,
+ },
+};
+</script>
+
+<template>
+ <div class="prepend-top-default">
+ <div class="mr-widget-heading p-3">
+ <gl-skeleton-loader :width="577" :height="12">
+ <rect width="86" height="12" rx="2" />
+ <rect x="96" width="300" height="12" rx="2" />
+ </gl-skeleton-loader>
+ </div>
+ <div class="mr-widget-heading mr-widget-workflow p-3">
+ <gl-skeleton-loader :width="577" :height="72">
+ <rect width="120" height="12" rx="2" />
+ <rect y="20" width="300" height="12" rx="2" />
+ <rect y="40" width="60" height="12" rx="2" />
+ <rect y="40" x="68" width="100" height="12" rx="2" />
+ <rect y="60" width="40" height="12" rx="2" />
+ </gl-skeleton-loader>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index 7c5f35579b8..42db1935123 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -1,12 +1,12 @@
<script>
/* eslint-disable vue/require-default-prop */
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import mrWidgetPipelineMixin from 'ee_else_ce/vue_merge_request_widget/mixins/mr_widget_pipeline';
import { sprintf, s__ } from '~/locale';
import PipelineStage from '~/pipelines/components/stage.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import Icon from '~/vue_shared/components/icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
-import mrWidgetPipelineMixin from 'ee_else_ce/vue_merge_request_widget/mixins/mr_widget_pipeline';
export default {
name: 'MRWidgetPipeline',
@@ -28,6 +28,10 @@ export default {
type: Object,
required: true,
},
+ pipelineCoverageDelta: {
+ type: String,
+ required: false,
+ },
// This prop needs to be camelCase, html attributes are case insensive
// https://vuejs.org/v2/guide/components.html#camelCase-vs-kebab-case
hasCi: {
@@ -92,6 +96,16 @@ export default {
showSourceBranch() {
return Boolean(this.pipeline.ref.branch);
},
+ coverageDeltaClass() {
+ const delta = this.pipelineCoverageDelta;
+ if (delta && parseFloat(delta) > 0) {
+ return 'text-success';
+ }
+ if (delta && parseFloat(delta) < 0) {
+ return 'text-danger';
+ }
+ return '';
+ },
},
};
</script>
@@ -142,6 +156,14 @@ export default {
</div>
<div v-if="pipeline.coverage" class="coverage">
{{ s__('Pipeline|Coverage') }} {{ pipeline.coverage }}%
+
+ <span
+ v-if="pipelineCoverageDelta"
+ class="js-pipeline-coverage-delta"
+ :class="coverageDeltaClass"
+ >
+ ({{ pipelineCoverageDelta }}%)
+ </span>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
index ffc3e0967d4..90fb254ecca 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
@@ -1,9 +1,10 @@
<script>
import _ from 'underscore';
import ArtifactsApp from './artifacts_list_app.vue';
-import Deployment from './deployment.vue';
+import Deployment from './deployment/deployment.vue';
import MrWidgetContainer from './mr_widget_container.vue';
import MrWidgetPipeline from './mr_widget_pipeline.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
/**
* Renders the pipeline and related deployments from the store.
@@ -23,6 +24,7 @@ export default {
MergeTrainPositionIndicator: () =>
import('ee_component/vue_merge_request_widget/components/merge_train_position_indicator.vue'),
},
+ mixins: [glFeatureFlagsMixin()],
props: {
mr: {
type: Object,
@@ -62,7 +64,7 @@ export default {
return this.isPostMerge ? this.mr.mergePipeline : this.mr.pipeline;
},
showVisualReviewAppLink() {
- return this.mr.visualReviewAppAvailable;
+ return this.mr.visualReviewAppAvailable && this.glFeatures.anonymousVisualReviewFeedback;
},
showMergeTrainPositionIndicator() {
return _.isNumber(this.mr.mergeTrainIndex);
@@ -74,6 +76,7 @@ export default {
<mr-widget-container>
<mr-widget-pipeline
:pipeline="pipeline"
+ :pipeline-coverage-delta="mr.pipelineCoverageDelta"
:ci-status="mr.ciStatus"
:has-ci="mr.hasCI"
:source-branch="branch"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
index 75f557d05dd..1550ec0f21e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
@@ -1,4 +1,5 @@
<script>
+import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
export default {
@@ -6,19 +7,35 @@ export default {
Icon,
},
props: {
- link: {
+ cssClass: {
type: String,
required: true,
},
- cssClass: {
+ isCurrent: {
+ type: Boolean,
+ required: true,
+ },
+ link: {
type: String,
required: true,
},
},
+ computed: {
+ linkText() {
+ return this.isCurrent ? __('View app') : __('View previous app');
+ },
+ },
};
</script>
<template>
- <a :href="link" target="_blank" rel="noopener noreferrer nofollow" :class="cssClass">
- {{ __('View app') }} <icon class="fgray" name="external-link" />
+ <a
+ :href="link"
+ target="_blank"
+ rel="noopener noreferrer nofollow"
+ :class="cssClass"
+ data-track-event="open_review_app"
+ data-track-label="review_app"
+ >
+ {{ linkText }} <icon class="fgray" name="external-link" />
</a>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
index a2b5a79af36..c8e652a1305 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
@@ -1,5 +1,6 @@
<script>
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
+import { GlLoadingIcon } from '@gitlab/ui';
import Flash from '~/flash';
import tooltip from '~/vue_shared/directives/tooltip';
import { s__, __ } from '~/locale';
@@ -7,7 +8,6 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import MrWidgetAuthorTime from '../../components/mr_widget_author_time.vue';
import statusIcon from '../mr_widget_status_icon.vue';
import eventHub from '../../event_hub';
-import { GlLoadingIcon } from '@gitlab/ui';
export default {
name: 'MRWidgetMerged',
@@ -155,7 +155,7 @@ export default {
{{ cherryPickLabel }}
</a>
</div>
- <section class="mr-info-list">
+ <section class="mr-info-list" data-qa-selector="merged_status_content">
<p>
{{ s__('mrWidget|The changes were merged into') }}
<span class="label-branch">
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 2c113770d8b..d230ac566de 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
@@ -1,10 +1,11 @@
<script>
import _ from 'underscore';
+import { GlIcon } from '@gitlab/ui';
import successSvg from 'icons/_icon_status_success.svg';
import warningSvg from 'icons/_icon_status_warning.svg';
-import simplePoll from '~/lib/utils/simple_poll';
-import { __ } from '~/locale';
import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge';
+import simplePoll from '~/lib/utils/simple_poll';
+import { __, sprintf } from '~/locale';
import MergeRequest from '../../../merge_request';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import Flash from '../../../flash';
@@ -24,6 +25,11 @@ export default {
CommitsHeader,
CommitEdit,
CommitMessageDropdown,
+ GlIcon,
+ MergeImmediatelyConfirmationDialog: () =>
+ import(
+ 'ee_component/vue_merge_request_widget/components/merge_immediately_confirmation_dialog.vue'
+ ),
},
mixins: [readyToMergeMixin],
props: {
@@ -111,6 +117,18 @@ export default {
shouldShowMergeEdit() {
return !this.mr.ffOnlyEnabled;
},
+ shaMismatchLink() {
+ const href = this.mr.mergeRequestDiffsPath;
+
+ return sprintf(
+ __('New changes were added. %{linkStart}Reload the page to review them%{linkEnd}'),
+ {
+ linkStart: `<a href="${href}">`,
+ linkEnd: '</a>',
+ },
+ false,
+ );
+ },
},
methods: {
updateMergeCommitMessage(includeDescription) {
@@ -123,7 +141,7 @@ export default {
}
const options = {
- sha: this.mr.sha,
+ sha: this.mr.latestSHA || this.mr.sha,
commit_message: this.commitMessage,
auto_merge_strategy: useAutoMerge ? this.mr.preferredAutoMergeStrategy : undefined,
should_remove_source_branch: this.removeSourceBranch === true,
@@ -151,6 +169,16 @@ export default {
new Flash(__('Something went wrong. Please try again.')); // eslint-disable-line
});
},
+ handleMergeImmediatelyButtonClick() {
+ if (this.isMergeImmediatelyDangerous) {
+ this.$refs.confirmationDialog.show();
+ } else {
+ this.handleMergeButtonClick(false, true);
+ }
+ },
+ onMergeImmediatelyConfirmation() {
+ this.handleMergeButtonClick(false, true);
+ },
initiateMergePolling() {
simplePoll(
(continuePolling, stopPolling) => {
@@ -249,9 +277,10 @@ export default {
type="button"
class="btn btn-sm btn-info dropdown-toggle js-merge-moment"
data-toggle="dropdown"
+ data-qa-selector="merge_moment_dropdown"
:aria-label="__('Select merge moment')"
>
- <i class="fa fa-chevron-down qa-merge-moment-dropdown" aria-hidden="true"></i>
+ <i class="fa fa-chevron-down" aria-hidden="true"></i>
</button>
<ul
v-if="shouldShowMergeImmediatelyDropdown"
@@ -271,10 +300,16 @@ export default {
</a>
</li>
<li>
+ <merge-immediately-confirmation-dialog
+ ref="confirmationDialog"
+ :docs-url="mr.mergeImmediatelyDocsPath"
+ @mergeImmediately="onMergeImmediatelyConfirmation"
+ />
<a
- class="accept-merge-request qa-merge-immediately-option"
+ class="accept-merge-request js-merge-immediately-button"
+ data-qa-selector="merge_immediately_option"
href="#"
- @click.prevent="handleMergeButtonClick(false, true)"
+ @click.prevent="handleMergeImmediatelyButtonClick"
>
<span class="media">
<span class="merge-opt-icon" aria-hidden="true" v-html="warningSvg"></span>
@@ -312,6 +347,10 @@ export default {
</template>
</div>
</div>
+ <div v-if="mr.isSHAMismatch" class="d-flex align-items-center mt-2 js-sha-mismatch">
+ <gl-icon name="warning-solid" class="text-warning mr-1" />
+ <span class="text-warning" v-html="shaMismatchLink"></span>
+ </div>
</div>
</div>
<template v-if="shouldShowMergeControls">
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
index 40e6203599f..32a2b7b83f4 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
@@ -23,5 +23,8 @@ export default {
shouldShowMergeImmediatelyDropdown() {
return this.mr.isPipelineActive && !this.mr.onlyAllowMergeIfPipelineSucceeds;
},
+ isMergeImmediatelyDangerous() {
+ return false;
+ },
},
};
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 edd21a81f8b..38a7c262b3e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -1,16 +1,17 @@
<script>
import _ from 'underscore';
-import { sprintf, s__, __ } from '~/locale';
-import Project from '~/pages/projects/project';
-import SmartInterval from '~/smart_interval';
import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store';
import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service';
import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps';
+import { sprintf, s__, __ } from '~/locale';
+import Project from '~/pages/projects/project';
+import SmartInterval from '~/smart_interval';
import createFlash from '../flash';
+import Loading from './components/loading.vue';
import WidgetHeader from './components/mr_widget_header.vue';
import WidgetMergeHelp from './components/mr_widget_merge_help.vue';
import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue';
-import Deployment from './components/deployment.vue';
+import Deployment from './components/deployment/deployment.vue';
import WidgetRelatedLinks from './components/mr_widget_related_links.vue';
import MrWidgetAlertMessage from './components/mr_widget_alert_message.vue';
import MergedState from './components/states/mr_widget_merged.vue';
@@ -24,7 +25,6 @@ import NothingToMergeState from './components/states/nothing_to_merge.vue';
import MissingBranchState from './components/states/mr_widget_missing_branch.vue';
import NotAllowedState from './components/states/mr_widget_not_allowed.vue';
import ReadyToMergeState from './components/states/ready_to_merge.vue';
-import ShaMismatchState from './components/states/sha_mismatch.vue';
import UnresolvedDiscussionsState from './components/states/unresolved_discussions.vue';
import PipelineBlockedState from './components/states/mr_widget_pipeline_blocked.vue';
import PipelineFailedState from './components/states/pipeline_failed.vue';
@@ -44,6 +44,7 @@ export default {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
name: 'MRWidget',
components: {
+ Loading,
'mr-widget-header': WidgetHeader,
'mr-widget-merge-help': WidgetMergeHelp,
MrWidgetPipelineContainer,
@@ -61,7 +62,7 @@ export default {
'mr-widget-not-allowed': NotAllowedState,
'mr-widget-missing-branch': MissingBranchState,
'mr-widget-ready-to-merge': ReadyToMergeState,
- 'sha-mismatch': ShaMismatchState,
+ 'sha-mismatch': ReadyToMergeState,
'mr-widget-checking': CheckingState,
'mr-widget-unresolved-discussions': UnresolvedDiscussionsState,
'mr-widget-pipeline-blocked': PipelineBlockedState,
@@ -80,12 +81,12 @@ export default {
},
},
data() {
- const store = new MRWidgetStore(this.mrData || window.gl.mrWidgetData);
- const service = this.createService(store);
+ const store = this.mrData && new MRWidgetStore(this.mrData);
+
return {
mr: store,
- state: store.state,
- service,
+ state: store && store.state,
+ service: store && this.createService(store),
};
},
computed: {
@@ -133,29 +134,58 @@ export default {
}
},
},
- created() {
- this.initPolling();
- this.bindEventHubListeners();
- eventHub.$on('mr.discussion.updated', this.checkStatus);
- },
mounted() {
- this.setFaviconHelper();
- this.initDeploymentsPolling();
-
- if (this.shouldRenderMergedPipeline) {
- this.initPostMergeDeploymentsPolling();
+ 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();
}
},
beforeDestroy() {
eventHub.$off('mr.discussion.updated', this.checkStatus);
- this.pollingInterval.destroy();
- this.deploymentsInterval.destroy();
+ if (this.pollingInterval) {
+ this.pollingInterval.destroy();
+ }
+
+ if (this.deploymentsInterval) {
+ this.deploymentsInterval.destroy();
+ }
if (this.postMergeDeploymentsInterval) {
this.postMergeDeploymentsInterval.destroy();
}
},
methods: {
+ initWidget(data = {}) {
+ if (this.mr) {
+ this.mr.setData({ ...window.gl.mrWidgetData, ...data });
+ } else {
+ this.mr = new MRWidgetStore({ ...window.gl.mrWidgetData, ...data });
+ }
+
+ if (!this.state) {
+ this.state = this.mr.state;
+ }
+
+ if (!this.service) {
+ this.service = this.createService(this.mr);
+ }
+
+ this.setFaviconHelper();
+ this.initDeploymentsPolling();
+
+ if (this.shouldRenderMergedPipeline) {
+ this.initPostMergeDeploymentsPolling();
+ }
+
+ this.initPolling();
+ this.bindEventHubListeners();
+ eventHub.$on('mr.discussion.updated', this.checkStatus);
+ },
getServiceEndpoints(store) {
return {
mergePath: store.mergePath,
@@ -319,7 +349,7 @@ export default {
};
</script>
<template>
- <div class="mr-state-widget prepend-top-default">
+ <div v-if="mr" class="mr-state-widget prepend-top-default">
<mr-widget-header :mr="mr" />
<mr-widget-pipeline-container
v-if="shouldRenderPipelines"
@@ -377,4 +407,5 @@ export default {
:is-post-merge="true"
/>
</div>
+ <loading v-else />
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
index 8a229d80954..d22cb4ced80 100644
--- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
+++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
@@ -61,4 +61,11 @@ export default class MRWidgetService {
static fetchMetrics(metricsUrl) {
return axios.get(`${metricsUrl}.json`);
}
+
+ static fetchInitialData() {
+ return Promise.all([
+ axios.get(window.gl.mrWidgetData.merge_request_cached_widget_path),
+ axios.get(window.gl.mrWidgetData.merge_request_widget_path),
+ ]).then(axios.spread((res, cachedRes) => ({ data: Object.assign(res.data, cachedRes.data) })));
+ }
}
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 f51d0fa4f52..c7949fa264e 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
@@ -1,4 +1,4 @@
-import Timeago from 'timeago.js';
+import { format } from 'timeago.js';
import _ from 'underscore';
import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key';
import { stateKey } from './state_maps';
@@ -42,12 +42,14 @@ export default class MergeRequestStore {
this.commitsCount = data.commits_count;
this.divergedCommitsCount = data.diverged_commits_count;
this.pipeline = data.pipeline || {};
+ this.pipelineCoverageDelta = data.pipeline_coverage_delta;
this.mergePipeline = data.merge_pipeline || {};
this.deployments = this.deployments || data.deployments || [];
this.postMergeDeployments = this.postMergeDeployments || [];
this.commits = data.commits_without_merge_commits || [];
this.squashCommitMessage = data.default_squash_commit_message;
this.rebaseInProgress = data.rebase_in_progress;
+ this.mergeRequestDiffsPath = data.diffs_path;
if (data.issues_links) {
const links = data.issues_links;
@@ -81,6 +83,7 @@ export default class MergeRequestStore {
this.isOpen = data.state === 'opened';
this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false;
this.isSHAMismatch = this.sha !== data.diff_head_sha;
+ this.latestSHA = data.diff_head_sha;
this.canBeMerged = data.can_be_merged || false;
this.isMergeAllowed = data.mergeable || false;
this.mergeOngoing = data.merge_ongoing;
@@ -170,6 +173,8 @@ export default class MergeRequestStore {
this.conflictsDocsPath = data.conflicts_docs_path;
this.ciEnvironmentsStatusPath = data.ci_environments_status_path;
this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path;
+ this.eligibleApproversDocsPath = data.eligible_approvers_docs_path;
+ this.mergeImmediatelyDocsPath = data.merge_immediately_docs_path;
}
get isNothingToMergeState() {
@@ -213,9 +218,7 @@ export default class MergeRequestStore {
return '';
}
- const timeagoInstance = new Timeago();
-
- return timeagoInstance.format(date);
+ return format(date);
}
static getPreferredAutoMergeStrategy(availableAutoMergeStrategies) {
diff --git a/app/assets/javascripts/vue_shared/components/bar_chart.vue b/app/assets/javascripts/vue_shared/components/bar_chart.vue
index eabf5d4bf60..25d7bfe515c 100644
--- a/app/assets/javascripts/vue_shared/components/bar_chart.vue
+++ b/app/assets/javascripts/vue_shared/components/bar_chart.vue
@@ -55,13 +55,13 @@ export default {
vbWidth: 0,
vbHeight: 0,
vpWidth: 0,
- vpHeight: 350,
- preserveAspectRatioType: 'xMidYMid meet',
+ vpHeight: 200,
+ preserveAspectRatioType: 'xMidYMin meet',
containerMargin: {
leftRight: 30,
},
viewBoxMargin: {
- topBottom: 150,
+ topBottom: 100,
},
panX: 0,
xScale: {},
@@ -274,6 +274,7 @@ export default {
<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"
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
index c50304f057d..eb3e489fb8c 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
@@ -1,8 +1,8 @@
<script>
-import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
import $ from 'jquery';
import { GlSkeletonLoading } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
const { CancelToken } = axios;
let axiosSource;
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
index 22f370c4bca..494df2d7a37 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
@@ -1,6 +1,6 @@
<script>
-import { __ } from '~/locale';
import { GlLoadingIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
export default {
components: {
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue
index c01c7cc4ccc..610bce9a705 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue
@@ -8,6 +8,11 @@ export default {
required: true,
default: __('Search'),
},
+ focused: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return { searchQuery: this.value };
@@ -16,6 +21,11 @@ export default {
searchQuery(query) {
this.$emit('input', query);
},
+ focused(val) {
+ if (val) {
+ this.$refs.searchInput.focus();
+ }
+ },
},
};
</script>
@@ -23,6 +33,7 @@ export default {
<template>
<div class="dropdown-input">
<input
+ ref="searchInput"
v-model="searchQuery"
:placeholder="placeholderText"
class="dropdown-input-field"
diff --git a/app/assets/javascripts/vue_shared/components/expand_button.vue b/app/assets/javascripts/vue_shared/components/expand_button.vue
index d64ab774431..e2a6e92081f 100644
--- a/app/assets/javascripts/vue_shared/components/expand_button.vue
+++ b/app/assets/javascripts/vue_shared/components/expand_button.vue
@@ -1,4 +1,5 @@
<script>
+import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
@@ -15,6 +16,7 @@ import Icon from '~/vue_shared/components/icon.vue';
export default {
name: 'ExpandButton',
components: {
+ GlButton,
Icon,
},
data() {
@@ -39,15 +41,25 @@ export default {
</script>
<template>
<span>
- <button
+ <gl-button
v-show="isCollapsed"
:aria-label="ariaLabel"
type="button"
- class="text-expander btn-blank"
+ class="js-text-expander-prepend text-expander btn-blank"
@click="onClick"
>
<icon :size="12" name="ellipsis_h" />
- </button>
+ </gl-button>
+ <span v-if="isCollapsed"> <slot name="short"></slot> </span>
<span v-if="!isCollapsed"> <slot name="expanded"></slot> </span>
+ <gl-button
+ v-show="!isCollapsed"
+ :aria-label="ariaLabel"
+ type="button"
+ class="js-text-expander-append text-expander btn-blank"
+ @click="onClick"
+ >
+ <icon :size="12" name="ellipsis_h" />
+ </gl-button>
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/gl_countdown.vue b/app/assets/javascripts/vue_shared/components/gl_countdown.vue
index 4aae3549601..1769a283d8c 100644
--- a/app/assets/javascripts/vue_shared/components/gl_countdown.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_countdown.vue
@@ -1,6 +1,6 @@
<script>
-import { calculateRemainingMilliseconds, formatTime } from '~/lib/utils/datetime_utility';
import { GlTooltipDirective } from '@gitlab/ui';
+import { calculateRemainingMilliseconds, formatTime } from '~/lib/utils/datetime_utility';
/**
* Counts down to a given end date.
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue
index 9b2ee5062b1..cfbc5b0df3c 100644
--- a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue
@@ -54,7 +54,7 @@ export default {
return timeFor(
this.milestoneDue,
sprintf(__('Expired %{expiredOn}'), {
- expiredOn: this.timeFormated(this.milestoneDue),
+ expiredOn: this.timeFormatted(this.milestoneDue),
}),
);
}
@@ -62,7 +62,7 @@ export default {
return sprintf(
this.isMilestoneStarted ? __('Started %{startsIn}') : __('Starts %{startsIn}'),
{
- startsIn: this.timeFormated(this.milestoneStart),
+ startsIn: this.timeFormatted(this.milestoneStart),
},
);
}
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
index cc700440a23..8a8cf09194c 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -1,6 +1,6 @@
<script>
-import Icon from '~/vue_shared/components/icon.vue';
import { GlButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
export default {
components: { Icon, GlButton, GlLoadingIcon },
diff --git a/app/assets/javascripts/vue_shared/components/memory_graph.vue b/app/assets/javascripts/vue_shared/components/memory_graph.vue
index 26d7d8e8866..af0b85cc6e4 100644
--- a/app/assets/javascripts/vue_shared/components/memory_graph.vue
+++ b/app/assets/javascripts/vue_shared/components/memory_graph.vue
@@ -1,128 +1,43 @@
<script>
-import { __, sprintf } from '~/locale';
-import { getTimeago } from '../../lib/utils/datetime_utility';
+import { GlSparklineChart } from '@gitlab/ui/dist/charts';
+import { formatDate, secondsToMilliseconds } from '~/lib/utils/datetime_utility';
export default {
name: 'MemoryGraph',
+ components: {
+ GlSparklineChart,
+ },
props: {
metrics: { type: Array, required: true },
- deploymentTime: { type: Number, required: true },
- width: { type: String, required: true },
- height: { type: String, required: true },
- },
- data() {
- return {
- pathD: '',
- pathViewBox: '',
- dotX: '',
- dotY: '',
- };
+ width: { type: Number, required: true },
+ height: { type: Number, required: true },
},
computed: {
- getFormattedMedian() {
- const deployedSince = getTimeago().format(this.deploymentTime * 1000);
- return sprintf(__('Deployed %{deployedSince}'), { deployedSince });
+ chartData() {
+ return this.metrics.map(([x, y]) => [
+ this.getFormattedDeploymentTime(x),
+ this.getMemoryUsage(y),
+ ]);
},
},
- mounted() {
- this.renderGraph(this.deploymentTime, this.metrics);
- },
methods: {
- /**
- * Returns metric value index in metrics array
- * with timestamp closest to matching median
- */
- getMedianMetricIndex(median, metrics) {
- let matchIndex = 0;
- let timestampDiff = 0;
- let smallestDiff = 0;
-
- const metricTimestamps = metrics.map(v => v[0]);
-
- // Find metric timestamp which is closest to deploymentTime
- timestampDiff = Math.abs(metricTimestamps[0] - median);
- metricTimestamps.forEach((timestamp, index) => {
- if (index === 0) {
- // Skip first element
- return;
- }
-
- smallestDiff = Math.abs(timestamp - median);
- if (smallestDiff < timestampDiff) {
- matchIndex = index;
- timestampDiff = smallestDiff;
- }
- });
-
- return matchIndex;
+ getFormattedDeploymentTime(timestamp) {
+ return formatDate(new Date(secondsToMilliseconds(timestamp)), 'mmm dd yyyy HH:MM:s');
},
-
- /**
- * Get Graph Plotting values to render Line and Dot
- */
- getGraphPlotValues(median, metrics) {
- const renderData = metrics.map(v => v[1]);
- const medianMetricIndex = this.getMedianMetricIndex(median, metrics);
- let cx = 0;
- let cy = 0;
-
- // Find Maximum and Minimum values from `renderData` array
- const maxMemory = Math.max.apply(null, renderData);
- const minMemory = Math.min.apply(null, renderData);
-
- // Find difference between extreme ends
- const diff = maxMemory - minMemory;
- const lineWidth = renderData.length;
-
- // Iterate over metrics values and perform following
- // 1. Find x & y co-ords for deploymentTime's memory value
- // 2. Return line path against maxMemory
- const linePath = renderData.map((y, x) => {
- if (medianMetricIndex === x) {
- cx = x;
- cy = maxMemory - y;
- }
- return `${x} ${maxMemory - y}`;
- });
-
- return {
- pathD: linePath,
- pathViewBox: {
- lineWidth,
- diff,
- },
- dotX: cx,
- dotY: cy,
- };
- },
-
- /**
- * Render Graph based on provided median and metrics values
- */
- renderGraph(median, metrics) {
- const { pathD, pathViewBox, dotX, dotY } = this.getGraphPlotValues(median, metrics);
-
- // Set props and update graph on UI.
- this.pathD = `M ${pathD}`;
- this.pathViewBox = `0 0 ${pathViewBox.lineWidth} ${pathViewBox.diff}`;
- this.dotX = dotX;
- this.dotY = dotY;
+ getMemoryUsage(MBs) {
+ return Number(MBs).toFixed(2);
},
},
};
</script>
<template>
- <div class="memory-graph-container">
- <svg
- :title="getFormattedMedian"
- :width="width"
+ <div class="memory-graph-container p-1" :style="{ width: `${width}px` }">
+ <gl-sparkline-chart
:height="height"
- class="has-tooltip"
- xmlns="http://www.w3.org/2000/svg"
- >
- <path :d="pathD" :viewBox="pathViewBox" />
- <circle :cx="dotX" :cy="dotY" r="1.5" transform="translate(0 -1)" />
- </svg>
+ :tooltip-label="__('MB')"
+ :show-last-y-value="false"
+ :data="chartData"
+ />
</div>
</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 edbeab9c600..cdcfff42981 100644
--- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
+++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
@@ -1,9 +1,9 @@
<script>
import $ from 'jquery';
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import Clipboard from 'clipboard';
import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
-import Clipboard from 'clipboard';
export default {
components: {
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 f8e010c4f42..15ca64ba297 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -19,9 +19,9 @@
import $ from 'jquery';
import { mapGetters, mapActions } from 'vuex';
import { GlSkeletonLoading } 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 descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history';
import TimelineEntryItem from './timeline_entry_item.vue';
import { spriteIcon } from '../../../lib/utils/common_utils';
import initMRPopovers from '~/mr_popover/';
diff --git a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue
index e89638130f5..29a4a90a59f 100644
--- a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue
+++ b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue
@@ -1,15 +1,18 @@
<script>
+import { GlPagination } from '@gitlab/ui';
import {
- PAGINATION_UI_BUTTON_LIMIT,
- UI_LIMIT,
- SPREAD,
PREV,
NEXT,
- FIRST,
- LAST,
+ LABEL_FIRST_PAGE,
+ LABEL_PREV_PAGE,
+ LABEL_NEXT_PAGE,
+ LABEL_LAST_PAGE,
} from '~/vue_shared/components/pagination/constants';
export default {
+ components: {
+ GlPagination,
+ },
props: {
/**
This function will take the information given by the pagination component
@@ -46,113 +49,34 @@ export default {
},
},
computed: {
- prev() {
- return this.pageInfo.previousPage;
- },
- next() {
- return this.pageInfo.nextPage;
- },
- getItems() {
- const { totalPages, nextPage, previousPage, page } = this.pageInfo;
- const items = [];
-
- if (page > 1) {
- items.push({ title: FIRST, first: true });
- }
-
- if (previousPage) {
- items.push({ title: PREV, prev: true });
- } else {
- items.push({ title: PREV, disabled: true, prev: true });
- }
-
- if (page > UI_LIMIT) items.push({ title: SPREAD, separator: true });
-
- if (totalPages) {
- const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1);
- const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, totalPages);
-
- for (let i = start; i <= end; i += 1) {
- const isActive = i === page;
- items.push({ title: i, active: isActive, page: true });
- }
-
- if (totalPages - page > PAGINATION_UI_BUTTON_LIMIT) {
- items.push({ title: SPREAD, separator: true, page: true });
- }
- }
-
- if (nextPage) {
- items.push({ title: NEXT, next: true });
- } else {
- items.push({ title: NEXT, disabled: true, next: true });
- }
-
- if (totalPages && totalPages - page >= 1) {
- items.push({ title: LAST, last: true });
- }
-
- return items;
- },
showPagination() {
return this.pageInfo.nextPage || this.pageInfo.previousPage;
},
},
- methods: {
- changePage(text, isDisabled) {
- if (isDisabled) return;
-
- const { totalPages, nextPage, previousPage } = this.pageInfo;
-
- switch (text) {
- case SPREAD:
- break;
- case LAST:
- this.change(totalPages);
- break;
- case NEXT:
- this.change(nextPage);
- break;
- case PREV:
- this.change(previousPage);
- break;
- case FIRST:
- this.change(1);
- break;
- default:
- this.change(Number(text));
- break;
- }
- },
- hideOnSmallScreen(item) {
- return !item.first && !item.last && !item.next && !item.prev && !item.active;
- },
- },
+ prevText: PREV,
+ nextText: NEXT,
+ labelFirstPage: LABEL_FIRST_PAGE,
+ labelPrevPage: LABEL_PREV_PAGE,
+ labelNextPage: LABEL_NEXT_PAGE,
+ labelLastPage: LABEL_LAST_PAGE,
};
</script>
<template>
- <div v-if="showPagination" class="gl-pagination prepend-top-default">
- <ul class="pagination justify-content-center">
- <li
- v-for="(item, index) in getItems"
- :key="index"
- :class="{
- page: item.page,
- 'js-previous-button': item.prev,
- 'js-next-button': item.next,
- 'js-last-button': item.last,
- 'js-first-button': item.first,
- 'd-none d-md-block': hideOnSmallScreen(item),
- separator: item.separator,
- active: item.active,
- disabled: item.disabled || item.separator,
- }"
- class="page-item"
- >
- <button type="button" class="page-link" @click="changePage(item.title, item.disabled)">
- {{ item.title }}
- </button>
- </li>
- </ul>
- </div>
+ <gl-pagination
+ v-if="showPagination"
+ class="justify-content-center prepend-top-default"
+ v-bind="$attrs"
+ :value="pageInfo.page"
+ :per-page="pageInfo.perPage"
+ :total-items="pageInfo.total"
+ :prev-page="pageInfo.previousPage"
+ :prev-text="$options.prevText"
+ :next-page="pageInfo.nextPage"
+ :next-text="$options.nextText"
+ :label-first-page="$options.labelFirstPage"
+ :label-prev-page="$options.labelPrevPage"
+ :label-next-page="$options.labelNextPage"
+ :label-last-page="$options.labelLastPage"
+ @input="change"
+ />
</template>
diff --git a/app/assets/javascripts/vue_shared/components/project_avatar/image.vue b/app/assets/javascripts/vue_shared/components/project_avatar/image.vue
index 43bbb756805..269736c799c 100644
--- a/app/assets/javascripts/vue_shared/components/project_avatar/image.vue
+++ b/app/assets/javascripts/vue_shared/components/project_avatar/image.vue
@@ -14,8 +14,8 @@
/>
*/
-import { __ } from '~/locale';
import defaultAvatarUrl from 'images/no_avatar.png';
+import { __ } from '~/locale';
import { placeholderImage } from '../../../lazy_loader';
export default {
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
index 071bae7f665..c472e54efda 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
@@ -1,10 +1,10 @@
<script>
import { GlButton } from '@gitlab/ui';
+import _ from 'underscore';
import Icon from '~/vue_shared/components/icon.vue';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
-import _ from 'underscore';
export default {
name: 'ProjectListItem',
diff --git a/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue b/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue
index 1f3d248e991..02cb7785ef4 100644
--- a/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue
+++ b/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue
@@ -1,6 +1,6 @@
<script>
-import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
import $ from 'jquery';
+import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
export default {
data() {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue
index c1f3d86335a..80c61627b8f 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue
@@ -64,7 +64,7 @@ export default {
tooltipText(dateType = 'min') {
const defaultText = dateType === 'min' ? __('Start date') : __('Due date');
const date = this[`${dateType}Date`];
- const timeAgo = dateType === 'min' ? this.timeFormated(date) : timeFor(date);
+ const timeAgo = dateType === 'min' ? this.timeFormatted(date) : timeFor(date);
const dateText = date ? [this.dateText(dateType), `(${timeAgo})`].join(' ') : '';
if (date) {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
index 13795eff714..0e401a9f7aa 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
@@ -1,10 +1,10 @@
<script>
import $ from 'jquery';
+import { GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import LabelsSelect from '~/labels_select';
import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue';
-import { GlLoadingIcon } from '@gitlab/ui';
import DropdownTitle from './dropdown_title.vue';
import DropdownValue from './dropdown_value.vue';
import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
diff --git a/app/assets/javascripts/vue_shared/components/split_button.vue b/app/assets/javascripts/vue_shared/components/split_button.vue
index f7dc00a345c..9aacde49264 100644
--- a/app/assets/javascripts/vue_shared/components/split_button.vue
+++ b/app/assets/javascripts/vue_shared/components/split_button.vue
@@ -26,6 +26,11 @@ export default {
required: false,
default: '',
},
+ variant: {
+ type: String,
+ required: false,
+ default: 'secondary',
+ },
},
data() {
@@ -53,6 +58,7 @@ export default {
:menu-class="`dropdown-menu-selectable ${menuClass}`"
split
:text="dropdownToggleText"
+ :variant="variant"
v-bind="$attrs"
@click="triggerEvent"
>
diff --git a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue
index af4eb2de7f8..ea564d1b2f2 100644
--- a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue
+++ b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue
@@ -71,6 +71,10 @@ export default {
},
methods: {
getPercent(count) {
+ if (!this.totalCount) {
+ return 0;
+ }
+
const percent = roundOffFloat((count / this.totalCount) * 100, 1);
if (percent > 0 && percent < 1) {
return '< 1';
diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
index 43935cf31d5..b1a4f3dccaf 100644
--- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
+++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
@@ -35,7 +35,7 @@ export default {
v-gl-tooltip.viewport="{ placement: tooltipPlacement }"
:class="cssClass"
:title="tooltipTitle(time)"
- v-text="timeFormated(time)"
+ v-text="timeFormatted(time)"
>
</time>
</template>
diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
index 3c727cb7b3f..fbebd7c7945 100644
--- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
+++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
@@ -159,7 +159,7 @@ const mixins = {
return this.displayReference.split(this.pathIdSeparator).pop();
},
createdAtInWords() {
- return this.createdAt ? this.timeFormated(this.createdAt) : '';
+ return this.createdAt ? this.timeFormatted(this.createdAt) : '';
},
createdAtTimestamp() {
return this.createdAt ? formatDate(new Date(this.createdAt)) : '';
@@ -168,10 +168,10 @@ const mixins = {
return this.mergedAt ? formatDate(new Date(this.mergedAt)) : '';
},
mergedAtInWords() {
- return this.mergedAt ? this.timeFormated(this.mergedAt) : '';
+ return this.mergedAt ? this.timeFormatted(this.mergedAt) : '';
},
closedAtInWords() {
- return this.closedAt ? this.timeFormated(this.closedAt) : '';
+ return this.closedAt ? this.timeFormatted(this.closedAt) : '';
},
closedAtTimestamp() {
return this.closedAt ? formatDate(new Date(this.closedAt)) : '';
diff --git a/app/assets/javascripts/vue_shared/mixins/timeago.js b/app/assets/javascripts/vue_shared/mixins/timeago.js
index 4e3b9d7b767..af14c6d9486 100644
--- a/app/assets/javascripts/vue_shared/mixins/timeago.js
+++ b/app/assets/javascripts/vue_shared/mixins/timeago.js
@@ -5,7 +5,7 @@ import { formatDate, getTimeago } from '../../lib/utils/datetime_utility';
*/
export default {
methods: {
- timeFormated(time) {
+ timeFormatted(time) {
const timeago = getTimeago();
return timeago.format(time);
diff --git a/app/assets/stylesheets/components/release_block_milestone_info.scss b/app/assets/stylesheets/components/release_block_milestone_info.scss
new file mode 100644
index 00000000000..b6a85ae965a
--- /dev/null
+++ b/app/assets/stylesheets/components/release_block_milestone_info.scss
@@ -0,0 +1,6 @@
+.release-block-milestone-info {
+ .milestone-progress-bar-container {
+ width: 300px;
+ min-height: 46px;
+ }
+}
diff --git a/app/assets/stylesheets/components/toast.scss b/app/assets/stylesheets/components/toast.scss
deleted file mode 100644
index e27bf282247..00000000000
--- a/app/assets/stylesheets/components/toast.scss
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
-* These styles are specific to the gl-toast component.
-* Documentation: https://design.gitlab.com/components/toasts
-* Note: Styles below are nested in order to override some of vue-toasted's default styling
-*/
-.toasted-container {
-
- max-width: $toast-max-width;
-
- @include media-breakpoint-down(xs) {
- width: 100%;
- padding-right: $toast-padding-right;
- }
-
- .toasted.gl-toast {
- border-radius: $border-radius-default;
- font-size: $gl-font-size;
- padding: $gl-padding-8 $gl-padding $gl-padding-8 $gl-padding-24;
- margin-top: $toast-default-margin;
- line-height: $gl-line-height;
- background-color: rgba($gray-900, $toast-background-opacity);
-
- span {
- padding-right: $gl-padding-8;
- }
-
- @include media-breakpoint-down(xs) {
- .action:first-of-type {
- // Ensures actions buttons are right aligned on mobile
- margin-left: auto;
- }
- }
-
- .action {
- color: $blue-300;
- margin: 0 0 0 $toast-default-margin;
- text-transform: none;
- font-size: $gl-font-size;
- }
-
- .toast-close {
- font-size: $default-icon-size;
- margin-left: $toast-default-margin;
- }
- }
-}
-
-// Overrides the default positioning of toasts
-body .toasted-container.bottom-left {
- bottom: $toast-offset;
- left: $toast-offset;
-}
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 31ea59df4c5..4b7dda3a2ff 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -433,6 +433,7 @@ img.emoji {
.block { display: block; }
.flex { display: flex; }
.vertical-align-top { vertical-align: top; }
+.vertical-align-text-top { vertical-align: text-top; }
.vertical-align-middle { vertical-align: middle; }
.vertical-align-sub { vertical-align: sub; }
.flex-align-self-center { align-self: center; }
@@ -442,6 +443,7 @@ img.emoji {
.ws-normal { white-space: normal; }
.ws-pre-wrap { white-space: pre-wrap; }
.overflow-auto { overflow: auto; }
+.overflow-visible { overflow: visible; }
.d-flex-center {
display: flex;
@@ -514,6 +516,12 @@ img.emoji {
cursor: pointer;
}
+// this needs to use "!important" due to some very specific styles
+// around buttons
+.cursor-default {
+ cursor: default !important;
+}
+
// Make buttons/dropdowns full-width on mobile
.full-width-mobile {
@include media-breakpoint-down(xs) {
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index d53a4c1286c..21253e004ef 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -288,7 +288,7 @@
list-style: none;
padding: 0 1px;
- a,
+ > a,
button,
.menu-item {
@include dropdown-link;
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 4938215b2e7..8e0314bc6da 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -334,10 +334,6 @@ span.idiff {
padding: $gl-padding-8 $gl-padding;
margin: 0;
border-radius: $border-radius-default $border-radius-default 0 0;
-
- &.is-stuck {
- border-radius: 0;
- }
}
.file-header-content {
@@ -490,3 +486,8 @@ span.idiff {
overflow-y: auto;
max-height: 20rem;
}
+
+#js-openapi-viewer pre.version {
+ background-color: transparent;
+ border: transparent;
+}
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 2d826064569..1c252584047 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -214,8 +214,8 @@
padding-left: 0;
height: $input-height - 2;
line-height: inherit;
- border-color: transparent;
+ &,
&:focus,
&:hover {
outline: none;
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 1195e467192..5ae4f72de56 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -552,6 +552,11 @@
svg {
vertical-align: text-top;
}
+
+ a.trial-link gl-emoji {
+ font-size: $gl-font-size;
+ vertical-align: baseline;
+ }
}
}
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index ecd32dcd0ce..4aba633e182 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -196,6 +196,11 @@ ul.content-list {
display: flex;
align-items: center;
white-space: nowrap;
+
+ // Override style that allows the flex-row text to wrap.
+ &.allow-wrap {
+ white-space: normal;
+ }
}
.row-main-content {
diff --git a/app/assets/stylesheets/framework/memory_graph.scss b/app/assets/stylesheets/framework/memory_graph.scss
index c84010c6f10..06e1ebe41be 100644
--- a/app/assets/stylesheets/framework/memory_graph.scss
+++ b/app/assets/stylesheets/framework/memory_graph.scss
@@ -1,18 +1,4 @@
.memory-graph-container {
- svg {
- background: $white-light;
- border: 1px solid $gray-200;
- }
-
- path {
- fill: none;
- stroke: $blue-500;
- stroke-width: 2px;
- }
-
- circle {
- stroke: $blue-700;
- fill: $blue-700;
- stroke-width: 4px;
- }
+ background: $white-light;
+ border: 1px solid $gray-200;
}
diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss
index 2289f0a7011..bd0134a82d3 100644
--- a/app/assets/stylesheets/framework/selects.scss
+++ b/app/assets/stylesheets/framework/selects.scss
@@ -118,7 +118,7 @@
background: none;
.select2-search-field input {
- padding: 5px $gl-padding / 2;
+ padding: 5px $gl-input-padding;
height: auto;
font-family: inherit;
font-size: inherit;
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index b9cfcf6ce5c..bf1fd7fd29f 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -61,10 +61,6 @@
padding-right: 0;
z-index: 300;
- .btn-sidebar-action {
- display: inline-flex;
- }
-
@include media-breakpoint-only(sm) {
&:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper {
padding-right: $gutter-collapsed-width;
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 3876d1c10d4..39e7e4bb7e5 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -645,6 +645,12 @@ h4 {
}
}
+.text-right-md {
+ @include media-breakpoint-up(md) {
+ text-align: right;
+ }
+}
+
.text-right-lg {
@include media-breakpoint-up(lg) {
text-align: right;
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 0f77c451fac..90600ecf615 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -62,6 +62,9 @@ $gray-dark: darken($gray-light, $darken-dark-factor);
$gray-darker: #eee;
$gray-darkest: #c4c4c4;
+$purple: #6d49cb;
+$purple-light: #ede8fb;
+
$black: #000;
$black-transparent: rgba(0, 0, 0, 0.3);
$almost-black: #242424;
@@ -529,16 +532,6 @@ $pagination-line-height: 20px;
$pagination-disabled-color: #cdcdcd;
/*
-* Toasts
-*/
-$toast-offset: 24px;
-$toast-height: 48px;
-$toast-max-width: 586px;
-$toast-padding-right: 42px;
-$toast-default-margin: 8px;
-$toast-background-opacity: 0.95;
-
-/*
* Status icons
*/
$status-icon-size: 22px;
diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss
index 434cbd6d21c..3eff1807403 100644
--- a/app/assets/stylesheets/framework/wells.scss
+++ b/app/assets/stylesheets/framework/wells.scss
@@ -3,7 +3,7 @@
color: $gl-text-color;
border: 1px solid $border-color;
border-radius: $border-radius-default;
- margin-bottom: $gl-padding;
+ margin-bottom: $gl-padding-8;
.card.card-body-segment {
padding: $gl-padding;
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index ba126d59eef..977fc8329b6 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -883,6 +883,15 @@ $ide-commit-header-height: 48px;
margin-right: $ide-tree-padding;
border-bottom: 1px solid $white-dark;
+ svg {
+ color: $gray-700;
+
+ &:focus,
+ &:hover {
+ color: $blue-600;
+ }
+ }
+
.ide-new-btn {
margin-left: auto;
}
@@ -899,6 +908,11 @@ $ide-commit-header-height: 48px;
.dropdown-menu-toggle {
svg {
vertical-align: middle;
+ color: $gray-700;
+
+ &:hover {
+ color: $gray-700;
+ }
}
&:hover {
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index d26979bc174..90c2e369ccd 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -187,6 +187,10 @@
font-size: 1em;
border-bottom: 1px solid $border-color;
padding: $gl-padding-8 $gl-padding;
+
+ .js-max-issue-size::before {
+ content: '/';
+ }
}
.board-title-text {
diff --git a/app/assets/stylesheets/pages/convdev_index.scss b/app/assets/stylesheets/pages/dev_ops_score.scss
index 52fcdf4a405..6b6dce43dba 100644
--- a/app/assets/stylesheets/pages/convdev_index.scss
+++ b/app/assets/stylesheets/pages/dev_ops_score.scss
@@ -1,24 +1,24 @@
$space-between-cards: 8px;
-.convdev-empty svg {
+.devops-empty svg {
margin: 64px auto 32px;
max-width: 420px;
}
-.convdev-header {
+.devops-header {
margin-top: $gl-padding;
margin-bottom: $gl-padding;
padding: 0 4px;
display: flex;
align-items: center;
- .convdev-header-title {
+ .devops-header-title {
font-size: 48px;
line-height: 1;
margin: 0;
}
- .convdev-header-subtitle {
+ .devops-header-subtitle {
font-size: 22px;
line-height: 1;
color: $gl-text-color-secondary;
@@ -36,13 +36,13 @@ $space-between-cards: 8px;
}
}
-.convdev-cards {
+.devops-cards {
display: flex;
justify-content: center;
flex-wrap: wrap;
}
-.convdev-card-wrapper {
+.devops-card-wrapper {
display: flex;
flex-direction: column;
align-items: stretch;
@@ -70,7 +70,7 @@ $space-between-cards: 8px;
}
}
-.convdev-card {
+.devops-card {
border: solid 1px $border-color;
border-radius: 3px;
border-top-width: 3px;
@@ -79,7 +79,7 @@ $space-between-cards: 8px;
flex-grow: 1;
}
-.convdev-card-low {
+.devops-card-low {
border-top-color: $red-400;
.board-card-score-big {
@@ -87,7 +87,7 @@ $space-between-cards: 8px;
}
}
-.convdev-card-average {
+.devops-card-average {
border-top-color: $orange-400;
.board-card-score-big {
@@ -95,7 +95,7 @@ $space-between-cards: 8px;
}
}
-.convdev-card-high {
+.devops-card-high {
border-top-color: $green-400;
.board-card-score-big {
@@ -103,7 +103,7 @@ $space-between-cards: 8px;
}
}
-.convdev-card-title {
+.devops-card-title {
margin: $gl-padding auto auto;
max-width: 100px;
@@ -170,7 +170,7 @@ $space-between-cards: 8px;
}
}
-.convdev-steps {
+.devops-steps {
margin-top: $gl-padding;
height: 1px;
min-width: 100%;
@@ -179,7 +179,7 @@ $space-between-cards: 8px;
background: $border-color;
}
-.convdev-step {
+.devops-step {
$step-positions: 5% 10% 30% 42% 48% 55% 60% 70% 75% 90%;
@each $pos in $step-positions {
$i: index($step-positions, $pos);
@@ -212,7 +212,7 @@ $space-between-cards: 8px;
height: auto;
width: auto;
- .convdev-step-title {
+ .devops-step-title {
max-height: 2em;
opacity: 1;
transition: opacity 0.2s;
@@ -233,7 +233,7 @@ $space-between-cards: 8px;
}
}
-.convdev-step-title {
+.devops-step-title {
max-height: 0;
opacity: 0;
text-transform: uppercase;
@@ -242,14 +242,14 @@ $space-between-cards: 8px;
font-size: 12px;
}
-.convdev-high-score {
+.devops-high-score {
color: $green-400;
}
-.convdev-average-score {
+.devops-average-score {
color: $orange-400;
}
-.convdev-low-score {
+.devops-low-score {
color: $red-400;
}
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index defa1a6c0d5..f394e4ab58a 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -10,6 +10,7 @@
.file-title-flex-parent {
border-top-left-radius: $border-radius-default;
border-top-right-radius: $border-radius-default;
+ box-shadow: 0 -2px 0 0 var(--white);
cursor: pointer;
@media (min-width: map-get($grid-breakpoints, md)) {
@@ -472,6 +473,7 @@ table.code {
text-align: right;
width: 50px;
position: relative;
+ white-space: nowrap;
a {
transition: none;
diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss
index 65d0ce8c52e..b716c6e14fe 100644
--- a/app/assets/stylesheets/pages/editor.scss
+++ b/app/assets/stylesheets/pages/editor.scss
@@ -172,7 +172,7 @@
}
.template-selector-dropdowns-wrap {
- display: inline-block;
+ display: flex;
vertical-align: top;
@media(max-width: map-get($grid-breakpoints, lg)-1) {
@@ -189,6 +189,7 @@
display: inline-block;
vertical-align: top;
font-family: $regular_font;
+ margin: 0 8px 0 0;
@media(max-width: map-get($grid-breakpoints, lg)-1) {
display: block;
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index 93dffb5ff09..3892d9dbd07 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -7,7 +7,6 @@
.environments-folder-name {
font-weight: $gl-font-weight-normal;
- padding-top: 20px;
}
.environments-container {
diff --git a/app/assets/stylesheets/pages/error_details.scss b/app/assets/stylesheets/pages/error_details.scss
index 0515db914e9..dcd25c126c4 100644
--- a/app/assets/stylesheets/pages/error_details.scss
+++ b/app/assets/stylesheets/pages/error_details.scss
@@ -12,6 +12,12 @@
}
}
+ .file-title-name {
+ &.limited-width {
+ max-width: 80%;
+ }
+ }
+
.line_content.old::before {
content: none !important;
}
diff --git a/app/assets/stylesheets/pages/error_tracking_list.scss b/app/assets/stylesheets/pages/error_tracking_list.scss
new file mode 100644
index 00000000000..cd1adb9a754
--- /dev/null
+++ b/app/assets/stylesheets/pages/error_tracking_list.scss
@@ -0,0 +1,5 @@
+.error-list {
+ .sort-dropdown {
+ min-width: auto;
+ }
+}
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index 1502cf18440..1cf72c51ca7 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -25,6 +25,7 @@
.description p {
margin-bottom: 0;
+ color: $gl-text-color-secondary;
}
}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 5617ab0af41..09b335f9ba2 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -883,7 +883,7 @@
.time-tracking-help-state {
background: $white-light;
- margin: 16px -20px 0;
+ margin: 16px -20px -20px;
padding: 16px 20px;
border-top: 1px solid $border-gray-light;
border-bottom: 1px solid $border-gray-light;
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index a37cbda8558..b03ad5c6b75 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -21,16 +21,11 @@
margin-bottom: 2px;
}
- .issue-labels {
+ .issue-labels,
+ .author-link {
display: inline-block;
}
- .issuable-meta {
- .author-link {
- display: inline-block;
- }
- }
-
.icon-merge-request-unmerged {
height: 13px;
margin-bottom: 3px;
@@ -53,16 +48,6 @@
margin-right: 15px;
}
-.issues_content {
- .title {
- height: 40px;
- }
-
- form {
- margin: 0;
- }
-}
-
form.edit-issue {
margin: 0;
}
@@ -79,10 +64,6 @@ ul.related-merge-requests > li {
margin-left: 5px;
}
- .row_title {
- vertical-align: bottom;
- }
-
gl-emoji {
font-size: 1em;
}
@@ -93,10 +74,6 @@ ul.related-merge-requests > li {
font-weight: $gl-font-weight-bold;
}
-.merge-request-id {
- display: inline-block;
-}
-
.merge-request-status {
&.merged {
color: $blue-500;
@@ -118,11 +95,7 @@ ul.related-merge-requests > li {
border-color: $issues-today-border;
}
- &.closed {
- background: $gray-light;
- border-color: $border-color;
- }
-
+ &.closed,
&.merged {
background: $gray-light;
border-color: $border-color;
@@ -160,9 +133,12 @@ ul.related-merge-requests > li {
padding-bottom: 37px;
}
-.issues-nav-controls {
+.issues-nav-controls,
+.new-branch-col {
font-size: 0;
+}
+.issues-nav-controls {
.btn-group:empty {
display: none;
}
@@ -198,8 +174,6 @@ ul.related-merge-requests > li {
}
.new-branch-col {
- font-size: 0;
-
.discussion-filter-container {
&:not(:only-child) {
margin-right: $gl-padding-8;
@@ -240,7 +214,6 @@ ul.related-merge-requests > li {
}
.create-merge-request-dropdown-menu {
- width: 300px;
opacity: 1;
visibility: visible;
transform: translateY(0);
@@ -297,11 +270,11 @@ ul.related-merge-requests > li {
padding-top: 0;
align-self: center;
}
+ }
- .create-mr-dropdown-wrap {
- .btn-group:not(.hidden) {
- display: inline-flex;
- }
+ .create-mr-dropdown-wrap {
+ .btn-group:not(.hidden) {
+ display: inline-flex;
}
}
}
diff --git a/app/assets/stylesheets/pages/issues/issue_count_badge.scss b/app/assets/stylesheets/pages/issues/issue_count_badge.scss
index 64ca61f7094..569f323abd8 100644
--- a/app/assets/stylesheets/pages/issues/issue_count_badge.scss
+++ b/app/assets/stylesheets/pages/issues/issue_count_badge.scss
@@ -2,7 +2,6 @@
.mr-count-badge {
display: inline-flex;
border-radius: $border-radius-base;
- border: 1px solid $border-color;
padding: 5px $gl-padding-8;
}
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 971f3b2c308..c023c9e5cbd 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -51,6 +51,10 @@
position: relative;
border: 1px solid $border-color;
border-radius: $border-radius-default;
+
+ .gl-skeleton-loader {
+ display: block;
+ }
}
.mr-widget-extension {
@@ -949,7 +953,6 @@
.deployment-info {
flex: 1;
white-space: nowrap;
- overflow: hidden;
text-overflow: ellipsis;
min-width: 100px;
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index 87cef43b923..08796742f08 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -17,12 +17,6 @@
display: inline-block;
}
-.account-btn-link,
-.profile-settings-sidebar a,
-.settings-sidebar a {
- color: $blue-600;
-}
-
.private-tokens-reset div.reset-action:not(:first-child) {
padding-top: 15px;
}
@@ -122,24 +116,12 @@
float: left;
}
}
-
- .description {
- white-space: nowrap;
- text-overflow: ellipsis;
- overflow: hidden;
- }
}
.key-created-at {
line-height: 42px;
}
-.profile-settings-content {
- a {
- color: $blue-600;
- }
-}
-
.provider-btn-group {
display: inline-block;
margin-right: 10px;
@@ -169,10 +151,6 @@
margin-left: -3px;
line-height: 22px;
background-color: $gray-light;
-
- &.not-active {
- color: $blue-500;
- }
}
.oauth-applications {
@@ -292,10 +270,6 @@ table.u2f-registrations {
}
.oauth-application-show {
- .scope-name {
- font-weight: $gl-font-weight-bold;
- }
-
.scopes-list {
padding-left: 18px;
}
@@ -317,52 +291,52 @@ table.u2f-registrations {
.landing {
padding: 32px;
+ }
- .close {
- position: absolute;
- top: 20px;
- right: 20px;
- opacity: 1;
+ .close {
+ position: absolute;
+ top: 20px;
+ right: 20px;
+ opacity: 1;
- .dismiss-icon {
- float: right;
- cursor: pointer;
- color: $blue-300;
- }
+ .dismiss-icon {
+ float: right;
+ cursor: pointer;
+ color: $blue-300;
+ }
- &:hover {
- background-color: transparent;
- border: 0;
+ &:hover {
+ background-color: transparent;
+ border: 0;
- .dismiss-icon {
- color: $blue-400;
- }
+ .dismiss-icon {
+ color: $blue-400;
}
}
+ }
- .svg-container {
- margin-right: 30px;
- display: inline-block;
+ .svg-container {
+ margin-right: 30px;
+ display: inline-block;
- svg {
- height: 110px;
- vertical-align: top;
- }
+ svg {
+ height: 110px;
+ vertical-align: top;
+ }
- &.convdev {
- margin: 0 0 0 30px;
+ &.convdev {
+ margin: 0 0 0 30px;
- svg {
- height: 127px;
- }
+ svg {
+ height: 127px;
}
}
+ }
- .user-callout-copy {
- display: inline-block;
- vertical-align: top;
- max-width: 570px;
- }
+ .user-callout-copy {
+ display: inline-block;
+ vertical-align: top;
+ max-width: 570px;
}
@include media-breakpoint-down(xs) {
@@ -372,43 +346,26 @@ table.u2f-registrations {
display: block;
}
- .landing {
- .svg-container,
- .user-callout-copy {
- margin: 0 auto;
- display: block;
+ .svg-container,
+ .user-callout-copy {
+ margin: 0 auto;
+ display: block;
- svg {
- height: 75px;
- }
+ svg {
+ height: 75px;
+ }
- &.convdev {
- margin: $gl-padding auto 0;
+ &.convdev {
+ margin: $gl-padding auto 0;
- svg {
- height: 120px;
- }
+ svg {
+ height: 120px;
}
}
}
}
}
-.nav-wip {
- border: 1px solid $blue-500;
- background: $blue-50;
- padding: $gl-padding;
- margin-bottom: $gl-padding;
-
- a {
- color: $blue-500;
- }
-
- p:last-child {
- margin-bottom: 0;
- }
-}
-
.email-badge {
display: inline;
margin-right: $gl-padding / 2;
@@ -433,10 +390,8 @@ table.u2f-registrations {
}
.edit-user {
- .clear-user-status {
- svg {
- fill: $gl-text-color-secondary;
- }
+ svg {
+ fill: $gl-text-color-secondary;
}
.form-group > label {
@@ -453,10 +408,6 @@ table.u2f-registrations {
.no-emoji-placeholder {
position: relative;
-
- svg {
- fill: $gl-text-color-secondary;
- }
}
}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 17a446fca53..8b2c67378d9 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -792,7 +792,7 @@
}
.btn {
- margin-top: $gl-padding;
+ margin-top: $gl-padding-8;
padding: $gl-btn-vert-padding $gl-btn-padding;
line-height: $gl-btn-line-height;
@@ -812,6 +812,10 @@
@extend .btn;
@extend .btn-default;
}
+
+ .nav > li:not(:last-child) {
+ margin-right: $gl-padding-8;
+ }
}
.repository-languages-bar {
diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss
index 154e505f7a4..e20e58e21cf 100644
--- a/app/assets/stylesheets/pages/prometheus.scss
+++ b/app/assets/stylesheets/pages/prometheus.scss
@@ -67,7 +67,6 @@
.prometheus-graph-group {
display: flex;
flex-wrap: wrap;
- margin-top: $gl-padding-8;
}
.prometheus-graph {
diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss
index 5664f46484e..79ad0bd7735 100644
--- a/app/assets/stylesheets/pages/tree.scss
+++ b/app/assets/stylesheets/pages/tree.scss
@@ -1,6 +1,6 @@
.tree-holder {
.nav-block {
- margin: 10px 0;
+ margin: 16px 0;
.btn .fa,
.btn svg {
@@ -17,6 +17,10 @@
.tree-controls {
text-align: right;
+ .btn {
+ margin-left: 8px;
+ }
+
.btn-group {
margin-left: 10px;
}
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 3b3a2778b23..1f4bba5fc33 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -29,5 +29,25 @@
.border-color-default { border-color: $border-color; }
.box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; }
+.mh-50vh { max-height: 50vh; }
+
.gl-w-64 { width: px-to-rem($grid-size * 8); }
.gl-h-64 { height: px-to-rem($grid-size * 8); }
+
+.gl-text-purple { color: $purple; }
+.gl-text-gray-800 { color: $gray-800; }
+.gl-bg-purple-light { background-color: $purple-light; }
+
+// Classes using mixins coming from @gitlab-ui
+// can be removed once https://gitlab.com/gitlab-org/gitlab/merge_requests/19021 has been merged
+.gl-bg-red-100 { @include gl-bg-red-100; }
+.gl-bg-orange-100 { @include gl-bg-orange-100; }
+.gl-bg-gray-100 { @include gl-bg-gray-100; }
+.gl-bg-green-100 { @include gl-bg-green-100;}
+
+.gl-text-blue-500 { @include gl-text-blue-500; }
+.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/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb
index 6e5dd1a1f55..06ba916fc55 100644
--- a/app/controllers/admin/broadcast_messages_controller.rb
+++ b/app/controllers/admin/broadcast_messages_controller.rb
@@ -60,6 +60,8 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController
font
message
starts_at
+ target_path
+ broadcast_type
))
end
end
diff --git a/app/controllers/admin/identities_controller.rb b/app/controllers/admin/identities_controller.rb
index 8f2e34a6294..327538f1e93 100644
--- a/app/controllers/admin/identities_controller.rb
+++ b/app/controllers/admin/identities_controller.rb
@@ -28,7 +28,8 @@ class Admin::IdentitiesController < Admin::ApplicationController
def update
if @identity.update(identity_params)
- RepairLdapBlockedUserService.new(@user).execute
+ ::Users::RepairLdapBlockedService.new(@user).execute
+
redirect_to admin_user_identities_path(@user), notice: _('User identity was successfully updated.')
else
render :edit
@@ -37,7 +38,8 @@ class Admin::IdentitiesController < Admin::ApplicationController
def destroy
if @identity.destroy
- RepairLdapBlockedUserService.new(@user).execute
+ ::Users::RepairLdapBlockedService.new(@user).execute
+
redirect_to admin_user_identities_path(@user), status: :found, notice: _('User identity was successfully removed.')
else
redirect_to admin_user_identities_path(@user), status: :found, alert: _('Failed to remove user identity.')
diff --git a/app/controllers/admin/jobs_controller.rb b/app/controllers/admin/jobs_controller.rb
index 0c1afdc3d3b..892f6dc657c 100644
--- a/app/controllers/admin/jobs_controller.rb
+++ b/app/controllers/admin/jobs_controller.rb
@@ -1,25 +1,15 @@
# frozen_string_literal: true
class Admin::JobsController < Admin::ApplicationController
- # rubocop: disable CodeReuse/ActiveRecord
def index
+ # We need all builds for tabs counters
+ @all_builds = JobsFinder.new(current_user: current_user).execute
+
@scope = params[:scope]
- @all_builds = Ci::Build
- @builds = @all_builds.order('id DESC')
- @builds =
- case @scope
- when 'pending'
- @builds.pending.reverse_order
- when 'running'
- @builds.running.reverse_order
- when 'finished'
- @builds.finished
- else
- @builds
- end
+ @builds = JobsFinder.new(current_user: current_user, params: params).execute
+ @builds = @builds.eager_load_everything
@builds = @builds.page(params[:page]).per(30)
end
- # rubocop: enable CodeReuse/ActiveRecord
def cancel_all
Ci::Build.running_or_pending.each(&:cancel)
diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb
index 1f946e41995..f9587655a8d 100644
--- a/app/controllers/admin/sessions_controller.rb
+++ b/app/controllers/admin/sessions_controller.rb
@@ -6,17 +6,23 @@ class Admin::SessionsController < ApplicationController
before_action :user_is_admin!
def new
- # Renders a form in which the admin can enter their password
+ if current_user_mode.admin_mode?
+ redirect_to redirect_path, notice: _('Admin mode already enabled')
+ else
+ current_user_mode.request_admin_mode! unless current_user_mode.admin_mode_requested?
+ store_location_for(:redirect, redirect_path)
+ end
end
def create
if current_user_mode.enable_admin_mode!(password: params[:password])
- redirect_location = stored_location_for(:redirect) || admin_root_path
- redirect_to safe_redirect_path(redirect_location)
+ redirect_to redirect_path, notice: _('Admin mode enabled')
else
- flash.now[:alert] = _('Invalid Login or password')
+ flash.now[:alert] = _('Invalid login or password')
render :new
end
+ rescue Gitlab::Auth::CurrentUserMode::NotRequestedError
+ redirect_to new_admin_session_path, alert: _('Re-authentication period expired or never requested. Please try again')
end
def destroy
@@ -30,4 +36,19 @@ class Admin::SessionsController < ApplicationController
def user_is_admin!
render_404 unless current_user&.admin?
end
+
+ def redirect_path
+ redirect_to_path = safe_redirect_path(stored_location_for(:redirect)) || safe_redirect_path_for_url(request.referer)
+
+ if redirect_to_path &&
+ excluded_redirect_paths.none? { |excluded| redirect_to_path.include?(excluded) }
+ redirect_to_path
+ else
+ admin_root_path
+ end
+ end
+
+ def excluded_redirect_paths
+ [new_admin_session_path, admin_session_path]
+ end
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 25c1d80b117..f5306801c04 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -16,15 +16,16 @@ class ApplicationController < ActionController::Base
include ConfirmEmailWarning
include Gitlab::Tracking::ControllerConcern
include Gitlab::Experimentation::ControllerConcern
+ include InitializesCurrentUserMode
before_action :authenticate_user!, except: [:route_not_found]
before_action :enforce_terms!, if: :should_enforce_terms?
before_action :validate_user_service_ticket!
- before_action :check_password_expiration
+ before_action :check_password_expiration, if: :html_request?
before_action :ldap_security_check
- before_action :sentry_context
+ around_action :sentry_context
before_action :default_headers
- before_action :add_gon_variables, unless: [:peek_request?, :json_request?]
+ before_action :add_gon_variables, if: :html_request?
before_action :configure_permitted_parameters, if: :devise_controller?
before_action :require_email, unless: :devise_controller?
before_action :active_user_check, unless: :devise_controller?
@@ -41,7 +42,6 @@ class ApplicationController < ActionController::Base
protect_from_forgery with: :exception, prepend: true
helper_method :can?
- helper_method :current_user_mode
helper_method :import_sources_enabled?, :github_import_enabled?,
:gitea_import_enabled?, :github_import_configured?,
:gitlab_import_enabled?, :gitlab_import_configured?,
@@ -74,6 +74,18 @@ class ApplicationController < ActionController::Base
render_403
end
+ rescue_from Gitlab::Auth::IpBlacklisted do
+ Gitlab::AuthLogger.error(
+ message: 'Rack_Attack',
+ env: :blocklist,
+ remote_ip: request.ip,
+ request_method: request.request_method,
+ path: request.fullpath
+ )
+
+ head :forbidden
+ end
+
rescue_from Gitlab::Auth::TooManyIps do |e|
head :forbidden, retry_after: Gitlab::Auth::UniqueIpsLimiter.config.unique_ips_limit_time_window
end
@@ -153,7 +165,7 @@ class ApplicationController < ActionController::Base
end
def log_exception(exception)
- Gitlab::Sentry.track_acceptable_exception(exception)
+ Gitlab::ErrorTracking.track_exception(exception)
backtrace_cleaner = request.env["action_dispatch.backtrace_cleaner"]
application_trace = ActionDispatch::ExceptionWrapper.new(backtrace_cleaner, exception).application_trace
@@ -216,10 +228,6 @@ class ApplicationController < ActionController::Base
end
end
- def respond_201
- head :created
- end
-
def respond_422
head :unprocessable_entity
end
@@ -455,8 +463,8 @@ class ApplicationController < ActionController::Base
response.headers['Page-Title'] = URI.escape(page_title('GitLab'))
end
- def peek_request?
- request.path.start_with?('/-/peek')
+ def html_request?
+ request.format.html?
end
def json_request?
@@ -466,7 +474,7 @@ class ApplicationController < ActionController::Base
def should_enforce_terms?
return false unless Gitlab::CurrentSettings.current_application_settings.enforce_terms
- !(peek_request? || devise_controller?)
+ html_request? && !devise_controller?
end
def set_usage_stats_consent_flag
@@ -524,8 +532,8 @@ class ApplicationController < ActionController::Base
@impersonator ||= User.find(session[:impersonator_id]) if session[:impersonator_id]
end
- def sentry_context
- Gitlab::Sentry.context(current_user)
+ def sentry_context(&block)
+ Gitlab::ErrorTracking.with_context(current_user, &block)
end
def allow_gitaly_ref_name_caching
@@ -534,10 +542,6 @@ class ApplicationController < ActionController::Base
end
end
- def current_user_mode
- @current_user_mode ||= Gitlab::Auth::CurrentUserMode.new(current_user)
- 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/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index 06531932b31..0df201ab506 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -40,10 +40,20 @@ class AutocompleteController < ApplicationController
end
def merge_request_target_branches
- merge_requests = MergeRequestsFinder.new(current_user, params).execute
- target_branches = merge_requests.recent_target_branches
+ if target_branch_params.present?
+ merge_requests = MergeRequestsFinder.new(current_user, target_branch_params).execute
+ target_branches = merge_requests.recent_target_branches
+
+ render json: target_branches.map { |target_branch| { title: target_branch } }
+ else
+ render json: { error: _('At least one of group_id or project_id must be specified') }, status: :bad_request
+ end
+ end
+
+ private
- render json: target_branches.map { |target_branch| { title: target_branch } }
+ def target_branch_params
+ params.permit(:group_id, :project_id).select { |_, v| v.present? }
end
end
diff --git a/app/controllers/boards/lists_controller.rb b/app/controllers/boards/lists_controller.rb
index 880f7500708..0b8469e8290 100644
--- a/app/controllers/boards/lists_controller.rb
+++ b/app/controllers/boards/lists_controller.rb
@@ -53,7 +53,7 @@ module Boards
service = Boards::Lists::GenerateService.new(board_parent, current_user)
if service.execute(board)
- lists = board.lists.movable.preload_associations
+ lists = board.lists.movable.preload_associated_models
List.preload_preferences_for_user(lists, current_user)
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index 9a539cf7c24..f4b74b14c0b 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -3,18 +3,15 @@
class Clusters::ClustersController < Clusters::BaseController
include RoutableActions
- before_action :cluster, only: [:cluster_status, :show, :update, :destroy]
+ before_action :cluster, only: [:cluster_status, :show, :update, :destroy, :clear_cache]
before_action :generate_gcp_authorize_url, only: [:new]
before_action :validate_gcp_token, only: [:new]
before_action :gcp_cluster, only: [:new]
before_action :user_cluster, only: [:new]
- before_action :authorize_create_cluster!, only: [:new, :authorize_aws_role, :revoke_aws_role, :aws_proxy]
+ before_action :authorize_create_cluster!, only: [:new, :authorize_aws_role]
before_action :authorize_update_cluster!, only: [:update]
- before_action :authorize_admin_cluster!, only: [:destroy]
+ before_action :authorize_admin_cluster!, only: [:destroy, :clear_cache]
before_action :update_applications_status, only: [:cluster_status]
- before_action only: [:new, :create_gcp] do
- push_frontend_feature_flag(:create_eks_clusters)
- end
before_action only: [:show] do
push_frontend_feature_flag(:enable_cluster_application_elastic_stack)
push_frontend_feature_flag(:enable_cluster_application_crossplane)
@@ -42,11 +39,10 @@ class Clusters::ClustersController < Clusters::BaseController
end
def new
- return unless Feature.enabled?(:create_eks_clusters)
-
if params[:provider] == 'aws'
@aws_role = current_user.aws_role || Aws::Role.new
@aws_role.ensure_role_external_id!
+ @instance_types = load_instance_types.to_json
elsif params[:provider] == 'gcp'
redirect_to @authorize_url if @authorize_url && !@valid_gcp_token
@@ -113,6 +109,7 @@ class Clusters::ClustersController < Clusters::BaseController
generate_gcp_authorize_url
validate_gcp_token
user_cluster
+ params[:provider] = 'gcp'
render :new, locals: { active_tab: 'create' }
end
@@ -149,34 +146,24 @@ class Clusters::ClustersController < Clusters::BaseController
end
def authorize_aws_role
- role = current_user.build_aws_role(create_role_params)
-
- role.save ? respond_201 : respond_422
- end
-
- def revoke_aws_role
- current_user.aws_role&.destroy
+ response = Clusters::Aws::AuthorizeRoleService.new(
+ current_user,
+ params: aws_role_params
+ ).execute
- head :no_content
+ render json: response.body, status: response.status
end
- def aws_proxy
- response = Clusters::Aws::ProxyService.new(
- current_user.aws_role,
- params: params
- ).execute
+ def clear_cache
+ cluster.delete_cached_resources!
- render json: response.body, status: response.status
+ redirect_to cluster.show_path, notice: _('Cluster cache cleared.')
end
private
def destroy_params
- # To be uncomented on https://gitlab.com/gitlab-org/gitlab/merge_requests/16954
- # This MR got split into other since it was too big.
- #
- # params.permit(:cleanup)
- {}
+ params.permit(:cleanup)
end
def update_params
@@ -270,13 +257,12 @@ class Clusters::ClustersController < Clusters::BaseController
)
end
- def create_role_params
+ def aws_role_params
params.require(:cluster).permit(:role_arn, :role_external_id)
end
def generate_gcp_authorize_url
- params = Feature.enabled?(:create_eks_clusters) ? { provider: :gke } : {}
- state = generate_session_key_redirect(clusterable.new_path(params).to_s)
+ state = generate_session_key_redirect(clusterable.new_path(provider: :gcp).to_s)
@authorize_url = GoogleApi::CloudPlatform::Client.new(
nil, callback_google_api_auth_url,
@@ -317,6 +303,19 @@ class Clusters::ClustersController < Clusters::BaseController
end
end
+ ##
+ # Unfortunately the EC2 API doesn't provide a list of
+ # possible instance types. There is a workaround, using
+ # the Pricing API, but instead of requiring the
+ # user to grant extra permissions for this we use the
+ # values that validate the CloudFormation template.
+ def load_instance_types
+ stack_template = File.read(Rails.root.join('vendor', 'aws', 'cloudformation', 'eks_cluster.yaml'))
+ instance_types = YAML.safe_load(stack_template).dig('Parameters', 'NodeInstanceType', 'AllowedValues')
+
+ instance_types.map { |type| Hash(name: type, value: type) }
+ end
+
def update_applications_status
@cluster.applications.each(&:schedule_status_update)
end
diff --git a/app/controllers/concerns/boards_actions.rb b/app/controllers/concerns/boards_actions.rb
index a093d0d6e7f..eb1080cb3d2 100644
--- a/app/controllers/concerns/boards_actions.rb
+++ b/app/controllers/concerns/boards_actions.rb
@@ -9,6 +9,7 @@ module BoardsActions
before_action :boards, only: :index
before_action :board, only: :show
+ before_action :push_wip_limits, only: [:index, :show]
end
def index
@@ -24,6 +25,10 @@ module BoardsActions
private
+ # Noop on FOSS
+ def push_wip_limits
+ end
+
def boards
strong_memoize(:boards) do
Boards::ListService.new(parent, current_user).execute
diff --git a/app/controllers/concerns/confirm_email_warning.rb b/app/controllers/concerns/confirm_email_warning.rb
index 86df0010665..32e1a46e580 100644
--- a/app/controllers/concerns/confirm_email_warning.rb
+++ b/app/controllers/concerns/confirm_email_warning.rb
@@ -4,15 +4,18 @@ module ConfirmEmailWarning
extend ActiveSupport::Concern
included do
- before_action :set_confirm_warning, if: -> { Feature.enabled?(:soft_email_confirmation) }
+ before_action :set_confirm_warning, if: :show_confirm_warning?
end
protected
+ def show_confirm_warning?
+ html_request? && request.get? && Feature.enabled?(:soft_email_confirmation)
+ end
+
def set_confirm_warning
return unless current_user
return if current_user.confirmed?
- return if peek_request? || json_request? || !request.get?
email = current_user.unconfirmed_email || current_user.email
diff --git a/app/controllers/concerns/cycle_analytics_params.rb b/app/controllers/concerns/cycle_analytics_params.rb
index 1645af695be..a78d803927c 100644
--- a/app/controllers/concerns/cycle_analytics_params.rb
+++ b/app/controllers/concerns/cycle_analytics_params.rb
@@ -38,7 +38,8 @@ module CycleAnalyticsParams
end
def to_utc_time(field)
- Date.parse(field).to_time.utc
+ date = field.is_a?(Date) ? field : Date.parse(field)
+ date.to_time.utc
end
end
diff --git a/app/controllers/concerns/enforces_admin_authentication.rb b/app/controllers/concerns/enforces_admin_authentication.rb
index e731211f423..527759de0bb 100644
--- a/app/controllers/concerns/enforces_admin_authentication.rb
+++ b/app/controllers/concerns/enforces_admin_authentication.rb
@@ -18,6 +18,7 @@ module EnforcesAdminAuthentication
return unless Feature.enabled?(:user_mode_in_session)
unless current_user_mode.admin_mode?
+ current_user_mode.request_admin_mode!
store_location_for(:redirect, request.fullpath) if storable_location?
redirect_to(new_admin_session_path, notice: _('Re-authentication required'))
end
diff --git a/app/controllers/concerns/initializes_current_user_mode.rb b/app/controllers/concerns/initializes_current_user_mode.rb
new file mode 100644
index 00000000000..df7cea5c754
--- /dev/null
+++ b/app/controllers/concerns/initializes_current_user_mode.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module InitializesCurrentUserMode
+ extend ActiveSupport::Concern
+
+ included do
+ helper_method :current_user_mode
+ end
+
+ def current_user_mode
+ @current_user_mode ||= Gitlab::Auth::CurrentUserMode.new(current_user)
+ end
+end
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index 6162d006cc7..c4abaacd573 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -98,13 +98,11 @@ module IssuableActions
error_message = "Destroy confirmation not provided for #{issuable.human_class_name}"
exception = RuntimeError.new(error_message)
- Gitlab::Sentry.track_acceptable_exception(
+ Gitlab::ErrorTracking.track_exception(
exception,
- extra: {
- project_path: issuable.project.full_path,
- issuable_type: issuable.class.name,
- issuable_id: issuable.id
- }
+ project_path: issuable.project.full_path,
+ issuable_type: issuable.class.name,
+ issuable_id: issuable.id
)
index_path = polymorphic_path([parent, issuable.class])
@@ -121,7 +119,7 @@ module IssuableActions
end
def bulk_update
- result = Issuable::BulkUpdateService.new(current_user, bulk_update_params).execute(resource_name)
+ result = Issuable::BulkUpdateService.new(parent, current_user, bulk_update_params).execute(resource_name)
quantity = result[:count]
render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" }
diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb
index 0b2756c0c6a..993f091b0e6 100644
--- a/app/controllers/concerns/membership_actions.rb
+++ b/app/controllers/concerns/membership_actions.rb
@@ -144,4 +144,15 @@ module MembershipActions
end
end
end
+
+ def requested_relations
+ case params[:with_inherited_permissions].presence
+ when 'exclude'
+ [:direct]
+ when 'only'
+ [:inherited]
+ else
+ [:inherited, :direct]
+ end
+ end
end
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index fbae4c53c31..3d599d9e7f9 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -63,7 +63,11 @@ module NotesActions
json.merge!(note_json(@note))
end
- render json: json
+ if @note.errors.present? && @note.errors.keys != [:commands_only]
+ render json: json, status: :unprocessable_entity
+ else
+ render json: json
+ end
end
format.html { redirect_back_or_default }
end
diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb
index fd9d5fad38e..3ccf227c431 100644
--- a/app/controllers/concerns/service_params.rb
+++ b/app/controllers/concerns/service_params.rb
@@ -18,6 +18,7 @@ module ServiceParams
:channels,
:color,
:colorize_messages,
+ :comment_on_event_enabled,
:confidential_issues_events,
:default_irc_uri,
:description,
diff --git a/app/controllers/concerns/sessionless_authentication.rb b/app/controllers/concerns/sessionless_authentication.rb
index f644923443b..d5c26fca957 100644
--- a/app/controllers/concerns/sessionless_authentication.rb
+++ b/app/controllers/concerns/sessionless_authentication.rb
@@ -33,6 +33,8 @@ module SessionlessAuthentication
end
def enable_admin_mode!
- current_user_mode.enable_admin_mode!(skip_password_validation: true) if Feature.enabled?(:user_mode_in_session)
+ return unless Feature.enabled?(:user_mode_in_session)
+
+ current_user_mode.enable_sessionless_admin_mode!
end
end
diff --git a/app/controllers/concerns/sourcegraph_gon.rb b/app/controllers/concerns/sourcegraph_gon.rb
index ab4abd734fb..01925cf9d4d 100644
--- a/app/controllers/concerns/sourcegraph_gon.rb
+++ b/app/controllers/concerns/sourcegraph_gon.rb
@@ -4,7 +4,7 @@ module SourcegraphGon
extend ActiveSupport::Concern
included do
- before_action :push_sourcegraph_gon, unless: :json_request?
+ before_action :push_sourcegraph_gon, if: :html_request?
end
private
diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb
index b87779c22d3..655575e0944 100644
--- a/app/controllers/concerns/uploads_actions.rb
+++ b/app/controllers/concerns/uploads_actions.rb
@@ -1,11 +1,16 @@
# frozen_string_literal: true
module UploadsActions
+ extend ActiveSupport::Concern
include Gitlab::Utils::StrongMemoize
include SendFileUpload
UPLOAD_MOUNTS = %w(avatar attachment file logo header_logo favicon).freeze
+ included do
+ prepend_before_action :set_request_format_from_path_extension
+ end
+
def create
uploader = UploadService.new(model, params[:file], uploader_class).execute
@@ -39,15 +44,14 @@ module UploadsActions
expires_in ttl, directives
- disposition = uploader.embeddable? ? 'inline' : 'attachment'
-
- uploaders = [uploader, *uploader.versions.values]
- uploader = uploaders.find { |version| version.filename == params[:filename] }
+ file_uploader = [uploader, *uploader.versions.values].find do |version|
+ version.filename == params[:filename]
+ end
- return render_404 unless uploader
+ return render_404 unless file_uploader
workhorse_set_content_type!
- send_upload(uploader, attachment: uploader.filename, disposition: disposition)
+ send_upload(file_uploader, attachment: file_uploader.filename, disposition: content_disposition)
end
def authorize
@@ -64,6 +68,28 @@ module UploadsActions
private
+ # Based on ActionDispatch::Http::MimeNegotiation. We have an
+ # initializer that monkey-patches this method out (so that repository
+ # paths don't guess a format based on extension), but we do want this
+ # behavior when serving uploads.
+ def set_request_format_from_path_extension
+ path = request.headers['action_dispatch.original_path'] || request.headers['PATH_INFO']
+
+ if match = path&.match(/\.(\w+)\z/)
+ format = Mime[match.captures.first]
+
+ request.format = format.symbol if format
+ end
+ end
+
+ def content_disposition
+ if uploader.embeddable? || uploader.pdf?
+ 'inline'
+ else
+ 'attachment'
+ end
+ end
+
def uploader_class
raise NotImplementedError
end
diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb
index 1b1416a72d7..dcdf9aced1a 100644
--- a/app/controllers/groups/group_members_controller.rb
+++ b/app/controllers/groups/group_members_controller.rb
@@ -24,8 +24,7 @@ class Groups::GroupMembersController < Groups::ApplicationController
@sort = params[:sort].presence || sort_value_name
@project = @group.projects.find(params[:project_id]) if params[:project_id]
-
- @members = GroupMembersFinder.new(@group).execute
+ @members = find_members
if can_manage_members
@invited_members = @members.invite
@@ -52,6 +51,12 @@ class Groups::GroupMembersController < Groups::ApplicationController
# MembershipActions concern
alias_method :membershipable, :group
+
+ private
+
+ def find_members
+ GroupMembersFinder.new(@group).execute(include_relations: requested_relations)
+ end
end
Groups::GroupMembersController.prepend_if_ee('EE::Groups::GroupMembersController')
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 755d97b091c..0953ca96317 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -181,6 +181,7 @@ class GroupsController < Groups::ApplicationController
:avatar,
:description,
:emails_disabled,
+ :mentions_disabled,
:lfs_enabled,
:name,
:path,
diff --git a/app/controllers/instance_statistics/conversational_development_index_controller.rb b/app/controllers/instance_statistics/conversational_development_index_controller.rb
deleted file mode 100644
index 306c16d559c..00000000000
--- a/app/controllers/instance_statistics/conversational_development_index_controller.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-class InstanceStatistics::ConversationalDevelopmentIndexController < InstanceStatistics::ApplicationController
- # rubocop: disable CodeReuse/ActiveRecord
- def index
- @metric = ConversationalDevelopmentIndex::Metric.order(:created_at).last&.present
- end
- # rubocop: enable CodeReuse/ActiveRecord
-end
diff --git a/app/controllers/instance_statistics/dev_ops_score_controller.rb b/app/controllers/instance_statistics/dev_ops_score_controller.rb
new file mode 100644
index 00000000000..238f7fa7707
--- /dev/null
+++ b/app/controllers/instance_statistics/dev_ops_score_controller.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class InstanceStatistics::DevOpsScoreController < InstanceStatistics::ApplicationController
+ # rubocop: disable CodeReuse/ActiveRecord
+ def index
+ @metric = DevOpsScore::Metric.order(:created_at).last&.present
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+end
diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb
index 8dd51ce1d64..bbf0bdd3662 100644
--- a/app/controllers/oauth/applications_controller.rb
+++ b/app/controllers/oauth/applications_controller.rb
@@ -6,6 +6,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
include PageLayoutHelper
include OauthApplications
include Gitlab::Experimentation::ControllerConcern
+ include InitializesCurrentUserMode
before_action :verify_user_oauth_applications_enabled, except: :index
before_action :authenticate_user!
diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb
index e65726dffbf..2a4e659c5b9 100644
--- a/app/controllers/oauth/authorizations_controller.rb
+++ b/app/controllers/oauth/authorizations_controller.rb
@@ -2,6 +2,8 @@
class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
include Gitlab::Experimentation::ControllerConcern
+ include InitializesCurrentUserMode
+
layout 'profile'
# Overridden from Doorkeeper::AuthorizationsController to
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index eca58748cc5..92f36c031f1 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -4,6 +4,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
include AuthenticatesWithTwoFactor
include Devise::Controllers::Rememberable
include AuthHelper
+ include InitializesCurrentUserMode
protect_from_forgery except: [:kerberos, :saml, :cas3, :failure], with: :exception, prepend: true
@@ -94,8 +95,12 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
return render_403 unless link_provider_allowed?(oauth['provider'])
log_audit_event(current_user, with: oauth['provider'])
- identity_linker ||= auth_module::IdentityLinker.new(current_user, oauth, session)
+ if Feature.enabled?(:user_mode_in_session)
+ return admin_mode_flow if current_user_mode.admin_mode_requested?
+ end
+
+ identity_linker ||= auth_module::IdentityLinker.new(current_user, oauth, session)
link_identity(identity_linker)
if identity_linker.changed?
@@ -239,6 +244,24 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
store_location_for(:user, uri.to_s)
end
end
+
+ def admin_mode_flow
+ if omniauth_identity_matches_current_user?
+ current_user_mode.enable_admin_mode!(skip_password_validation: true)
+
+ redirect_to stored_location_for(:redirect) || admin_root_path, notice: _('Admin mode enabled')
+ else
+ fail_admin_mode_invalid_credentials
+ end
+ end
+
+ def omniauth_identity_matches_current_user?
+ current_user.matches_identity?(oauth['provider'], oauth['uid'])
+ end
+
+ def fail_admin_mode_invalid_credentials
+ redirect_to new_admin_session_path, alert: _('Invalid login or password')
+ end
end
OmniauthCallbacksController.prepend_if_ee('EE::OmniauthCallbacksController')
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index 578a3d451a7..09754409104 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -46,7 +46,7 @@ class Projects::BranchesController < Projects::ApplicationController
def diverging_commit_counts
respond_to do |format|
format.json do
- service = Branches::DivergingCommitCountsService.new(repository)
+ service = ::Branches::DivergingCommitCountsService.new(repository)
branches = BranchesFinder.new(repository, params.permit(names: [])).execute
Gitlab::GitalyClient.allow_n_plus_1_calls do
@@ -63,7 +63,7 @@ class Projects::BranchesController < Projects::ApplicationController
redirect_to_autodeploy = project.empty_repo? && project.deployment_platform.present?
- result = CreateBranchService.new(project, current_user)
+ result = ::Branches::CreateService.new(project, current_user)
.execute(branch_name, ref)
success = (result[:status] == :success)
@@ -102,7 +102,7 @@ class Projects::BranchesController < Projects::ApplicationController
def destroy
@branch_name = Addressable::URI.unescape(params[:id])
- result = DeleteBranchService.new(project, current_user).execute(@branch_name)
+ result = ::Branches::DeleteService.new(project, current_user).execute(@branch_name)
respond_to do |format|
format.html do
@@ -118,7 +118,7 @@ class Projects::BranchesController < Projects::ApplicationController
end
def destroy_all_merged
- DeleteMergedBranchesService.new(@project, current_user).async_execute
+ ::Branches::DeleteMergedService.new(@project, current_user).async_execute
redirect_to project_branches_path(@project),
notice: _('Merged branches are being deleted. This can take some time depending on the number of branches. Please refresh the page to see changes.')
@@ -133,8 +133,6 @@ class Projects::BranchesController < Projects::ApplicationController
# frontend could omit this set. To prevent excessive I/O, we require
# that a list of names be specified.
def limit_diverging_commit_counts!
- return unless Feature.enabled?(:limit_diverging_commit_counts, default_enabled: true)
-
limit = Kaminari.config.default_per_page
# If we don't have many branches in the repository, then go ahead.
diff --git a/app/controllers/projects/ci/lints_controller.rb b/app/controllers/projects/ci/lints_controller.rb
index d7a0b7ece14..812420e9708 100644
--- a/app/controllers/projects/ci/lints_controller.rb
+++ b/app/controllers/projects/ci/lints_controller.rb
@@ -8,11 +8,13 @@ class Projects::Ci::LintsController < Projects::ApplicationController
def create
@content = params[:content]
- @error = Gitlab::Ci::YamlProcessor.validation_message(@content, yaml_processor_options)
- @status = @error.blank?
+ result = Gitlab::Ci::YamlProcessor.new_with_validation_errors(@content, yaml_processor_options)
- if @error.blank?
- @config_processor = Gitlab::Ci::YamlProcessor.new(@content, yaml_processor_options)
+ @error = result.errors.join(', ')
+ @status = result.valid?
+
+ if result.valid?
+ @config_processor = result.content
@stages = @config_processor.stages
@builds = @config_processor.builds
@jobs = @config_processor.jobs
diff --git a/app/controllers/projects/environments/prometheus_api_controller.rb b/app/controllers/projects/environments/prometheus_api_controller.rb
index e902d218c75..98fcc594d6e 100644
--- a/app/controllers/projects/environments/prometheus_api_controller.rb
+++ b/app/controllers/projects/environments/prometheus_api_controller.rb
@@ -7,23 +7,34 @@ class Projects::Environments::PrometheusApiController < Projects::ApplicationCon
before_action :environment
def proxy
- result = Prometheus::ProxyService.new(
+ variable_substitution_result =
+ variable_substitution_service.new(environment, permit_params).execute
+
+ if variable_substitution_result[:status] == :error
+ return error_response(variable_substitution_result)
+ end
+
+ prometheus_result = Prometheus::ProxyService.new(
environment,
proxy_method,
proxy_path,
- proxy_params
+ variable_substitution_result[:params]
).execute
- return continue_polling_response if result.nil?
- return error_response(result) if result[:status] == :error
+ return continue_polling_response if prometheus_result.nil?
+ return error_response(prometheus_result) if prometheus_result[:status] == :error
- success_response(result)
+ success_response(prometheus_result)
end
private
- def query_context
- Gitlab::Prometheus::QueryVariables.call(environment)
+ def variable_substitution_service
+ Prometheus::ProxyVariableSubstitutionService
+ end
+
+ def permit_params
+ params.permit!
end
def environment
@@ -37,15 +48,4 @@ class Projects::Environments::PrometheusApiController < Projects::ApplicationCon
def proxy_path
params[:proxy_path]
end
-
- def proxy_params
- substitute_query_variables(params).permit!
- end
-
- def substitute_query_variables(params)
- query = params[:query]
- return params unless query
-
- params.merge(query: query % query_context)
- end
end
diff --git a/app/controllers/projects/environments/sample_metrics_controller.rb b/app/controllers/projects/environments/sample_metrics_controller.rb
new file mode 100644
index 00000000000..79a7eab150b
--- /dev/null
+++ b/app/controllers/projects/environments/sample_metrics_controller.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class Projects::Environments::SampleMetricsController < Projects::ApplicationController
+ def query
+ result = Metrics::SampleMetricsService.new(params[:identifier]).query
+
+ if result
+ render json: { "status": "success", "data": { "resultType": "matrix", "result": result } }
+ else
+ render_404
+ end
+ end
+end
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index 4562296cea0..1179782036d 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -7,14 +7,15 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :authorize_read_environment!
before_action :authorize_create_environment!, only: [:new, :create]
before_action :authorize_stop_environment!, only: [:stop]
- before_action :authorize_update_environment!, only: [:edit, :update]
+ before_action :authorize_update_environment!, only: [:edit, :update, :cancel_auto_stop]
before_action :authorize_admin_environment!, only: [:terminal, :terminal_websocket_authorize]
- before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics]
+ before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics, :cancel_auto_stop]
before_action :verify_api_request!, only: :terminal_websocket_authorize
- before_action :expire_etag_cache, only: [:index]
+ before_action :expire_etag_cache, only: [:index], unless: -> { request.format.json? }
before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do
push_frontend_feature_flag(:prometheus_computed_alerts)
end
+ after_action :expire_etag_cache, only: [:cancel_auto_stop]
def index
@environments = project.environments
@@ -104,6 +105,27 @@ class Projects::EnvironmentsController < Projects::ApplicationController
end
end
+ def cancel_auto_stop
+ result = Environments::ResetAutoStopService.new(project, current_user)
+ .execute(environment)
+
+ if result[:status] == :success
+ respond_to do |format|
+ message = _('Auto stop successfully canceled.')
+
+ format.html { redirect_back_or_default(default: { action: 'show' }, options: { notice: message }) }
+ format.json { render json: { message: message }, status: :ok }
+ end
+ else
+ respond_to do |format|
+ message = result[:message]
+
+ format.html { redirect_back_or_default(default: { action: 'show' }, options: { alert: message }) }
+ format.json { render json: { message: message }, status: :unprocessable_entity }
+ end
+ end
+ end
+
def terminal
# Currently, this acts as a hint to load the terminal details into the cache
# if they aren't there already. In the future, users will need these details
@@ -175,8 +197,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController
end
def expire_etag_cache
- return if request.format.json?
-
# this forces to reload json content
Gitlab::EtagCaching::Store.new.tap do |store|
store.touch(project_environments_path(project, format: :json))
@@ -222,6 +242,10 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def authorize_stop_environment!
access_denied! unless can?(current_user, :stop_environment, environment)
end
+
+ def authorize_update_environment!
+ access_denied! unless can?(current_user, :update_environment, environment)
+ end
end
Projects::EnvironmentsController.prepend_if_ee('EE::Projects::EnvironmentsController')
diff --git a/app/controllers/projects/error_tracking_controller.rb b/app/controllers/projects/error_tracking_controller.rb
index 7143424473e..ba21ccfb169 100644
--- a/app/controllers/projects/error_tracking_controller.rb
+++ b/app/controllers/projects/error_tracking_controller.rb
@@ -44,13 +44,18 @@ class Projects::ErrorTrackingController < Projects::ApplicationController
private
def render_index_json
- service = ErrorTracking::ListIssuesService.new(project, current_user)
+ service = ErrorTracking::ListIssuesService.new(
+ project,
+ current_user,
+ list_issues_params
+ )
result = service.execute
return if handle_errors(result)
render json: {
errors: serialize_errors(result[:issues]),
+ pagination: result[:pagination],
external_url: service.external_url
}
end
@@ -72,8 +77,10 @@ class Projects::ErrorTrackingController < Projects::ApplicationController
return if handle_errors(result)
+ result_with_syntax_highlight = Gitlab::ErrorTracking::StackTraceHighlightDecorator.decorate(result[:latest_event])
+
render json: {
- error: serialize_error_event(result[:latest_event])
+ error: serialize_error_event(result_with_syntax_highlight)
}
end
@@ -106,6 +113,10 @@ class Projects::ErrorTrackingController < Projects::ApplicationController
end
end
+ def list_issues_params
+ params.permit(:search_term, :sort, :cursor)
+ end
+
def list_projects_params
params.require(:error_tracking_setting).permit([:api_host, :token])
end
diff --git a/app/controllers/projects/hook_logs_controller.rb b/app/controllers/projects/hook_logs_controller.rb
index a7afc3d77a5..ed7e7b68acb 100644
--- a/app/controllers/projects/hook_logs_controller.rb
+++ b/app/controllers/projects/hook_logs_controller.rb
@@ -16,15 +16,17 @@ class Projects::HookLogsController < Projects::ApplicationController
end
def retry
- result = hook.execute(hook_log.request_data, hook_log.trigger)
-
- set_hook_execution_notice(result)
-
+ execute_hook
redirect_to edit_project_hook_path(@project, @hook)
end
private
+ def execute_hook
+ result = hook.execute(hook_log.request_data, hook_log.trigger)
+ set_hook_execution_notice(result)
+ end
+
def hook
@hook ||= @project.hooks.find(params[:hook_id])
end
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 009765702ab..229374c3929 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -44,7 +44,7 @@ class Projects::IssuesController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, project.group)
- push_frontend_feature_flag(:release_search_filter, project)
+ push_frontend_feature_flag(:release_search_filter, project, default_enabled: true)
end
respond_to :html
@@ -237,7 +237,10 @@ class Projects::IssuesController < Projects::ApplicationController
end
def issue_params
- params.require(:issue).permit(*issue_params_attributes)
+ params.require(:issue).permit(
+ *issue_params_attributes,
+ sentry_issue_attributes: [:sentry_issue_identifier]
+ )
end
def issue_params_attributes
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index 1d914ab6011..796f3ff603f 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -12,39 +12,20 @@ class Projects::JobsController < Projects::ApplicationController
before_action :authorize_use_build_terminal!, only: [:terminal, :terminal_websocket_authorize]
before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action only: [:show] do
- push_frontend_feature_flag(:job_log_json, project)
+ push_frontend_feature_flag(:job_log_json, project, default_enabled: true)
end
layout 'project'
- # rubocop: disable CodeReuse/ActiveRecord
def index
+ # We need all builds for tabs counters
+ @all_builds = JobsFinder.new(current_user: current_user, project: @project).execute
+
@scope = params[:scope]
- @all_builds = project.builds.relevant
- @builds = @all_builds.order('ci_builds.id DESC')
- @builds =
- case @scope
- when 'pending'
- @builds.pending.reverse_order
- when 'running'
- @builds.running.reverse_order
- when 'finished'
- @builds.finished
- else
- @builds
- end
- @builds = @builds.includes([
- { pipeline: [:project, :user] },
- :job_artifacts_archive,
- :metadata,
- :trigger_request,
- :project,
- :user,
- :tags
- ])
+ @builds = JobsFinder.new(current_user: current_user, project: @project, params: params).execute
+ @builds = @builds.eager_load_everything
@builds = @builds.page(params[:page]).per(30).without_count
end
- # rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def show
@@ -72,7 +53,7 @@ class Projects::JobsController < Projects::ApplicationController
format.json do
# TODO: when the feature flag is removed we should not pass
# content_format to serialize method.
- content_format = Feature.enabled?(:job_log_json, @project) ? :json : :html
+ content_format = Feature.enabled?(:job_log_json, @project, default_enabled: true) ? :json : :html
build_trace = Ci::BuildTrace.new(
build: @build,
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index 42f9c0522a3..37d90ecdc00 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -5,8 +5,8 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
include RendersNotes
before_action :apply_diff_view_cookie!
- before_action :commit, except: :diffs_batch
- before_action :define_diff_vars, except: :diffs_batch
+ before_action :commit
+ before_action :define_diff_vars
before_action :define_diff_comment_vars, except: [:diffs_batch, :diffs_metadata]
def show
@@ -20,14 +20,11 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
def diffs_batch
return render_404 unless Feature.enabled?(:diffs_batch_load, @merge_request.project)
- diffable = @merge_request.merge_request_diff
-
- return render_404 unless diffable
-
- diffs = diffable.diffs_in_batch(params[:page], params[:per_page], diff_options: diff_options)
+ 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)
diffs.unfold_diff_files(positions.unfoldable)
+ diffs.write_cache
options = {
merge_request: @merge_request,
@@ -39,8 +36,10 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
end
def diffs_metadata
+ diffs = @compare.diffs(diff_options)
+
render json: DiffsMetadataSerializer.new(project: @merge_request.project)
- .represent(@diffs, additional_attributes)
+ .represent(diffs, additional_attributes)
end
private
@@ -49,11 +48,13 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
[{ source_project: :namespace }, { target_project: :namespace }]
end
+ # Deprecated: https://gitlab.com/gitlab-org/gitlab/issues/37735
def render_diffs
+ diffs = @compare.diffs(diff_options)
@environment = @merge_request.environments_for(current_user).last
- @diffs.unfold_diff_files(note_positions.unfoldable)
- @diffs.write_cache
+ diffs.unfold_diff_files(note_positions.unfoldable)
+ diffs.write_cache
request = {
current_user: current_user,
@@ -63,15 +64,14 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
options = additional_attributes.merge(diff_view: diff_view)
- render json: DiffsSerializer.new(request).represent(@diffs, options)
+ render json: DiffsSerializer.new(request).represent(diffs, options)
end
+ # Deprecated: https://gitlab.com/gitlab-org/gitlab/issues/37735
def define_diff_vars
@merge_request_diffs = @merge_request.merge_request_diffs.viewable.order_id_desc
@compare = commit || find_merge_request_diff_compare
return render_404 unless @compare
-
- @diffs = @compare.diffs(diff_options)
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -84,6 +84,8 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
+ #
+ # Deprecated: https://gitlab.com/gitlab-org/gitlab/issues/37735
def find_merge_request_diff_compare
@merge_request_diff =
if diff_id = params[:diff_id].presence
@@ -126,6 +128,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
}
end
+ # Deprecated: https://gitlab.com/gitlab-org/gitlab/issues/37735
def define_diff_comment_vars
@new_diff_note_attrs = {
noteable_type: 'MergeRequest',
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 766ec1e33f3..69e3e7c7acb 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -20,11 +20,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
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(:single_mr_diff_view, @project)
end
before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, @project.group)
- push_frontend_feature_flag(:release_search_filter, @project)
+ push_frontend_feature_flag(:release_search_filter, @project, default_enabled: true)
+ push_frontend_feature_flag(:async_mr_widget, @project)
end
around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions]
@@ -218,11 +220,16 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
def ci_environments_status
- environments = if ci_environments_status_on_merge_result?
- EnvironmentStatus.after_merge_request(@merge_request, current_user)
- else
- EnvironmentStatus.for_merge_request(@merge_request, current_user)
- end
+ environments =
+ if ci_environments_status_on_merge_result?
+ if Feature.enabled?(:deployment_merge_requests_widget, @project)
+ EnvironmentStatus.for_deployed_merge_request(@merge_request, current_user)
+ else
+ EnvironmentStatus.after_merge_request(@merge_request, current_user)
+ end
+ else
+ EnvironmentStatus.for_merge_request(@merge_request, current_user)
+ end
render json: EnvironmentStatusSerializer.new(current_user: current_user).represent(environments)
end
diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb
index 722fc30b3ff..f1e591ea1ec 100644
--- a/app/controllers/projects/pages_controller.rb
+++ b/app/controllers/projects/pages_controller.rb
@@ -15,8 +15,7 @@ class Projects::PagesController < Projects::ApplicationController
# rubocop: enable CodeReuse/ActiveRecord
def destroy
- project.remove_pages
- project.pages_domains.destroy_all # rubocop: disable DestroyAll
+ ::Pages::DeleteService.new(@project, current_user).execute
respond_to do |format|
format.html do
diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb
index b693642981e..5a81a064048 100644
--- a/app/controllers/projects/pages_domains_controller.rb
+++ b/app/controllers/projects/pages_domains_controller.rb
@@ -8,7 +8,6 @@ class Projects::PagesDomainsController < Projects::ApplicationController
before_action :domain, except: [:new, :create]
def show
- redirect_to edit_project_pages_domain_path(@project, @domain)
end
def new
@@ -24,17 +23,18 @@ class Projects::PagesDomainsController < Projects::ApplicationController
flash[:alert] = 'Failed to verify domain ownership'
end
- redirect_to edit_project_pages_domain_path(@project, @domain)
+ redirect_to project_pages_domain_path(@project, @domain)
end
def edit
+ redirect_to project_pages_domain_path(@project, @domain)
end
def create
@domain = @project.pages_domains.create(create_params)
if @domain.valid?
- redirect_to edit_project_pages_domain_path(@project, @domain)
+ redirect_to project_pages_domain_path(@project, @domain)
else
render 'new'
end
@@ -46,7 +46,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController
status: :found,
notice: 'Domain was updated'
else
- render 'edit'
+ render 'show'
end
end
@@ -68,7 +68,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController
flash[:alert] = @domain.errors.full_messages.join(', ')
end
- redirect_to edit_project_pages_domain_path(@project, @domain)
+ redirect_to project_pages_domain_path(@project, @domain)
end
private
diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb
index 72e939a3310..6a7e2b69652 100644
--- a/app/controllers/projects/pipeline_schedules_controller.rb
+++ b/app/controllers/projects/pipeline_schedules_controller.rb
@@ -83,12 +83,14 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
def play_rate_limit
return unless current_user
- limiter = ::Gitlab::ActionRateLimiter.new(action: :play_pipeline_schedule)
-
- return unless limiter.throttled?([current_user, schedule], 1)
+ if rate_limiter.throttled?(:play_pipeline_schedule, scope: [current_user, schedule])
+ flash[:alert] = _('You cannot play this scheduled pipeline at the moment. Please wait a minute.')
+ redirect_to pipeline_schedules_path(@project)
+ end
+ end
- flash[:alert] = _('You cannot play this scheduled pipeline at the moment. Please wait a minute.')
- redirect_to pipeline_schedules_path(@project)
+ def rate_limiter
+ ::Gitlab::ApplicationRateLimiter
end
def schedule
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 4d35353d5f5..e3ef8f3f2ff 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -11,7 +11,6 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :authorize_create_pipeline!, only: [:new, :create]
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action do
- push_frontend_feature_flag(:hide_dismissed_vulnerabilities)
push_frontend_feature_flag(:junit_pipeline_view)
end
diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb
index b01d48ca3d3..7bd084458d1 100644
--- a/app/controllers/projects/project_members_controller.rb
+++ b/app/controllers/projects/project_members_controller.rb
@@ -17,7 +17,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController
@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
+ @project_members = MembersFinder.new(@project, current_user).execute(include_relations: requested_relations)
if params[:search].present?
@project_members = @project_members.joins(:user).merge(User.search(params[:search]))
diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb
index c94fdd9483d..f39d98be516 100644
--- a/app/controllers/projects/raw_controller.rb
+++ b/app/controllers/projects/raw_controller.rb
@@ -4,11 +4,15 @@
class Projects::RawController < Projects::ApplicationController
include ExtractsPath
include SendsBlob
+ include StaticObjectExternalStorage
+
+ prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:blob) }
before_action :require_non_empty_project
before_action :assign_ref_vars
before_action :authorize_download_code!
- before_action :show_rate_limit, only: [:show]
+ before_action :show_rate_limit, only: [:show], unless: :external_storage_request?
+ before_action :redirect_to_external_storage, only: :show, if: :static_objects_external_storage_enabled?
def show
@blob = @repository.blob_at(@commit.id, @path)
@@ -19,14 +23,16 @@ class Projects::RawController < Projects::ApplicationController
private
def show_rate_limit
- limiter = ::Gitlab::ActionRateLimiter.new(action: :show_raw_controller)
-
- return unless limiter.throttled?([@project, @commit, @path], raw_blob_request_limit)
+ if rate_limiter.throttled?(:show_raw_controller, scope: [@project, @commit, @path], threshold: raw_blob_request_limit)
+ rate_limiter.log_request(request, :raw_blob_request_limit, current_user)
- limiter.log_request(request, :raw_blob_request_limit, current_user)
+ flash[:alert] = _('You cannot access the raw file. Please wait a minute.')
+ redirect_to project_blob_path(@project, File.join(@ref, @path)), status: :too_many_requests
+ end
+ end
- flash[:alert] = _('You cannot access the raw file. Please wait a minute.')
- redirect_to project_blob_path(@project, File.join(@ref, @path)), status: :too_many_requests
+ def rate_limiter
+ ::Gitlab::ApplicationRateLimiter
end
def raw_blob_request_limit
diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb
index 72c82aec31d..ffe69fe97e4 100644
--- a/app/controllers/projects/releases_controller.rb
+++ b/app/controllers/projects/releases_controller.rb
@@ -6,10 +6,11 @@ class Projects::ReleasesController < Projects::ApplicationController
before_action :release, only: %i[edit update]
before_action :authorize_read_release!
before_action do
- push_frontend_feature_flag(:release_edit_page, project, default_enabled: true)
push_frontend_feature_flag(:release_issue_summary, project)
+ push_frontend_feature_flag(:release_evidence_collection, project)
end
before_action :authorize_update_release!, only: %i[edit update]
+ before_action :authorize_download_code!, only: [:evidence]
def index
respond_to do |format|
@@ -20,6 +21,14 @@ class Projects::ReleasesController < Projects::ApplicationController
end
end
+ def evidence
+ respond_to do |format|
+ format.json do
+ render json: release.evidence_summary
+ end
+ end
+ end
+
protected
def releases
@@ -35,7 +44,6 @@ class Projects::ReleasesController < Projects::ApplicationController
private
def authorize_update_release!
- access_denied! unless Feature.enabled?(:release_edit_page, project, default_enabled: true)
access_denied! unless can?(current_user, :update_release, release)
end
diff --git a/app/controllers/projects/service_hook_logs_controller.rb b/app/controllers/projects/service_hook_logs_controller.rb
new file mode 100644
index 00000000000..5c814ea139f
--- /dev/null
+++ b/app/controllers/projects/service_hook_logs_controller.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class Projects::ServiceHookLogsController < Projects::HookLogsController
+ before_action :service, only: [:show, :retry]
+
+ def retry
+ execute_hook
+ redirect_to edit_project_service_path(@project, @service)
+ end
+
+ private
+
+ def hook
+ @hook ||= service.service_hook
+ end
+
+ def service
+ @service ||= @project.find_or_initialize_service(params[:service_id])
+ end
+end
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index c9f680a4696..daaca9e1268 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -7,6 +7,7 @@ class Projects::ServicesController < Projects::ApplicationController
before_action :authorize_admin_project!
before_action :ensure_service_enabled
before_action :service
+ before_action :web_hook_logs, only: [:edit, :update]
respond_to :html
@@ -77,6 +78,12 @@ class Projects::ServicesController < Projects::ApplicationController
@service ||= @project.find_or_initialize_service(params[:id])
end
+ def web_hook_logs
+ return unless @service.service_hook.present?
+
+ @web_hook_logs ||= @service.service_hook.web_hook_logs.recent.page(params[:page])
+ end
+
def ensure_service_enabled
render_404 unless service
end
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index cfed8727450..6af815b8daa 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -13,7 +13,7 @@ module Projects
Projects::UpdateService.new(project, current_user, update_params).tap do |service|
result = service.execute
if result[:status] == :success
- flash[:notice] = _("Pipelines settings for '%{project_name}' were successfully updated.") % { project_name: @project.name }
+ flash[:toast] = _("Pipelines settings for '%{project_name}' were successfully updated.") % { project_name: @project.name }
run_autodevops_pipeline(service)
@@ -39,7 +39,7 @@ module Projects
def reset_registration_token
@project.reset_runners_token!
- flash[:notice] = _('New runners registration token has been generated!')
+ flash[:toast] = _("New runners registration token has been generated!")
redirect_to namespace_project_settings_ci_cd_path
end
@@ -65,12 +65,14 @@ module Projects
return unless service.run_auto_devops_pipeline?
if @project.empty_repo?
- flash[:warning] = _("This repository is currently empty. A new Auto DevOps pipeline will be created after a new file has been pushed to a branch.")
+ flash[:notice] = _("This repository is currently empty. A new Auto DevOps pipeline will be created after a new file has been pushed to a branch.")
return
end
CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false)
- flash[:success] = "A new Auto DevOps pipeline has been created, go to <a href=\"#{project_pipelines_path(@project)}\">Pipelines page</a> for details".html_safe
+
+ 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 }
end
def define_variables
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index e5dea031bb5..47d6fb67108 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -32,6 +32,9 @@ class ProjectsController < Projects::ApplicationController
before_action :authorize_archive_project!, only: [:archive, :unarchive]
before_action :event_filter, only: [:show, :activity]
+ # Project Export Rate Limit
+ before_action :export_rate_limit, only: [:export, :download_export, :generate_new_export]
+
layout :determine_layout
def index
@@ -465,6 +468,21 @@ class ProjectsController < Projects::ApplicationController
def present_project
@project = @project.present(current_user: current_user)
end
+
+ def export_rate_limit
+ prefixed_action = "project_#{params[:action]}".to_sym
+
+ if rate_limiter.throttled?(prefixed_action, scope: [current_user, prefixed_action, @project])
+ rate_limiter.log_request(request, "#{prefixed_action}_request_limit".to_sym, current_user)
+
+ flash[:alert] = _('This endpoint has been requested too many times. Try again later.')
+ redirect_to edit_project_path(@project)
+ end
+ end
+
+ def rate_limiter
+ ::Gitlab::ApplicationRateLimiter
+ end
end
ProjectsController.prepend_if_ee('EE::ProjectsController')
diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb
index 5805d068e21..54774df5e76 100644
--- a/app/controllers/snippets_controller.rb
+++ b/app/controllers/snippets_controller.rb
@@ -15,13 +15,9 @@ class SnippetsController < ApplicationController
before_action :snippet, only: [:show, :edit, :destroy, :update, :raw]
- # Allow read snippet
+ before_action :authorize_create_snippet!, only: [:new, :create]
before_action :authorize_read_snippet!, only: [:show, :raw]
-
- # Allow modify snippet
before_action :authorize_update_snippet!, only: [:edit, :update]
-
- # Allow destroy snippet
before_action :authorize_admin_snippet!, only: [:destroy]
skip_before_action :authenticate_user!, only: [:index, :show, :raw]
@@ -140,6 +136,10 @@ class SnippetsController < ApplicationController
return render_404 unless can?(current_user, :admin_personal_snippet, @snippet)
end
+ def authorize_create_snippet!
+ return render_404 unless can?(current_user, :create_personal_snippet)
+ end
+
def snippet_params
params.require(:personal_snippet).permit(:title, :content, :file_name, :private, :visibility_level, :description)
end
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index 635db386792..67d33648470 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -20,7 +20,6 @@ class UploadsController < ApplicationController
skip_before_action :authenticate_user!
before_action :upload_mount_satisfied?
- before_action :find_model
before_action :authorize_access!, only: [:show]
before_action :authorize_create_access!, only: [:create, :authorize]
before_action :verify_workhorse_api!, only: [:authorize]
diff --git a/app/finders/clusters/knative_serving_namespace_finder.rb b/app/finders/clusters/knative_serving_namespace_finder.rb
new file mode 100644
index 00000000000..d3db5be558c
--- /dev/null
+++ b/app/finders/clusters/knative_serving_namespace_finder.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Clusters
+ class KnativeServingNamespaceFinder
+ attr_reader :cluster
+
+ def initialize(cluster)
+ @cluster = cluster
+ end
+
+ def execute
+ cluster.kubeclient&.get_namespace(Clusters::Kubernetes::KNATIVE_SERVING_NAMESPACE)
+ rescue Kubeclient::ResourceNotFoundError
+ nil
+ end
+ end
+end
diff --git a/app/finders/clusters/knative_version_role_binding_finder.rb b/app/finders/clusters/knative_version_role_binding_finder.rb
new file mode 100644
index 00000000000..26f5492840a
--- /dev/null
+++ b/app/finders/clusters/knative_version_role_binding_finder.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Clusters
+ class KnativeVersionRoleBindingFinder
+ attr_reader :cluster
+
+ def initialize(cluster)
+ @cluster = cluster
+ end
+
+ def execute
+ cluster.kubeclient&.get_cluster_role_binding(Clusters::Kubernetes::GITLAB_KNATIVE_VERSION_ROLE_BINDING_NAME)
+ rescue Kubeclient::ResourceNotFoundError
+ nil
+ end
+ end
+end
diff --git a/app/finders/deployments_finder.rb b/app/finders/deployments_finder.rb
new file mode 100644
index 00000000000..b718b55dd68
--- /dev/null
+++ b/app/finders/deployments_finder.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+class DeploymentsFinder
+ attr_reader :project, :params
+
+ ALLOWED_SORT_VALUES = %w[id iid created_at updated_at ref].freeze
+ DEFAULT_SORT_VALUE = 'id'.freeze
+
+ ALLOWED_SORT_DIRECTIONS = %w[asc desc].freeze
+ DEFAULT_SORT_DIRECTION = 'asc'.freeze
+
+ def initialize(project, params = {})
+ @project = project
+ @params = params
+ end
+
+ def execute
+ items = init_collection
+ items = by_updated_at(items)
+ sort(items)
+ end
+
+ private
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def init_collection
+ project
+ .deployments
+ .includes(
+ :user,
+ environment: [],
+ deployable: {
+ job_artifacts: [],
+ pipeline: {
+ project: {
+ route: [],
+ namespace: :route
+ }
+ },
+ project: {
+ namespace: :route
+ }
+ }
+ )
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def sort(items)
+ items.order(sort_params)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def by_updated_at(items)
+ items = items.updated_before(params[:updated_before]) if params[:updated_before].present?
+ items = items.updated_after(params[:updated_after]) if params[:updated_after].present?
+
+ items
+ end
+
+ def sort_params
+ order_by = ALLOWED_SORT_VALUES.include?(params[:order_by]) ? params[:order_by] : DEFAULT_SORT_VALUE
+ order_direction = ALLOWED_SORT_DIRECTIONS.include?(params[:sort]) ? params[:sort] : DEFAULT_SORT_DIRECTION
+
+ { order_by => order_direction }.tap do |sort_values|
+ sort_values['id'] = 'desc' if sort_values['updated_at']
+ sort_values['id'] = sort_values.delete('created_at') if sort_values['created_at'] # Sorting by `id` produces the same result as sorting by `created_at`
+ end
+ end
+end
diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb
index 165d9adae31..d8739c350e4 100644
--- a/app/finders/group_members_finder.rb
+++ b/app/finders/group_members_finder.rb
@@ -6,15 +6,15 @@ class GroupMembersFinder < UnionFinder
end
# rubocop: disable CodeReuse/ActiveRecord
- def execute(include_descendants: false)
+ def execute(include_relations: [:inherited, :direct])
group_members = @group.members
relations = []
- return group_members unless @group.parent || include_descendants
+ return group_members if include_relations == [:direct]
- relations << group_members
+ relations << group_members if include_relations.include?(:direct)
- if @group.parent
+ if include_relations.include?(:inherited) && @group.parent
parents_members = GroupMember.non_request
.where(source_id: @group.ancestors.select(:id))
.where.not(user_id: @group.users.select(:id))
@@ -22,7 +22,7 @@ class GroupMembersFinder < UnionFinder
relations << parents_members
end
- if include_descendants
+ if include_relations.include?(:descendants)
descendant_members = GroupMember.non_request
.where(source_id: @group.descendants.select(:id))
.where.not(user_id: @group.users.select(:id))
diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb
index 8ab5072fdc6..dd8b2f29425 100644
--- a/app/finders/group_projects_finder.rb
+++ b/app/finders/group_projects_finder.rb
@@ -11,6 +11,7 @@
# options:
# only_owned: boolean
# only_shared: boolean
+# limit: integer
# params:
# sort: string
# visibility_level: int
@@ -20,6 +21,8 @@
# non_archived: boolean
#
class GroupProjectsFinder < ProjectsFinder
+ DEFAULT_PROJECTS_LIMIT = 100
+
attr_reader :group, :options
def initialize(group:, params: {}, options: {}, current_user: nil, project_ids_relation: nil)
@@ -32,8 +35,19 @@ class GroupProjectsFinder < ProjectsFinder
@options = options
end
+ def execute
+ collection = super
+ limit(collection)
+ end
+
private
+ def limit(collection)
+ limit = options[:limit]
+
+ limit.present? ? collection.with_limit(limit) : collection
+ end
+
def init_collection
projects = if current_user
collection_with_user
diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb
index 7d419103b1c..54715557399 100644
--- a/app/finders/groups_finder.rb
+++ b/app/finders/groups_finder.rb
@@ -45,7 +45,7 @@ class GroupsFinder < UnionFinder
def all_groups
return [owned_groups] if params[:owned]
return [groups_with_min_access_level] if min_access_level?
- return [Group.all] if current_user&.full_private_access? && all_available?
+ return [Group.all] if current_user&.can_read_all_resources? && all_available?
groups = []
groups << Gitlab::ObjectHierarchy.new(groups_for_ancestors, groups_for_descendants).all_objects if current_user
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index dfddd32d7df..e3ea81d5564 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -13,6 +13,7 @@
# group_id: integer
# project_id: integer
# milestone_title: string
+# release_tag: string
# author_id: integer
# author_username: string
# assignee_id: integer or 'None' or 'Any'
@@ -59,6 +60,7 @@ class IssuableFinder
author_username
label_name
milestone_title
+ release_tag
my_reaction_emoji
search
in
@@ -126,6 +128,7 @@ class IssuableFinder
items = by_non_archived(items)
items = by_iids(items)
items = by_milestone(items)
+ items = by_release(items)
items = by_label(items)
by_my_reaction_emoji(items)
end
@@ -364,6 +367,10 @@ class IssuableFinder
end
end
+ def releases?
+ params[:release_tag].present?
+ end
+
private
def force_cte?
@@ -570,6 +577,18 @@ class IssuableFinder
end
# rubocop: enable CodeReuse/ActiveRecord
+ def by_release(items)
+ return items unless releases?
+
+ if filter_by_no_release?
+ items.without_release
+ elsif filter_by_any_release?
+ items.any_release
+ else
+ items.with_release(params[:release_tag], params[:project_id])
+ end
+ end
+
def filter_by_no_milestone?
# Accepts `No Milestone` for compatibility
params[:milestone_title].to_s.downcase == FILTER_NONE || params[:milestone_title] == Milestone::None.title
@@ -588,6 +607,14 @@ class IssuableFinder
params[:milestone_title] == Milestone::Started.name
end
+ def filter_by_no_release?
+ params[:release_tag].to_s.downcase == FILTER_NONE
+ end
+
+ def filter_by_any_release?
+ params[:release_tag].to_s.downcase == FILTER_ANY
+ end
+
def by_label(items)
return items unless labels?
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index 74e89a1e66c..641b4422db9 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -127,7 +127,7 @@ class IssuesFinder < IssuableFinder
return @user_can_see_all_confidential_issues if defined?(@user_can_see_all_confidential_issues)
return @user_can_see_all_confidential_issues = false if current_user.blank?
- return @user_can_see_all_confidential_issues = true if current_user.full_private_access?
+ return @user_can_see_all_confidential_issues = true if current_user.can_read_all_resources?
@user_can_see_all_confidential_issues =
if project? && project
diff --git a/app/finders/jobs_finder.rb b/app/finders/jobs_finder.rb
new file mode 100644
index 00000000000..bac18e69618
--- /dev/null
+++ b/app/finders/jobs_finder.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+class JobsFinder
+ include Gitlab::Allowable
+
+ def initialize(current_user:, project: nil, params: {})
+ @current_user = current_user
+ @project = project
+ @params = params
+ end
+
+ def execute
+ builds = init_collection.order_id_desc
+ filter_by_scope(builds)
+ rescue Gitlab::Access::AccessDeniedError
+ Ci::Build.none
+ end
+
+ private
+
+ attr_reader :current_user, :project, :params
+
+ def init_collection
+ project ? project_builds : all_builds
+ end
+
+ def all_builds
+ raise Gitlab::Access::AccessDeniedError unless current_user&.admin?
+
+ Ci::Build.all
+ end
+
+ def project_builds
+ raise Gitlab::Access::AccessDeniedError unless can?(current_user, :read_build, project)
+
+ project.builds.relevant
+ end
+
+ def filter_by_scope(builds)
+ case params[:scope]
+ when 'pending'
+ builds.pending.reverse_order
+ when 'running'
+ builds.running.reverse_order
+ when 'finished'
+ builds.finished
+ else
+ builds
+ end
+ end
+end
diff --git a/app/finders/keys_finder.rb b/app/finders/keys_finder.rb
new file mode 100644
index 00000000000..6fd914c88cd
--- /dev/null
+++ b/app/finders/keys_finder.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+class KeysFinder
+ InvalidFingerprint = Class.new(StandardError)
+ GitLabAccessDeniedError = Class.new(StandardError)
+
+ FINGERPRINT_ATTRIBUTES = {
+ 'sha256' => 'fingerprint_sha256',
+ 'md5' => 'fingerprint'
+ }.freeze
+
+ def initialize(current_user, params)
+ @current_user = current_user
+ @params = params
+ end
+
+ def execute
+ raise GitLabAccessDeniedError unless current_user.admin?
+
+ keys = by_key_type
+ keys = by_user(keys)
+ keys = sort(keys)
+
+ by_fingerprint(keys)
+ end
+
+ private
+
+ attr_reader :current_user, :params
+
+ def by_key_type
+ if params[:key_type] == 'ssh'
+ Key.regular_keys
+ else
+ Key.all
+ end
+ end
+
+ def sort(keys)
+ keys.order_last_used_at_desc
+ end
+
+ def by_user(keys)
+ return keys unless params[:user]
+
+ keys.for_user(params[:user])
+ end
+
+ def by_fingerprint(keys)
+ return keys unless params[:fingerprint].present?
+ raise InvalidFingerprint unless valid_fingerprint_param?
+
+ keys.where(fingerprint_query).first # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ def valid_fingerprint_param?
+ if fingerprint_type == "sha256"
+ Base64.decode64(fingerprint).length == 32
+ else
+ fingerprint =~ /^(\h{2}:){15}\h{2}/
+ end
+ end
+
+ def fingerprint_query
+ fingerprint_attribute = FINGERPRINT_ATTRIBUTES[fingerprint_type]
+
+ Key.arel_table[fingerprint_attribute].eq(fingerprint)
+ end
+
+ def fingerprint_type
+ if params[:fingerprint].start_with?(/sha256:|SHA256:/)
+ "sha256"
+ else
+ "md5"
+ end
+ end
+
+ def fingerprint
+ if fingerprint_type == "sha256"
+ params[:fingerprint].gsub(/sha256:|SHA256:/, "")
+ else
+ params[:fingerprint]
+ end
+ end
+end
diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb
index e8c7f9622a9..a919ff5bf8a 100644
--- a/app/finders/members_finder.rb
+++ b/app/finders/members_finder.rb
@@ -9,14 +9,18 @@ class MembersFinder
@group = project.group
end
- def execute(include_descendants: false, include_invited_groups_members: false)
+ def execute(include_relations: [:inherited, :direct])
project_members = project.project_members
project_members = project_members.non_invite unless can?(current_user, :admin_project, project)
- union_members = group_union_members(include_descendants, include_invited_groups_members)
+ 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 << project_members)
+ distinct_union_of_members(union_members)
else
project_members
end
@@ -28,15 +32,17 @@ class MembersFinder
private
- def group_union_members(include_descendants, include_invited_groups_members)
+ def group_union_members(include_relations)
[].tap do |members|
- members << direct_group_members(include_descendants) if group
- members << project_invited_groups_members if include_invited_groups_members
+ members << direct_group_members(include_relations.include?(:descendants)) if group
+ members << project_invited_groups_members if include_relations.include?(:invited_groups_members)
end
end
def direct_group_members(include_descendants)
- GroupMembersFinder.new(group).execute(include_descendants: include_descendants).non_invite # rubocop: disable CodeReuse/Finder
+ requested_relations = [:inherited, :direct]
+ requested_relations << :descendants if include_descendants
+ GroupMembersFinder.new(group).execute(include_relations: requested_relations).non_invite # rubocop: disable CodeReuse/Finder
end
def project_invited_groups_members
diff --git a/app/finders/merge_request_target_project_finder.rb b/app/finders/merge_request_target_project_finder.rb
index 5f0589f6c8b..85a73e0c6ff 100644
--- a/app/finders/merge_request_target_project_finder.rb
+++ b/app/finders/merge_request_target_project_finder.rb
@@ -11,15 +11,23 @@ class MergeRequestTargetProjectFinder
end
# rubocop: disable CodeReuse/ActiveRecord
- def execute
- if @source_project.fork_network
- @source_project.fork_network.projects
- .public_or_visible_to_user(current_user)
- .non_archived
- .with_feature_available_for_user(:merge_requests, current_user)
+ def execute(include_routes: false)
+ if source_project.fork_network
+ include_routes ? projects.inc_routes : projects
else
Project.where(id: source_project)
end
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ private
+
+ def projects
+ source_project
+ .fork_network
+ .projects
+ .public_or_visible_to_user(current_user)
+ .non_archived
+ .with_feature_available_for_user(:merge_requests, current_user)
+ end
end
diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb
index 1c9c7ec68d0..275a01330bf 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -12,6 +12,7 @@
# group_id: integer
# project_id: integer
# milestone_title: string
+# release_tag: string
# author_id: integer
# assignee_id: integer
# search: string
diff --git a/app/finders/personal_access_tokens_finder.rb b/app/finders/personal_access_tokens_finder.rb
index bd95dcd323f..7b15a3b0c10 100644
--- a/app/finders/personal_access_tokens_finder.rb
+++ b/app/finders/personal_access_tokens_finder.rb
@@ -13,18 +13,26 @@ class PersonalAccessTokensFinder
tokens = PersonalAccessToken.all
tokens = by_user(tokens)
tokens = by_impersonation(tokens)
- by_state(tokens)
+ tokens = by_state(tokens)
+
+ sort(tokens)
end
private
- # rubocop: disable CodeReuse/ActiveRecord
def by_user(tokens)
return tokens unless @params[:user]
- tokens.where(user: @params[:user])
+ tokens.for_user(@params[:user])
+ end
+
+ def sort(tokens)
+ available_sort_orders = PersonalAccessToken.simple_sorts.keys
+
+ return tokens unless available_sort_orders.include?(params[:sort])
+
+ tokens.order_by(params[:sort])
end
- # rubocop: enable CodeReuse/ActiveRecord
def by_impersonation(tokens)
case @params[:impersonation]
diff --git a/app/finders/pipelines_finder.rb b/app/finders/pipelines_finder.rb
index f5aadc42ff0..5a0d53d9683 100644
--- a/app/finders/pipelines_finder.rb
+++ b/app/finders/pipelines_finder.rb
@@ -3,7 +3,7 @@
class PipelinesFinder
attr_reader :project, :pipelines, :params, :current_user
- ALLOWED_INDEXED_COLUMNS = %w[id status ref user_id].freeze
+ ALLOWED_INDEXED_COLUMNS = %w[id status ref updated_at user_id].freeze
def initialize(project, current_user, params = {})
@project = project
@@ -25,6 +25,7 @@ class PipelinesFinder
items = by_name(items)
items = by_username(items)
items = by_yaml_errors(items)
+ items = by_updated_at(items)
sort_items(items)
end
@@ -128,6 +129,13 @@ class PipelinesFinder
end
# rubocop: enable CodeReuse/ActiveRecord
+ def by_updated_at(items)
+ items = items.updated_before(params[:updated_before]) if params[:updated_before].present?
+ items = items.updated_after(params[:updated_after]) if params[:updated_after].present?
+
+ items
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def sort_items(items)
order_by = if ALLOWED_INDEXED_COLUMNS.include?(params[:order_by])
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index 42a15234e57..ac18c17dc61 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -79,7 +79,7 @@ class ProjectsFinder < UnionFinder
elsif min_access_level?
current_user.authorized_projects(params[:min_access_level])
else
- if private_only?
+ if private_only? || impossible_visibility_level?
current_user.authorized_projects
else
Project.public_or_visible_to_user(current_user)
@@ -96,6 +96,30 @@ class ProjectsFinder < UnionFinder
end
end
+ # This is an optimization - surprisingly PostgreSQL does not optimize
+ # for this.
+ #
+ # If the default visiblity level and desired visiblity level filter cancels
+ # each other out, don't use the SQL clause for visibility level in
+ # `Project.public_or_visible_to_user`. In fact, this then becames equivalent
+ # to just authorized projects for the user.
+ #
+ # E.g.
+ # (EXISTS(<authorized_projects>) OR projects.visibility_level IN (10,20))
+ # AND "projects"."visibility_level" = 0
+ #
+ # is essentially
+ # EXISTS(<authorized_projects>) AND "projects"."visibility_level" = 0
+ #
+ # See https://gitlab.com/gitlab-org/gitlab/issues/37007
+ def impossible_visibility_level?
+ return unless params[:visibility_level].present?
+
+ public_visibility_levels = Gitlab::VisibilityLevel.levels_for_user(current_user)
+
+ !public_visibility_levels.include?(params[:visibility_level])
+ end
+
def owned_projects?
params[:owned].present?
end
diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb
index bd6b6190fb5..5819f279eaa 100644
--- a/app/finders/snippets_finder.rb
+++ b/app/finders/snippets_finder.rb
@@ -40,15 +40,14 @@
# Any other value will be ignored.
class SnippetsFinder < UnionFinder
include FinderMethods
+ include Gitlab::Utils::StrongMemoize
- attr_accessor :current_user, :project, :author, :scope, :explore
+ attr_accessor :current_user, :params
+ delegate :explore, :only_personal, :only_project, :scope, to: :params
def initialize(current_user = nil, params = {})
@current_user = current_user
- @project = params[:project]
- @author = params[:author]
- @scope = params[:scope].to_s
- @explore = params[:explore]
+ @params = OpenStruct.new(params)
if project && author
raise(
@@ -60,8 +59,15 @@ class SnippetsFinder < UnionFinder
end
def execute
- base = init_collection
- base.with_optional_visibility(visibility_from_scope).fresh
+ # The snippet query can be expensive, therefore if the
+ # author or project params have been passed and they don't
+ # exist, it's better to return
+ return Snippet.none if author.nil? && params[:author].present?
+ return Snippet.none if project.nil? && params[:project].present?
+
+ items = init_collection
+ items = by_ids(items)
+ items.with_optional_visibility(visibility_from_scope).fresh
end
private
@@ -69,10 +75,12 @@ class SnippetsFinder < UnionFinder
def init_collection
if explore
snippets_for_explore
+ elsif only_personal
+ personal_snippets
elsif project
snippets_for_a_single_project
else
- snippets_for_multiple_projects
+ snippets_for_personal_and_multiple_projects
end
end
@@ -96,8 +104,9 @@ class SnippetsFinder < UnionFinder
#
# Each collection is constructed in isolation, allowing for greater control
# over the resulting SQL query.
- def snippets_for_multiple_projects
- queries = [personal_snippets]
+ def snippets_for_personal_and_multiple_projects
+ queries = []
+ queries << personal_snippets unless only_project
if Ability.allowed?(current_user, :read_cross_project)
queries << snippets_of_visible_projects
@@ -158,7 +167,7 @@ class SnippetsFinder < UnionFinder
end
def visibility_from_scope
- case scope
+ case scope.to_s
when 'are_private'
Snippet::PRIVATE
when 'are_internal'
@@ -169,6 +178,28 @@ class SnippetsFinder < UnionFinder
nil
end
end
+
+ def by_ids(items)
+ return items unless params[:ids].present?
+
+ items.id_in(params[:ids])
+ end
+
+ def author
+ strong_memoize(:author) do
+ next unless params[:author].present?
+
+ params[:author].is_a?(User) ? params[:author] : User.find_by_id(params[:author])
+ end
+ end
+
+ def project
+ strong_memoize(:project) do
+ next unless params[:project].present?
+
+ params[:project].is_a?(Project) ? params[:project] : Project.find_by_id(params[:project])
+ end
+ end
end
SnippetsFinder.prepend_if_ee('EE::SnippetsFinder')
diff --git a/app/finders/user_finder.rb b/app/finders/user_finder.rb
index 1dd1a27437e..556be4c4338 100644
--- a/app/finders/user_finder.rb
+++ b/app/finders/user_finder.rb
@@ -52,12 +52,6 @@ class UserFinder
end
end
- def find_by_ssh_key_id
- return unless input_is_id?
-
- User.find_by_ssh_key_id(@username_or_id)
- end
-
def input_is_id?
@username_or_id.is_a?(Numeric) || @username_or_id =~ /^\d+$/
end
diff --git a/app/graphql/mutations/issues/base.rb b/app/graphql/mutations/issues/base.rb
new file mode 100644
index 00000000000..b7fa234a50b
--- /dev/null
+++ b/app/graphql/mutations/issues/base.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Issues
+ class Base < BaseMutation
+ include Mutations::ResolvesProject
+
+ argument :project_path, GraphQL::ID_TYPE,
+ required: true,
+ description: "The project the issue to mutate is in"
+
+ argument :iid, GraphQL::STRING_TYPE,
+ required: true,
+ description: "The iid of the issue to mutate"
+
+ field :issue,
+ Types::IssueType,
+ null: true,
+ description: "The issue after mutation"
+
+ authorize :update_issue
+
+ private
+
+ def find_object(project_path:, iid:)
+ project = resolve_project(full_path: project_path)
+ resolver = Resolvers::IssuesResolver
+ .single.new(object: project, context: context)
+
+ resolver.resolve(iid: iid)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/issues/set_confidential.rb b/app/graphql/mutations/issues/set_confidential.rb
new file mode 100644
index 00000000000..0fff5518665
--- /dev/null
+++ b/app/graphql/mutations/issues/set_confidential.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Issues
+ class SetConfidential < Base
+ graphql_name 'IssueSetConfidential'
+
+ argument :confidential,
+ GraphQL::BOOLEAN_TYPE,
+ required: true,
+ description: 'Whether or not to set the issue as a confidential.'
+
+ def resolve(project_path:, iid:, confidential:)
+ issue = authorized_find!(project_path: project_path, iid: iid)
+ project = issue.project
+
+ ::Issues::UpdateService.new(project, current_user, confidential: confidential)
+ .execute(issue)
+
+ {
+ issue: issue,
+ errors: issue.errors.full_messages
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/issues/set_due_date.rb b/app/graphql/mutations/issues/set_due_date.rb
new file mode 100644
index 00000000000..1855c6f053b
--- /dev/null
+++ b/app/graphql/mutations/issues/set_due_date.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Issues
+ class SetDueDate < Base
+ graphql_name 'IssueSetDueDate'
+
+ argument :due_date,
+ Types::TimeType,
+ required: true,
+ description: 'The desired due date for the issue'
+
+ def resolve(project_path:, iid:, due_date:)
+ issue = authorized_find!(project_path: project_path, iid: iid)
+ project = issue.project
+
+ ::Issues::UpdateService.new(project, current_user, due_date: due_date)
+ .execute(issue)
+
+ {
+ issue: issue,
+ errors: issue.errors.full_messages
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/snippets/base.rb b/app/graphql/mutations/snippets/base.rb
new file mode 100644
index 00000000000..9dc6d49774e
--- /dev/null
+++ b/app/graphql/mutations/snippets/base.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Snippets
+ class Base < BaseMutation
+ field :snippet,
+ Types::SnippetType,
+ null: true,
+ description: 'The snippet after mutation'
+
+ private
+
+ def find_object(id:)
+ GitlabSchema.object_from_id(id)
+ end
+
+ def authorized_resource?(snippet)
+ Ability.allowed?(context[:current_user], ability_for(snippet), snippet)
+ end
+
+ def ability_for(snippet)
+ "#{ability_name}_#{snippet.to_ability_name}".to_sym
+ end
+
+ def ability_name
+ raise NotImplementedError
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb
new file mode 100644
index 00000000000..fe1f543ea1a
--- /dev/null
+++ b/app/graphql/mutations/snippets/create.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Snippets
+ class Create < BaseMutation
+ include Mutations::ResolvesProject
+
+ graphql_name 'CreateSnippet'
+
+ field :snippet,
+ Types::SnippetType,
+ null: true,
+ description: 'The snippet after mutation'
+
+ argument :title, GraphQL::STRING_TYPE,
+ required: true,
+ description: 'Title of the snippet'
+
+ argument :file_name, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'File name of the snippet'
+
+ argument :content, GraphQL::STRING_TYPE,
+ required: true,
+ description: 'Content of the snippet'
+
+ argument :description, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Description of the snippet'
+
+ argument :visibility_level, Types::VisibilityLevelsEnum,
+ description: 'The visibility level of the snippet',
+ required: true
+
+ argument :project_path, GraphQL::ID_TYPE,
+ required: false,
+ description: 'The project full path the snippet is associated with'
+
+ def resolve(args)
+ project_path = args.delete(:project_path)
+
+ if project_path.present?
+ project = find_project!(project_path: project_path)
+ elsif !can_create_personal_snippet?
+ raise_resource_not_avaiable_error!
+ end
+
+ snippet = CreateSnippetService.new(project,
+ context[:current_user],
+ args).execute
+
+ {
+ snippet: snippet.valid? ? snippet : nil,
+ errors: errors_on_object(snippet)
+ }
+ end
+
+ private
+
+ def find_project!(project_path:)
+ authorized_find!(full_path: project_path)
+ end
+
+ def find_object(full_path:)
+ resolve_project(full_path: full_path)
+ end
+
+ def authorized_resource?(project)
+ Ability.allowed?(context[:current_user], :create_project_snippet, project)
+ end
+
+ def can_create_personal_snippet?
+ Ability.allowed?(context[:current_user], :create_personal_snippet)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/snippets/destroy.rb b/app/graphql/mutations/snippets/destroy.rb
new file mode 100644
index 00000000000..115fcfd6488
--- /dev/null
+++ b/app/graphql/mutations/snippets/destroy.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Snippets
+ class Destroy < Base
+ graphql_name 'DestroySnippet'
+
+ ERROR_MSG = 'Error deleting the snippet'
+
+ argument :id,
+ GraphQL::ID_TYPE,
+ required: true,
+ description: 'The global id of the snippet to destroy'
+
+ def resolve(id:)
+ snippet = authorized_find!(id: id)
+
+ result = snippet.destroy
+ errors = result ? [] : [ERROR_MSG]
+
+ {
+ errors: errors
+ }
+ end
+
+ private
+
+ def ability_name
+ "admin"
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/snippets/mark_as_spam.rb b/app/graphql/mutations/snippets/mark_as_spam.rb
new file mode 100644
index 00000000000..260a9753f76
--- /dev/null
+++ b/app/graphql/mutations/snippets/mark_as_spam.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Snippets
+ class MarkAsSpam < Base
+ graphql_name 'MarkAsSpamSnippet'
+
+ argument :id,
+ GraphQL::ID_TYPE,
+ required: true,
+ description: 'The global id of the snippet to update'
+
+ def resolve(id:)
+ snippet = authorized_find!(id: id)
+
+ result = mark_as_spam(snippet)
+ errors = result ? [] : ['Error with Akismet. Please check the logs for more info.']
+
+ {
+ errors: errors
+ }
+ end
+
+ private
+
+ def mark_as_spam(snippet)
+ SpamService.new(snippet).mark_as_spam!
+ end
+
+ def authorized_resource?(snippet)
+ super && snippet.submittable_as_spam_by?(context[:current_user])
+ end
+
+ def ability_name
+ "admin"
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb
new file mode 100644
index 00000000000..27c232bc7f8
--- /dev/null
+++ b/app/graphql/mutations/snippets/update.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Snippets
+ class Update < Base
+ graphql_name 'UpdateSnippet'
+
+ argument :id,
+ GraphQL::ID_TYPE,
+ required: true,
+ description: 'The global id of the snippet to update'
+
+ argument :title, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Title of the snippet'
+
+ argument :file_name, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'File name of the snippet'
+
+ argument :content, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Content of the snippet'
+
+ argument :description, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Description of the snippet'
+
+ argument :visibility_level, Types::VisibilityLevelsEnum,
+ description: 'The visibility level of the snippet',
+ required: false
+
+ def resolve(args)
+ snippet = authorized_find!(id: args.delete(:id))
+
+ result = UpdateSnippetService.new(snippet.project,
+ context[:current_user],
+ snippet,
+ args).execute
+
+ {
+ snippet: result ? snippet : snippet.reset,
+ errors: errors_on_object(snippet)
+ }
+ end
+
+ private
+
+ def ability_name
+ "update"
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/todos/base.rb b/app/graphql/mutations/todos/base.rb
index b6c7b320be1..2a72019fbac 100644
--- a/app/graphql/mutations/todos/base.rb
+++ b/app/graphql/mutations/todos/base.rb
@@ -9,6 +9,12 @@ module Mutations
GitlabSchema.object_from_id(id)
end
+ def map_to_global_ids(ids)
+ return [] if ids.blank?
+
+ ids.map { |id| to_global_id(id) }
+ end
+
def to_global_id(id)
::URI::GID.build(app: GlobalID.app, model_name: Todo.name, model_id: id, params: nil).to_s
end
diff --git a/app/graphql/mutations/todos/mark_all_done.rb b/app/graphql/mutations/todos/mark_all_done.rb
new file mode 100644
index 00000000000..5694985717c
--- /dev/null
+++ b/app/graphql/mutations/todos/mark_all_done.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Todos
+ class MarkAllDone < ::Mutations::Todos::Base
+ graphql_name 'TodosMarkAllDone'
+
+ authorize :update_user
+
+ field :updated_ids,
+ [GraphQL::ID_TYPE],
+ null: false,
+ description: 'Ids of the updated todos'
+
+ def resolve
+ authorize!(current_user)
+
+ updated_ids = mark_all_todos_done
+
+ {
+ updated_ids: map_to_global_ids(updated_ids),
+ errors: []
+ }
+ end
+
+ private
+
+ def mark_all_todos_done
+ return [] unless current_user
+
+ TodoService.new.mark_all_todos_as_done_by_user(current_user)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/todos/mark_done.rb b/app/graphql/mutations/todos/mark_done.rb
index 5483708b5c6..d738e387c43 100644
--- a/app/graphql/mutations/todos/mark_done.rb
+++ b/app/graphql/mutations/todos/mark_done.rb
@@ -16,22 +16,21 @@ module Mutations
null: false,
description: 'The requested todo'
- # rubocop: disable CodeReuse/ActiveRecord
def resolve(id:)
todo = authorized_find!(id: id)
- mark_done(Todo.where(id: todo.id)) unless todo.done?
+
+ mark_done(todo)
{
todo: todo.reset,
errors: errors_on_object(todo)
}
end
- # rubocop: enable CodeReuse/ActiveRecord
private
def mark_done(todo)
- TodoService.new.mark_todos_as_done(todo, current_user)
+ TodoService.new.mark_todo_as_done(todo, current_user)
end
end
end
diff --git a/app/graphql/mutations/todos/restore.rb b/app/graphql/mutations/todos/restore.rb
new file mode 100644
index 00000000000..c4597bd84a2
--- /dev/null
+++ b/app/graphql/mutations/todos/restore.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Todos
+ class Restore < ::Mutations::Todos::Base
+ graphql_name 'TodoRestore'
+
+ authorize :update_todo
+
+ argument :id,
+ GraphQL::ID_TYPE,
+ required: true,
+ description: 'The global id of the todo to restore'
+
+ field :todo, Types::TodoType,
+ null: false,
+ description: 'The requested todo'
+
+ def resolve(id:)
+ todo = authorized_find!(id: id)
+ restore(todo.id) if todo.done?
+
+ {
+ todo: todo.reset,
+ errors: errors_on_object(todo)
+ }
+ end
+
+ private
+
+ def restore(id)
+ TodoService.new.mark_todos_as_pending_by_ids([id], current_user)
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb
index 85d6b377934..62dcc41dd9c 100644
--- a/app/graphql/resolvers/base_resolver.rb
+++ b/app/graphql/resolvers/base_resolver.rb
@@ -2,6 +2,8 @@
module Resolvers
class BaseResolver < GraphQL::Schema::Resolver
+ extend ::Gitlab::Utils::Override
+
def self.single
@single ||= Class.new(self) do
def resolve(**args)
@@ -36,5 +38,13 @@ module Resolvers
# complexity difference is minimal in this case.
[args[:iid], args[:iids]].any? ? 0 : 0.01
end
+
+ override :object
+ def object
+ super.tap do |obj|
+ # If the field this resolver is used in is wrapped in a presenter, go back to it's subject
+ break obj.subject if obj.is_a?(Gitlab::View::Presenter::Base)
+ end
+ end
end
end
diff --git a/app/graphql/resolvers/concerns/resolves_snippets.rb b/app/graphql/resolvers/concerns/resolves_snippets.rb
new file mode 100644
index 00000000000..483372bbf63
--- /dev/null
+++ b/app/graphql/resolvers/concerns/resolves_snippets.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module ResolvesSnippets
+ extend ActiveSupport::Concern
+
+ included do
+ type Types::SnippetType, null: false
+
+ argument :ids, [GraphQL::ID_TYPE],
+ required: false,
+ description: 'Array of global snippet ids, e.g., "gid://gitlab/ProjectSnippet/1"'
+
+ argument :visibility, Types::Snippets::VisibilityScopesEnum,
+ required: false,
+ description: 'The visibility of the snippet'
+ end
+
+ def resolve(**args)
+ resolve_snippets(args)
+ end
+
+ private
+
+ def resolve_snippets(args)
+ SnippetsFinder.new(context[:current_user], snippet_finder_params(args)).execute
+ end
+
+ def snippet_finder_params(args)
+ {
+ ids: resolve_ids(args[:ids]),
+ scope: args[:visibility]
+ }.merge(options_by_type(args[:type]))
+ end
+
+ def resolve_ids(ids)
+ Array.wrap(ids).map { |id| resolve_gid(id, :id) }
+ end
+
+ def resolve_gid(gid, argument)
+ return unless gid.present?
+
+ GlobalID.parse(gid)&.model_id.tap do |id|
+ raise Gitlab::Graphql::Errors::ArgumentError, "Invalid global id format for param #{argument}" if id.nil?
+ end
+ end
+
+ def options_by_type(type)
+ case type
+ when 'personal'
+ { only_personal: true }
+ when 'project'
+ { only_project: true }
+ else
+ {}
+ end
+ end
+end
diff --git a/app/graphql/resolvers/echo_resolver.rb b/app/graphql/resolvers/echo_resolver.rb
index 2ce55544254..fe0b1893a23 100644
--- a/app/graphql/resolvers/echo_resolver.rb
+++ b/app/graphql/resolvers/echo_resolver.rb
@@ -2,9 +2,11 @@
module Resolvers
class EchoResolver < BaseResolver
- argument :text, GraphQL::STRING_TYPE, required: true # rubocop:disable Graphql/Descriptions
description 'Testing endpoint to validate the API with'
+ argument :text, GraphQL::STRING_TYPE, required: true,
+ description: 'Text to echo back'
+
def resolve(**args)
username = context[:current_user]&.username
diff --git a/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb
new file mode 100644
index 00000000000..63455ff3acb
--- /dev/null
+++ b/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module ErrorTracking
+ class SentryDetailedErrorResolver < BaseResolver
+ argument :id, GraphQL::ID_TYPE,
+ required: true,
+ description: 'ID of the Sentry issue'
+
+ def resolve(**args)
+ project = object
+ current_user = context[:current_user]
+ issue_id = GlobalID.parse(args[:id]).model_id
+
+ # Get data from Sentry
+ response = ::ErrorTracking::IssueDetailsService.new(
+ project,
+ current_user,
+ { issue_id: issue_id }
+ ).execute
+ issue = response[:issue]
+ issue.gitlab_project = project if issue
+
+ issue
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb
index 1fbc61cd950..664e0955535 100644
--- a/app/graphql/resolvers/issues_resolver.rb
+++ b/app/graphql/resolvers/issues_resolver.rb
@@ -4,17 +4,17 @@ module Resolvers
class IssuesResolver < BaseResolver
argument :iid, GraphQL::STRING_TYPE,
required: false,
- description: 'The IID of the issue, e.g., "1"'
+ description: 'IID of the issue. For example, "1"'
argument :iids, [GraphQL::STRING_TYPE],
required: false,
- description: 'The list of IIDs of issues, e.g., [1, 2]'
+ description: 'List of IIDs of issues. For example, [1, 2]'
argument :state, Types::IssuableStateEnum,
required: false,
- description: 'Current state of Issue'
+ description: 'Current state of this issue'
argument :label_name, GraphQL::STRING_TYPE.to_list_type,
required: false,
- description: 'Labels applied to the Issue'
+ description: 'Labels applied to this issue'
argument :created_before, Types::TimeType,
required: false,
description: 'Issues created before this date'
@@ -33,8 +33,9 @@ module Resolvers
argument :closed_after, Types::TimeType,
required: false,
description: 'Issues closed after this date'
- argument :search, GraphQL::STRING_TYPE, # rubocop:disable Graphql/Descriptions
- required: false
+ argument :search, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Search query for finding issues by title or description'
argument :sort, Types::IssueSortEnum,
description: 'Sort issues by this criteria',
required: false,
@@ -53,6 +54,7 @@ module Resolvers
# https://gitlab.com/gitlab-org/gitlab-foss/issues/54520
args[:project_id] = project.id
args[:iids] ||= [args[:iid]].compact
+ args[:attempt_project_search_optimizations] = args[:search].present?
IssuesFinder.new(context[:current_user], args).execute
end
diff --git a/app/graphql/resolvers/projects/snippets_resolver.rb b/app/graphql/resolvers/projects/snippets_resolver.rb
new file mode 100644
index 00000000000..bf9aa45349f
--- /dev/null
+++ b/app/graphql/resolvers/projects/snippets_resolver.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Projects
+ class SnippetsResolver < BaseResolver
+ include ResolvesSnippets
+
+ alias_method :project, :object
+
+ def resolve(**args)
+ return Snippet.none if project.nil?
+
+ super
+ end
+
+ private
+
+ def snippet_finder_params(args)
+ super.merge(project: project)
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/snippets_resolver.rb b/app/graphql/resolvers/snippets_resolver.rb
new file mode 100644
index 00000000000..530a288a25b
--- /dev/null
+++ b/app/graphql/resolvers/snippets_resolver.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class SnippetsResolver < BaseResolver
+ include ResolvesSnippets
+
+ ERROR_MESSAGE = 'Filtering by both an author and a project is not supported'
+
+ alias_method :user, :object
+
+ argument :author_id, GraphQL::ID_TYPE,
+ required: false,
+ description: 'The ID of an author'
+
+ argument :project_id, GraphQL::ID_TYPE,
+ required: false,
+ description: 'The ID of a project'
+
+ argument :type, Types::Snippets::TypeEnum,
+ required: false,
+ description: 'The type of snippet'
+
+ argument :explore,
+ GraphQL::BOOLEAN_TYPE,
+ required: false,
+ description: 'Explore personal snippets'
+
+ def resolve(**args)
+ if args[:author_id].present? && args[:project_id].present?
+ raise Gitlab::Graphql::Errors::ArgumentError, ERROR_MESSAGE
+ end
+
+ super
+ end
+
+ private
+
+ def snippet_finder_params(args)
+ super
+ .merge(author: resolve_gid(args[:author_id], :author),
+ project: resolve_gid(args[:project_id], :project),
+ explore: args[:explore])
+ end
+ end
+end
diff --git a/app/graphql/resolvers/todo_resolver.rb b/app/graphql/resolvers/todo_resolver.rb
index 38a4539f34a..cff65321dc0 100644
--- a/app/graphql/resolvers/todo_resolver.rb
+++ b/app/graphql/resolvers/todo_resolver.rb
@@ -38,53 +38,15 @@ module Resolvers
private
- # TODO: Support multiple queries for e.g. state and type on TodosFinder:
- #
- # https://gitlab.com/gitlab-org/gitlab/merge_requests/18487
- # https://gitlab.com/gitlab-org/gitlab/merge_requests/18518
- #
- # As soon as these MR's are merged, we can refactor this to query by
- # multiple contents.
- #
def todo_finder_params(args)
{
- state: first_state(args),
- type: first_type(args),
- group_id: first_group_id(args),
- author_id: first_author_id(args),
- action_id: first_action(args),
- project_id: first_project(args)
+ state: args[:state],
+ type: args[:type],
+ group_id: args[:group_id],
+ author_id: args[:author_id],
+ action_id: args[:action],
+ project_id: args[:project_id]
}
end
-
- def first_project(args)
- first_query_field(args, :project_id)
- end
-
- def first_action(args)
- first_query_field(args, :action)
- end
-
- def first_author_id(args)
- first_query_field(args, :author_id)
- end
-
- def first_group_id(args)
- first_query_field(args, :group_id)
- end
-
- def first_state(args)
- first_query_field(args, :state)
- end
-
- def first_type(args)
- first_query_field(args, :type)
- end
-
- def first_query_field(query, field)
- return unless query.key?(field)
-
- query[field].first if query[field].respond_to?(:first)
- end
end
end
diff --git a/app/graphql/resolvers/users/snippets_resolver.rb b/app/graphql/resolvers/users/snippets_resolver.rb
new file mode 100644
index 00000000000..d757640b5ff
--- /dev/null
+++ b/app/graphql/resolvers/users/snippets_resolver.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Users
+ class SnippetsResolver < BaseResolver
+ include ResolvesSnippets
+
+ alias_method :user, :object
+
+ argument :type, Types::Snippets::TypeEnum,
+ required: false,
+ description: 'The type of snippet'
+
+ private
+
+ def snippet_finder_params(args)
+ super.merge(author: user)
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/diff_refs_type.rb b/app/graphql/types/diff_refs_type.rb
index 33a5780cd68..03d080d784b 100644
--- a/app/graphql/types/diff_refs_type.rb
+++ b/app/graphql/types/diff_refs_type.rb
@@ -6,9 +6,12 @@ module Types
class DiffRefsType < BaseObject
graphql_name 'DiffRefs'
- field :head_sha, GraphQL::STRING_TYPE, null: false, description: 'The sha of the head at the time the comment was made'
- field :base_sha, GraphQL::STRING_TYPE, null: false, description: 'The merge base of the branch the comment was made on'
- field :start_sha, GraphQL::STRING_TYPE, null: false, description: 'The sha of the branch being compared against'
+ field :head_sha, GraphQL::STRING_TYPE, null: false,
+ description: 'SHA of the HEAD at the time the comment was made'
+ field :base_sha, GraphQL::STRING_TYPE, null: false,
+ description: 'Merge base of the branch the comment was made on'
+ field :start_sha, GraphQL::STRING_TYPE, null: false,
+ description: 'SHA of the branch being compared against'
end
# rubocop: enable Graphql/AuthorizeTypes
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
new file mode 100644
index 00000000000..c680f387a9a
--- /dev/null
+++ b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+module Types
+ module ErrorTracking
+ class SentryDetailedErrorType < ::Types::BaseObject
+ graphql_name 'SentryDetailedError'
+
+ present_using SentryDetailedErrorPresenter
+
+ authorize :read_sentry_issue
+
+ 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 :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 :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 :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 :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"
+ 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 :first_release_last_commit, GraphQL::STRING_TYPE,
+ null: true,
+ description: "Commit the error was first seen"
+ field :last_release_last_commit, GraphQL::STRING_TYPE,
+ null: true,
+ 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"
+ field :last_release_short_version, GraphQL::STRING_TYPE,
+ null: true,
+ description: "Release version the error was last seen"
+
+ 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
+ end
+ end
+end
diff --git a/app/graphql/types/error_tracking/sentry_error_frequency_type.rb b/app/graphql/types/error_tracking/sentry_error_frequency_type.rb
new file mode 100644
index 00000000000..a44ca0684b6
--- /dev/null
+++ b/app/graphql/types/error_tracking/sentry_error_frequency_type.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Types
+ module ErrorTracking
+ # rubocop: disable Graphql/AuthorizeTypes
+ class SentryErrorFrequencyType < ::Types::BaseObject
+ graphql_name 'SentryErrorFrequency'
+
+ field :time, Types::TimeType,
+ null: false,
+ description: "Time the error frequency stats were recorded"
+ field :count, GraphQL::INT_TYPE,
+ null: false,
+ description: "Count of errors received since the previously recorded time"
+ end
+ # rubocop: enable Graphql/AuthorizeTypes
+ end
+end
diff --git a/app/graphql/types/error_tracking/sentry_error_status_enum.rb b/app/graphql/types/error_tracking/sentry_error_status_enum.rb
new file mode 100644
index 00000000000..df68eef4f3c
--- /dev/null
+++ b/app/graphql/types/error_tracking/sentry_error_status_enum.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Types
+ module ErrorTracking
+ class SentryErrorStatusEnum < ::Types::BaseEnum
+ graphql_name 'SentryErrorStatus'
+ description 'State of a Sentry error'
+
+ value 'RESOLVED', value: 'resolved', description: 'Error has been resolved'
+ value 'RESOLVED_IN_NEXT_RELEASE', value: 'resolvedInNextRelease', description: 'Error has been ignored until next release'
+ value 'UNRESOLVED', value: 'unresolved', description: 'Error is unresolved'
+ value 'IGNORED', value: 'ignored', description: 'Error has been ignored'
+ end
+ end
+end
diff --git a/app/graphql/types/issuable_sort_enum.rb b/app/graphql/types/issuable_sort_enum.rb
index 932e90c2d22..9fb1249d582 100644
--- a/app/graphql/types/issuable_sort_enum.rb
+++ b/app/graphql/types/issuable_sort_enum.rb
@@ -1,10 +1,8 @@
# frozen_string_literal: true
module Types
- # rubocop: disable Graphql/AuthorizeTypes
class IssuableSortEnum < SortEnum
graphql_name 'IssuableSort'
description 'Values for sorting issuables'
end
- # rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/issue_sort_enum.rb b/app/graphql/types/issue_sort_enum.rb
index 48ff5819286..c8d8f3ef079 100644
--- a/app/graphql/types/issue_sort_enum.rb
+++ b/app/graphql/types/issue_sort_enum.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
module Types
- # rubocop: disable Graphql/AuthorizeTypes
class IssueSortEnum < IssuableSortEnum
graphql_name 'IssueSort'
description 'Values for sorting issues'
@@ -10,5 +9,6 @@ module Types
value 'DUE_DATE_DESC', 'Due date by descending order', value: 'due_date_desc'
value 'RELATIVE_POSITION_ASC', 'Relative position by ascending order', value: 'relative_position_asc'
end
- # rubocop: enable Graphql/AuthorizeTypes
end
+
+Types::IssueSortEnum.prepend_if_ee('::EE::Types::IssueSortEnum')
diff --git a/app/graphql/types/issue_state_enum.rb b/app/graphql/types/issue_state_enum.rb
index 70c34fbe491..6521407fc9d 100644
--- a/app/graphql/types/issue_state_enum.rb
+++ b/app/graphql/types/issue_state_enum.rb
@@ -1,11 +1,8 @@
# frozen_string_literal: true
module Types
- # rubocop: disable Graphql/AuthorizeTypes
- # This is a BaseEnum through IssuableEnum, so it does not need authorization
class IssueStateEnum < IssuableStateEnum
graphql_name 'IssueState'
description 'State of a GitLab issue'
end
- # rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/label_type.rb b/app/graphql/types/label_type.rb
index d0bcf2068b7..738a00ad616 100644
--- a/app/graphql/types/label_type.rb
+++ b/app/graphql/types/label_type.rb
@@ -9,7 +9,7 @@ module Types
field :id, GraphQL::ID_TYPE, null: false,
description: 'Label ID'
field :description, GraphQL::STRING_TYPE, null: true,
- description: 'Description of the label (markdown rendered as HTML for caching)'
+ description: 'Description of the label (Markdown rendered as HTML for caching)'
markdown_field :description_html, null: true
field :title, GraphQL::STRING_TYPE, null: false,
description: 'Content of the label'
diff --git a/app/graphql/types/merge_request_state_enum.rb b/app/graphql/types/merge_request_state_enum.rb
index 37c890a3c8d..92f52726ab3 100644
--- a/app/graphql/types/merge_request_state_enum.rb
+++ b/app/graphql/types/merge_request_state_enum.rb
@@ -1,13 +1,10 @@
# frozen_string_literal: true
module Types
- # rubocop: disable Graphql/AuthorizeTypes
- # This is a BaseEnum through IssuableEnum, so it does not need authorization
class MergeRequestStateEnum < IssuableStateEnum
graphql_name 'MergeRequestState'
description 'State of a GitLab merge request'
value 'merged'
end
- # rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index 278a95fe3ca..0da95b367d8 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -20,7 +20,7 @@ module Types
description: 'Title of the merge request'
markdown_field :title_html, null: true
field :description, GraphQL::STRING_TYPE, null: true,
- description: 'Description of the merge request (markdown rendered as HTML for caching)'
+ description: 'Description of the merge request (Markdown rendered as HTML for caching)'
markdown_field :description_html, null: true
field :state, MergeRequestStateEnum, null: false,
description: 'State of the merge request'
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index b3c7c162bb3..0a9c0143945 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -9,6 +9,8 @@ module Types
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::MergeRequests::SetLabels
mount_mutation Mutations::MergeRequests::SetLocked
mount_mutation Mutations::MergeRequests::SetMilestone
@@ -21,6 +23,12 @@ module Types
mount_mutation Mutations::Notes::Update
mount_mutation Mutations::Notes::Destroy
mount_mutation Mutations::Todos::MarkDone
+ mount_mutation Mutations::Todos::Restore
+ mount_mutation Mutations::Todos::MarkAllDone
+ mount_mutation Mutations::Snippets::Destroy
+ mount_mutation Mutations::Snippets::Update
+ mount_mutation Mutations::Snippets::Create
+ mount_mutation Mutations::Snippets::MarkAsSpam
end
end
diff --git a/app/graphql/types/notes/diff_position_type.rb b/app/graphql/types/notes/diff_position_type.rb
index cab8c750dc0..654562da0a7 100644
--- a/app/graphql/types/notes/diff_position_type.rb
+++ b/app/graphql/types/notes/diff_position_type.rb
@@ -7,36 +7,38 @@ module Types
class DiffPositionType < BaseObject
graphql_name 'DiffPosition'
- field :diff_refs, Types::DiffRefsType, null: false # rubocop:disable Graphql/Descriptions
+ field :diff_refs, Types::DiffRefsType, null: false,
+ description: 'Information about the branch, HEAD, and base at the time of commenting'
field :file_path, GraphQL::STRING_TYPE, null: false,
- description: "The path of the file that was changed"
+ description: 'Path of the file that was changed'
field :old_path, GraphQL::STRING_TYPE, null: true,
- description: "The path of the file on the start sha."
+ description: 'Path of the file on the start SHA'
field :new_path, GraphQL::STRING_TYPE, null: true,
- description: "The path of the file on the head sha."
- field :position_type, Types::Notes::PositionTypeEnum, null: false # rubocop:disable Graphql/Descriptions
+ description: 'Path of the file on the HEAD SHA'
+ field :position_type, Types::Notes::PositionTypeEnum, null: false,
+ description: 'Type of file the position refers to'
# Fields for text positions
field :old_line, GraphQL::INT_TYPE, null: true,
- description: "The line on start sha that was changed",
+ description: 'Line on start SHA that was changed',
resolve: -> (position, _args, _ctx) { position.old_line if position.on_text? }
field :new_line, GraphQL::INT_TYPE, null: true,
- description: "The line on head sha that was changed",
+ description: 'Line on HEAD SHA that was changed',
resolve: -> (position, _args, _ctx) { position.new_line if position.on_text? }
# Fields for image positions
field :x, GraphQL::INT_TYPE, null: true,
- description: "The X postion on which the comment was made",
+ description: 'X position on which the comment was made',
resolve: -> (position, _args, _ctx) { position.x if position.on_image? }
field :y, GraphQL::INT_TYPE, null: true,
- description: "The Y position on which the comment was made",
+ description: 'Y position on which the comment was made',
resolve: -> (position, _args, _ctx) { position.y if position.on_image? }
field :width, GraphQL::INT_TYPE, null: true,
- description: "The total width of the image",
+ description: 'Total width of the image',
resolve: -> (position, _args, _ctx) { position.width if position.on_image? }
field :height, GraphQL::INT_TYPE, null: true,
- description: "The total height of the image",
+ description: 'Total height of the image',
resolve: -> (position, _args, _ctx) { position.height if position.on_image? }
end
# rubocop: enable Graphql/AuthorizeTypes
diff --git a/app/graphql/types/notes/discussion_type.rb b/app/graphql/types/notes/discussion_type.rb
index ab87f8280ac..74a233e9d26 100644
--- a/app/graphql/types/notes/discussion_type.rb
+++ b/app/graphql/types/notes/discussion_type.rb
@@ -7,10 +7,14 @@ module Types
authorize :read_note
- field :id, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions
- field :reply_id, GraphQL::ID_TYPE, null: false, description: 'The ID used to reply to this discussion'
- field :created_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions
- field :notes, Types::Notes::NoteType.connection_type, null: false, description: "All notes in the discussion"
+ field :id, GraphQL::ID_TYPE, null: false,
+ description: "ID of this discussion"
+ field :reply_id, GraphQL::ID_TYPE, null: false,
+ description: 'ID used to reply to this discussion'
+ field :created_at, Types::TimeType, null: false,
+ description: "Timestamp of the discussion's creation"
+ field :notes, Types::Notes::NoteType.connection_type, null: false,
+ description: 'All notes in the discussion'
# The gem we use to generate Global IDs is hard-coded to work with
# `id` properties. To generate a GID for the `reply_id` property,
diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb
index 4edf6ed90f7..b60fc96bd03 100644
--- a/app/graphql/types/notes/note_type.rb
+++ b/app/graphql/types/notes/note_type.rb
@@ -9,40 +9,48 @@ module Types
expose_permissions Types::PermissionTypes::Note
- field :id, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions
+ field :id, GraphQL::ID_TYPE, null: false,
+ description: 'ID of the note'
field :project, Types::ProjectType,
null: true,
- description: "The project this note is associated to",
+ description: 'Project associated with the note',
resolve: -> (note, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, note.project_id).find }
field :author, Types::UserType,
null: false,
- description: "The user who wrote this note",
+ description: 'User who wrote this note',
resolve: -> (note, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, note.author_id).find }
field :resolved_by, Types::UserType,
null: true,
- description: "The user that resolved the discussion",
+ description: 'User that resolved the discussion',
resolve: -> (note, _args, _context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, note.resolved_by_id).find }
field :system, GraphQL::BOOLEAN_TYPE,
null: false,
- description: "Whether or not this note was created by the system or by a user"
+ description: 'Indicates whether this note was created by the system or by a user'
field :body, GraphQL::STRING_TYPE,
null: false,
method: :note,
- description: "The content note itself"
+ description: 'Content of the note'
markdown_field :body_html, null: true, method: :note
- field :created_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions
- field :updated_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions
- field :discussion, Types::Notes::DiscussionType, null: true, description: "The discussion this note is a part of"
- field :resolvable, GraphQL::BOOLEAN_TYPE, null: false, method: :resolvable? # rubocop:disable Graphql/Descriptions
- field :resolved_at, Types::TimeType, null: true, description: "The time the discussion was resolved"
- field :position, Types::Notes::DiffPositionType, null: true, description: "The position of this note on a diff"
+ field :created_at, Types::TimeType, null: false,
+ description: 'Timestamp of the note creation'
+ field :updated_at, Types::TimeType, null: false,
+ description: "Timestamp of the note's last activity"
+ field :discussion, Types::Notes::DiscussionType, null: true,
+ description: 'The discussion this note is a part of'
+ field :resolvable, GraphQL::BOOLEAN_TYPE, null: false,
+ description: 'Indicates if this note can be resolved. That is, if it is a resolvable discussion or simply a standalone note',
+ method: :resolvable?
+ field :resolved_at, Types::TimeType, null: true,
+ description: "Timestamp of the note's resolution"
+ field :position, Types::Notes::DiffPositionType, null: true,
+ description: 'The position of this note on a diff'
end
end
end
diff --git a/app/graphql/types/notes/noteable_type.rb b/app/graphql/types/notes/noteable_type.rb
index ab4a170b123..2ac66452841 100644
--- a/app/graphql/types/notes/noteable_type.rb
+++ b/app/graphql/types/notes/noteable_type.rb
@@ -15,6 +15,8 @@ module Types
Types::IssueType
when MergeRequest
Types::MergeRequestType
+ when Snippet
+ Types::SnippetType
else
raise "Unknown GraphQL type for #{object}"
end
diff --git a/app/graphql/types/permission_types/project.rb b/app/graphql/types/permission_types/project.rb
index 3a6ba371154..2879dbd2b5c 100644
--- a/app/graphql/types/permission_types/project.rb
+++ b/app/graphql/types/permission_types/project.rb
@@ -10,13 +10,19 @@ module Types
:remove_pages, :read_project, :create_merge_request_in,
:read_wiki, :read_project_member, :create_issue, :upload_file,
:read_cycle_analytics, :download_code, :download_wiki_code,
- :fork_project, :create_project_snippet, :read_commit_status,
+ :fork_project, :read_commit_status,
:request_access, :create_pipeline, :create_pipeline_schedule,
:create_merge_request_from, :create_wiki, :push_code,
: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
+
+ permission_field :create_snippet
+
+ def create_snippet
+ Ability.allowed?(context[:current_user], :create_project_snippet, object)
+ end
end
end
end
diff --git a/app/graphql/types/permission_types/snippet.rb b/app/graphql/types/permission_types/snippet.rb
new file mode 100644
index 00000000000..0fc13c60983
--- /dev/null
+++ b/app/graphql/types/permission_types/snippet.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Types
+ module PermissionTypes
+ class Snippet < BasePermissionType
+ graphql_name 'SnippetPermissions'
+
+ abilities :create_note, :award_emoji
+
+ permission_field :read_snippet, method: :can_read_snippet?
+ permission_field :update_snippet, method: :can_update_snippet?
+ permission_field :admin_snippet, method: :can_admin_snippet?
+ permission_field :report_snippet, method: :can_report_as_spam?
+ end
+ end
+end
diff --git a/app/graphql/types/permission_types/user.rb b/app/graphql/types/permission_types/user.rb
new file mode 100644
index 00000000000..dba4de2dacc
--- /dev/null
+++ b/app/graphql/types/permission_types/user.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Types
+ module PermissionTypes
+ class User < BasePermissionType
+ graphql_name 'UserPermissions'
+
+ permission_field :create_snippet
+
+ def create_snippet
+ Ability.allowed?(context[:current_user], :create_personal_snippet)
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 73255021119..bd80ad7ff74 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -145,5 +145,19 @@ module Types
null: true,
description: 'Build pipelines of the project',
resolver: Resolvers::ProjectPipelinesResolver
+
+ field :sentry_detailed_error,
+ Types::ErrorTracking::SentryDetailedErrorType,
+ null: true,
+ description: 'Detailed version of a Sentry error on the project',
+ resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver
+
+ field :snippets,
+ Types::SnippetType.connection_type,
+ null: true,
+ description: 'Snippets of the project',
+ resolver: Resolvers::Projects::SnippetsResolver
end
end
+
+Types::ProjectType.prepend_if_ee('::EE::Types::ProjectType')
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 996bf225976..199a6226c6d 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -29,6 +29,14 @@ module Types
resolver: Resolvers::MetadataResolver,
description: 'Metadata about GitLab'
- field :echo, GraphQL::STRING_TYPE, null: false, resolver: Resolvers::EchoResolver # rubocop:disable Graphql/Descriptions
+ field :snippets,
+ Types::SnippetType.connection_type,
+ null: true,
+ resolver: Resolvers::SnippetsResolver,
+ description: 'Find Snippets visible to the current user'
+
+ field :echo, GraphQL::STRING_TYPE, null: false,
+ description: 'Text to echo back',
+ resolver: Resolvers::EchoResolver
end
end
diff --git a/app/graphql/types/root_storage_statistics_type.rb b/app/graphql/types/root_storage_statistics_type.rb
index a7498ee0a2e..3c471df072d 100644
--- a/app/graphql/types/root_storage_statistics_type.rb
+++ b/app/graphql/types/root_storage_statistics_type.rb
@@ -7,7 +7,7 @@ module Types
authorize :read_statistics
field :storage_size, GraphQL::INT_TYPE, null: false, description: 'The total storage in bytes'
- field :repository_size, GraphQL::INT_TYPE, null: false, description: 'The git repository size in bytes'
+ field :repository_size, GraphQL::INT_TYPE, null: false, description: 'The Git repository size in bytes'
field :lfs_objects_size, GraphQL::INT_TYPE, null: false, description: 'The LFS objects size in bytes'
field :build_artifacts_size, GraphQL::INT_TYPE, null: false, description: 'The CI artifacts size in bytes'
field :packages_size, GraphQL::INT_TYPE, null: false, description: 'The packages size in bytes'
diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb
new file mode 100644
index 00000000000..3f780528945
--- /dev/null
+++ b/app/graphql/types/snippet_type.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module Types
+ class SnippetType < BaseObject
+ graphql_name 'Snippet'
+ description 'Represents a snippet entry'
+
+ implements(Types::Notes::NoteableType)
+
+ present_using SnippetPresenter
+
+ authorize :read_snippet
+
+ expose_permissions Types::PermissionTypes::Snippet
+
+ field :id, GraphQL::ID_TYPE,
+ description: 'Id of the snippet',
+ null: false
+
+ field :title, GraphQL::STRING_TYPE,
+ description: 'Title of the snippet',
+ null: false
+
+ field :project, Types::ProjectType,
+ description: 'The project the snippet is associated with',
+ null: true,
+ authorize: :read_project,
+ resolve: -> (snippet, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, snippet.project_id).find }
+
+ field :author, Types::UserType,
+ description: 'The owner of the snippet',
+ null: false,
+ resolve: -> (snippet, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, snippet.author_id).find }
+
+ field :file_name, GraphQL::STRING_TYPE,
+ 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
+
+ field :visibility_level, Types::VisibilityLevelsEnum,
+ description: 'Visibility Level of the snippet',
+ null: false
+
+ field :created_at, Types::TimeType,
+ description: 'Timestamp this snippet was created',
+ null: false
+
+ field :updated_at, Types::TimeType,
+ description: 'Timestamp this snippet was updated',
+ null: false
+
+ field :web_url, type: GraphQL::STRING_TYPE,
+ description: 'Web URL of the snippet',
+ null: false
+
+ field :raw_url, type: GraphQL::STRING_TYPE,
+ description: 'Raw URL of the snippet',
+ null: false
+
+ markdown_field :description_html, null: true, method: :description
+ end
+end
diff --git a/app/graphql/types/snippets/type_enum.rb b/app/graphql/types/snippets/type_enum.rb
new file mode 100644
index 00000000000..243f05359db
--- /dev/null
+++ b/app/graphql/types/snippets/type_enum.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Types
+ module Snippets
+ class TypeEnum < BaseEnum
+ value 'personal', value: 'personal'
+ value 'project', value: 'project'
+ end
+ end
+end
diff --git a/app/graphql/types/snippets/visibility_scopes_enum.rb b/app/graphql/types/snippets/visibility_scopes_enum.rb
new file mode 100644
index 00000000000..5488e05b95d
--- /dev/null
+++ b/app/graphql/types/snippets/visibility_scopes_enum.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Types
+ module Snippets
+ class VisibilityScopesEnum < BaseEnum
+ value 'private', value: 'are_private'
+ value 'internal', value: 'are_internal'
+ value 'public', value: 'are_public'
+ end
+ end
+end
diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb
index b45c7893e75..3943c891335 100644
--- a/app/graphql/types/user_type.rb
+++ b/app/graphql/types/user_type.rb
@@ -8,6 +8,8 @@ module Types
present_using UserPresenter
+ expose_permissions Types::PermissionTypes::User
+
field :name, GraphQL::STRING_TYPE, null: false,
description: 'Human-readable name of the user'
field :username, GraphQL::STRING_TYPE, null: false,
@@ -19,5 +21,11 @@ module Types
field :todos, Types::TodoType.connection_type, null: false,
resolver: Resolvers::TodoResolver,
description: 'Todos of the user'
+
+ field :snippets,
+ Types::SnippetType.connection_type,
+ null: true,
+ description: 'Snippets authored by the user',
+ resolver: Resolvers::Users::SnippetsResolver
end
end
diff --git a/app/graphql/types/visibility_levels_enum.rb b/app/graphql/types/visibility_levels_enum.rb
new file mode 100644
index 00000000000..d5ace24455e
--- /dev/null
+++ b/app/graphql/types/visibility_levels_enum.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Types
+ class VisibilityLevelsEnum < BaseEnum
+ Gitlab::VisibilityLevel.string_options.each do |name, int_value|
+ value name.downcase, value: int_value
+ end
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 3ae804ff231..8389272fd35 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -94,6 +94,25 @@ module ApplicationHelper
sanitize(str, tags: %w(a span))
end
+ def body_data
+ {
+ page: body_data_page,
+ page_type_id: controller.params[:id],
+ find_file: find_file_path,
+ group: "#{@group&.path}"
+ }.merge(project_data)
+ end
+
+ def project_data
+ return {} unless @project
+
+ {
+ project_id: @project.id,
+ project: @project.path,
+ namespace_id: @project.namespace&.id
+ }
+ end
+
def body_data_page
[*controller.controller_path.split('/'), controller.action_name].compact.join(':')
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index a011209375e..71e4195c50f 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -232,6 +232,7 @@ module ApplicationSettingsHelper
:metrics_port,
:metrics_sample_interval,
:metrics_timeout,
+ :minimum_password_length,
:mirror_available,
:pages_domain_verification_enabled,
:password_authentication_enabled_for_web,
@@ -301,7 +302,8 @@ module ApplicationSettingsHelper
:snowplow_iglu_registry_url,
:push_event_hooks_limit,
:push_event_activities_limit,
- :custom_http_clone_url_root
+ :custom_http_clone_url_root,
+ :snippet_size_limit
]
end
diff --git a/app/helpers/award_emoji_helper.rb b/app/helpers/award_emoji_helper.rb
index 4bc5a7b090e..13df53a751b 100644
--- a/app/helpers/award_emoji_helper.rb
+++ b/app/helpers/award_emoji_helper.rb
@@ -7,7 +7,7 @@ module AwardEmojiHelper
if awardable.is_a?(Note)
# We render a list of notes very frequently and calling the specific method is a lot faster than the generic one (4.5x)
if awardable.for_personal_snippet?
- toggle_award_emoji_snippet_note_path(awardable.noteable, awardable)
+ gitlab_toggle_award_emoji_snippet_note_path(awardable.noteable, awardable)
else
toggle_award_emoji_project_note_path(@project, awardable.id)
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 912f0b61978..c9fb28d0299 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -141,7 +141,7 @@ module BlobHelper
if @build && @entry
raw_project_job_artifacts_url(@project, @build, path: @entry.path, **kwargs)
elsif @snippet
- reliable_raw_snippet_url(@snippet)
+ gitlab_raw_snippet_url(@snippet)
elsif @blob
project_raw_url(@project, @id, **kwargs)
end
@@ -215,14 +215,29 @@ module BlobHelper
return if blob.binary? || blob.stored_externally?
title = _('Open raw')
- link_to icon('file-code-o'), blob_raw_path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: title, data: { container: 'body' }
+ link_to sprite_icon('doc-code'),
+ external_storage_url_or_path(blob_raw_path),
+ class: 'btn btn-sm has-tooltip',
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ aria: { label: title },
+ title: title,
+ data: { container: 'body' }
end
def download_blob_button(blob)
return if blob.empty?
title = _('Download')
- link_to sprite_icon('download'), blob_raw_path(inline: false), download: @path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: title, data: { container: 'body' }
+ link_to sprite_icon('download'),
+ external_storage_url_or_path(blob_raw_path(inline: false)),
+ download: @path,
+ class: 'btn btn-sm has-tooltip',
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ aria: { label: title },
+ title: title,
+ data: { container: 'body' }
end
def blob_render_error_reason(viewer)
diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb
index 495c29d3e24..21e57a8d391 100644
--- a/app/helpers/broadcast_messages_helper.rb
+++ b/app/helpers/broadcast_messages_helper.rb
@@ -1,11 +1,15 @@
# frozen_string_literal: true
module BroadcastMessagesHelper
+ def current_broadcast_messages
+ BroadcastMessage.current(request.path)
+ end
+
def broadcast_message(message)
return unless message.present?
content_tag :div, dir: 'auto', class: 'broadcast-message', style: broadcast_message_style(message) do
- icon('bullhorn') << ' ' << render_broadcast_message(message)
+ sprite_icon('bullhorn', size: 16, css_class: 'vertical-align-text-top mr-2') << ' ' << render_broadcast_message(message)
end
end
diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb
index 0037c49f134..f55acad8517 100644
--- a/app/helpers/clusters_helper.rb
+++ b/app/helpers/clusters_helper.rb
@@ -9,11 +9,11 @@ module ClustersHelper
def create_new_cluster_label(provider: nil)
case provider
when 'aws'
- s_('ClusterIntegration|Create new Cluster on EKS')
+ s_('ClusterIntegration|Create new cluster on EKS')
when 'gcp'
- s_('ClusterIntegration|Create new Cluster on GKE')
+ s_('ClusterIntegration|Create new cluster on GKE')
else
- s_('ClusterIntegration|Create new Cluster')
+ s_('ClusterIntegration|Create new cluster')
end
end
diff --git a/app/helpers/container_expiration_policies_helper.rb b/app/helpers/container_expiration_policies_helper.rb
new file mode 100644
index 00000000000..17791e7b0ff
--- /dev/null
+++ b/app/helpers/container_expiration_policies_helper.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module ContainerExpirationPoliciesHelper
+ def cadence_options
+ ContainerExpirationPolicy.cadence_options.map do |key, val|
+ { key: key.to_s, label: val }
+ end
+ end
+
+ def keep_n_options
+ ContainerExpirationPolicy.keep_n_options.map do |key, val|
+ { key: key, label: val }
+ end
+ end
+
+ def older_than_options
+ ContainerExpirationPolicy.older_than_options.map do |key, val|
+ { key: key.to_s, label: val }
+ end
+ end
+end
diff --git a/app/helpers/conversational_development_index_helper.rb b/app/helpers/dev_ops_score_helper.rb
index 37e5bb325fb..9a673998149 100644
--- a/app/helpers/conversational_development_index_helper.rb
+++ b/app/helpers/dev_ops_score_helper.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-module ConversationalDevelopmentIndexHelper
+module DevOpsScoreHelper
def score_level(score)
if score < 33.33
'low'
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index 52ec2eadf5e..620a63fdc46 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -161,6 +161,18 @@ module DiffHelper
end
end
+ def render_overflow_warning?(diffs_collection)
+ diff_files = diffs_collection.raw_diff_files
+
+ if diff_files.any?(&:too_large?)
+ Gitlab::Metrics.add_event(:diffs_overflow_single_file_limits)
+ end
+
+ diff_files.overflow?.tap do |overflown|
+ Gitlab::Metrics.add_event(:diffs_overflow_collection_limits) if overflown
+ end
+ end
+
private
def diff_btn(title, name, selected)
@@ -203,12 +215,6 @@ module DiffHelper
link_to "#{hide_whitespace? ? 'Show' : 'Hide'} whitespace changes", url, class: options[:class]
end
- def render_overflow_warning?(diffs_collection)
- diffs = @merge_request_diff.presence || diffs_collection.diff_files
-
- diffs.overflow?
- end
-
def diff_file_path_text(diff_file, max: 60)
path = diff_file.new_path
diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index c244eba9e08..ba2330dfc9a 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -112,20 +112,20 @@ module EmailsHelper
end
end
- # "You are receiving this email because #{reason}"
+ # "You are receiving this email because #{reason} on #{gitlab_host}."
def notification_reason_text(reason)
- string = case reason
- when NotificationReason::OWN_ACTIVITY
- 'of your activity'
- when NotificationReason::ASSIGNED
- 'you have been assigned an item'
- when NotificationReason::MENTIONED
- 'you have been mentioned'
- else
- 'of your account'
- end
-
- "#{string} on #{Gitlab.config.gitlab.host}"
+ gitlab_host = Gitlab.config.gitlab.host
+
+ case reason
+ when NotificationReason::OWN_ACTIVITY
+ _("You're receiving this email because of your activity on %{host}.") % { host: gitlab_host }
+ when NotificationReason::ASSIGNED
+ _("You're receiving this email because you have been assigned an item on %{host}.") % { host: gitlab_host }
+ when NotificationReason::MENTIONED
+ _("You're receiving this email because you have been mentioned on %{host}.") % { host: gitlab_host }
+ else
+ _("You're receiving this email because of your account on %{host}.") % { host: gitlab_host }
+ end
end
def create_list_id_string(project, list_id_max_length = 255)
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index f57d0fa19d4..59972118ae3 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -26,6 +26,7 @@ module EnvironmentsHelper
"empty-getting-started-svg-path" => image_path('illustrations/monitoring/getting_started.svg'),
"empty-loading-svg-path" => image_path('illustrations/monitoring/loading.svg'),
"empty-no-data-svg-path" => image_path('illustrations/monitoring/no_data.svg'),
+ "empty-no-data-small-svg-path" => image_path('illustrations/chart-empty-state-small.svg'),
"empty-unable-to-connect-svg-path" => image_path('illustrations/monitoring/unable_to_connect.svg'),
"metrics-endpoint" => additional_metrics_project_environment_path(project, environment, format: :json),
"dashboard-endpoint" => metrics_dashboard_project_environment_path(project, environment, format: :json),
diff --git a/app/helpers/git_helper.rb b/app/helpers/git_helper.rb
index 5edc6dcf454..0fb37a69e56 100644
--- a/app/helpers/git_helper.rb
+++ b/app/helpers/git_helper.rb
@@ -1,8 +1,11 @@
# frozen_string_literal: true
module GitHelper
- def strip_gpg_signature(text)
- text.gsub(/-----BEGIN PGP SIGNATURE-----(.*)-----END PGP SIGNATURE-----/m, "")
+ def strip_signature(text)
+ text = text.gsub(/-----BEGIN PGP SIGNATURE-----(.*)-----END PGP SIGNATURE-----/m, "")
+ text = text.gsub(/-----BEGIN PGP MESSAGE-----(.*)-----END PGP MESSAGE-----/m, "")
+ text = text.gsub(/-----BEGIN SIGNED MESSAGE-----(.*)-----END SIGNED MESSAGE-----/m, "")
+ text
end
def short_sha(text)
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index 404ea7b00d4..78c41257404 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -193,6 +193,97 @@ module GitlabRoutingHelper
project = schedule.project
take_ownership_project_pipeline_schedule_path(project, schedule, *args)
end
+
+ def gitlab_snippet_path(snippet, *args)
+ if snippet.is_a?(ProjectSnippet)
+ project_snippet_path(snippet.project, snippet, *args)
+ else
+ new_args = snippet_query_params(snippet, *args)
+ snippet_path(snippet, *new_args)
+ end
+ end
+
+ def gitlab_snippet_url(snippet, *args)
+ if snippet.is_a?(ProjectSnippet)
+ project_snippet_url(snippet.project, snippet, *args)
+ else
+ new_args = snippet_query_params(snippet, *args)
+ snippet_url(snippet, *new_args)
+ end
+ end
+
+ def gitlab_raw_snippet_path(snippet, *args)
+ if snippet.is_a?(ProjectSnippet)
+ raw_project_snippet_path(snippet.project, snippet, *args)
+ else
+ new_args = snippet_query_params(snippet, *args)
+ raw_snippet_path(snippet, *new_args)
+ end
+ end
+
+ def gitlab_raw_snippet_url(snippet, *args)
+ if snippet.is_a?(ProjectSnippet)
+ raw_project_snippet_url(snippet.project, snippet, *args)
+ else
+ new_args = snippet_query_params(snippet, *args)
+ raw_snippet_url(snippet, *new_args)
+ end
+ end
+
+ def gitlab_snippet_notes_path(snippet, *args)
+ new_args = snippet_query_params(snippet, *args)
+ snippet_notes_path(snippet, *new_args)
+ end
+
+ def gitlab_snippet_notes_url(snippet, *args)
+ new_args = snippet_query_params(snippet, *args)
+ snippet_notes_url(snippet, *new_args)
+ end
+
+ def gitlab_snippet_note_path(snippet, note, *args)
+ new_args = snippet_query_params(snippet, *args)
+ snippet_note_path(snippet, note, *new_args)
+ end
+
+ def gitlab_snippet_note_url(snippet, note, *args)
+ new_args = snippet_query_params(snippet, *args)
+ snippet_note_url(snippet, note, *new_args)
+ end
+
+ def gitlab_toggle_award_emoji_snippet_note_path(snippet, note, *args)
+ new_args = snippet_query_params(snippet, *args)
+ toggle_award_emoji_snippet_note_path(snippet, note, *new_args)
+ end
+
+ def gitlab_toggle_award_emoji_snippet_note_url(snippet, note, *args)
+ new_args = snippet_query_params(snippet, *args)
+ toggle_award_emoji_snippet_note_url(snippet, note, *new_args)
+ end
+
+ def gitlab_toggle_award_emoji_snippet_path(snippet, *args)
+ new_args = snippet_query_params(snippet, *args)
+ toggle_award_emoji_snippet_path(snippet, *new_args)
+ end
+
+ def gitlab_toggle_award_emoji_snippet_url(snippet, *args)
+ new_args = snippet_query_params(snippet, *args)
+ toggle_award_emoji_snippet_url(snippet, *new_args)
+ end
+
+ private
+
+ def snippet_query_params(snippet, *args)
+ opts = case args.last
+ when Hash
+ args.pop
+ when ActionController::Parameters
+ args.pop.to_h
+ else
+ {}
+ end
+
+ args << opts
+ end
end
GitlabRoutingHelper.include_if_ee('EE::GitlabRoutingHelper')
diff --git a/app/helpers/hooks_helper.rb b/app/helpers/hooks_helper.rb
index c4b39939192..9466a37ed93 100644
--- a/app/helpers/hooks_helper.rb
+++ b/app/helpers/hooks_helper.rb
@@ -2,18 +2,40 @@
module HooksHelper
def link_to_test_hook(hook, trigger)
- path = case hook
- when ProjectHook
- project = hook.project
- test_project_hook_path(project, hook, trigger: trigger)
- when SystemHook
- test_admin_hook_path(hook, trigger: trigger)
- end
-
+ path = test_hook_path(hook, trigger)
trigger_human_name = trigger.to_s.tr('_', ' ').camelize
link_to path, rel: 'nofollow', method: :post do
content_tag(:span, trigger_human_name)
end
end
+
+ def test_hook_path(hook, trigger)
+ case hook
+ when ProjectHook
+ test_project_hook_path(hook.project, hook, trigger: trigger)
+ when SystemHook
+ test_admin_hook_path(hook, trigger: trigger)
+ end
+ end
+
+ def edit_hook_path(hook)
+ case hook
+ when ProjectHook
+ edit_project_hook_path(hook.project, hook)
+ when SystemHook
+ edit_admin_hook_path(hook)
+ end
+ end
+
+ def destroy_hook_path(hook)
+ case hook
+ when ProjectHook
+ project_hook_path(hook.project, hook)
+ when SystemHook
+ admin_hook_path(hook)
+ end
+ end
end
+
+HooksHelper.prepend_if_ee('EE::HooksHelper')
diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb
index 4f73270577f..876789e0d4a 100644
--- a/app/helpers/icons_helper.rb
+++ b/app/helpers/icons_helper.rb
@@ -42,11 +42,9 @@ module IconsHelper
end
def sprite_icon(icon_name, size: nil, css_class: nil)
- if Gitlab::Sentry.should_raise_for_dev?
- unless known_sprites.include?(icon_name)
- exception = ArgumentError.new("#{icon_name} is not a known icon in @gitlab-org/gitlab-svg")
- raise exception
- end
+ if known_sprites&.exclude?(icon_name)
+ exception = ArgumentError.new("#{icon_name} is not a known icon in @gitlab-org/gitlab-svg")
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(exception)
end
css_classes = []
@@ -158,6 +156,8 @@ module IconsHelper
private
def known_sprites
+ return if Rails.env.production?
+
@known_sprites ||= JSON.parse(File.read(Rails.root.join('node_modules/@gitlab/svgs/dist/icons.json')))['icons']
end
end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 3c72f41a4c9..8c75a4a13e8 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -279,19 +279,30 @@ module IssuablesHelper
initialDescriptionText: issuable.description,
initialTaskStatus: issuable.task_status
}
+ data.merge!(issue_only_initial_data(issuable))
+ data.merge!(path_data(parent))
+ data.merge!(updated_at_by(issuable))
- data[:hasClosingMergeRequest] = issuable.merge_requests_count(current_user) != 0 if issuable.is_a?(Issue)
- data[:zoomMeetingUrl] = ZoomMeeting.canonical_meeting_url(issuable) if issuable.is_a?(Issue)
+ data
+ end
- if parent.is_a?(Group)
- data[:groupPath] = parent.path
- else
- data.merge!(projectPath: ref_project.path, projectNamespace: ref_project.namespace.full_path)
- end
+ def issue_only_initial_data(issuable)
+ return {} unless issuable.is_a?(Issue)
- data.merge!(updated_at_by(issuable))
+ {
+ hasClosingMergeRequest: issuable.merge_requests_count(current_user) != 0,
+ zoomMeetingUrl: ZoomMeeting.canonical_meeting_url(issuable),
+ sentryIssueIdentifier: SentryIssue.find_by(issue: issuable)&.sentry_issue_identifier # rubocop:disable CodeReuse/ActiveRecord
+ }
+ end
- data
+ def path_data(parent)
+ return { groupPath: parent.path } if parent.is_a?(Group)
+
+ {
+ projectPath: ref_project.path,
+ projectNamespace: ref_project.namespace.full_path
+ }
end
def updated_at_by(issuable)
@@ -391,6 +402,10 @@ module IssuablesHelper
end
end
+ def issuable_templates_names(issuable)
+ issuable_templates(issuable).map { |template| template[:name] }
+ end
+
def selected_template(issuable)
params[:issuable_template] if issuable_templates(issuable).any? { |template| template[:name] == params[:issuable_template] }
end
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 6375513f514..34b6ba05a62 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -183,4 +183,4 @@ module IssuesHelper
module_function :url_for_tracker_issue
end
-IssuesHelper.include_if_ee('EE::IssuesHelper')
+IssuesHelper.prepend_if_ee('EE::IssuesHelper')
diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb
index 3a872622e73..0d3cf4d73fb 100644
--- a/app/helpers/labels_helper.rb
+++ b/app/helpers/labels_helper.rb
@@ -47,11 +47,11 @@ module LabelsHelper
end
end
- def render_label(label, tooltip: true, link: nil, css: nil)
+ def render_label(label, tooltip: true, link: nil, css: nil, dataset: nil)
# if scoped label is used then EE wraps label tag with scoped label
# doc link
html = render_colored_label(label, tooltip: tooltip)
- html = link_to(html, link, class: css) if link
+ html = link_to(html, link, class: css, data: dataset) if link
html
end
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index b8f6458b499..7940ec1162b 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
module MergeRequestsHelper
+ include Gitlab::Utils::StrongMemoize
+
def new_mr_path_from_push_event(event)
target_project = event.project.default_merge_request_target
project_new_merge_request_path(
@@ -27,6 +29,16 @@ module MergeRequestsHelper
classes.join(' ')
end
+ def state_name_with_icon(merge_request)
+ if merge_request.merged?
+ [_("Merged"), "git-merge"]
+ elsif merge_request.closed?
+ [_("Closed"), "close"]
+ else
+ [_("Open"), "issue-open-m"]
+ end
+ end
+
def ci_build_details_path(merge_request)
build_url = merge_request.source_project.ci_service.build_page(merge_request.diff_head_sha, merge_request.source_branch)
return unless build_url
@@ -76,7 +88,7 @@ module MergeRequestsHelper
def target_projects(project)
MergeRequestTargetProjectFinder.new(current_user: current_user, source_project: project)
- .execute
+ .execute(include_routes: true)
end
def merge_request_button_visibility(merge_request, closed)
@@ -158,6 +170,12 @@ module MergeRequestsHelper
current_user.fork_of(project)
end
end
+
+ def mr_tabs_position_enabled?
+ strong_memoize(:mr_tabs_position_enabled) do
+ Feature.enabled?(:mr_tabs_position, @project, default_enabled: true)
+ end
+ end
end
MergeRequestsHelper.prepend_if_ee('EE::MergeRequestsHelper')
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index 2ce45cec878..6013475acb1 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -87,7 +87,7 @@ module NavHelper
end
if Feature.enabled?(:user_mode_in_session)
- if current_user&.admin? && current_user_mode&.admin_mode?
+ if current_user_mode.admin_mode?
links << :admin_mode
end
end
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index fbbdebaa623..acf9f8c5b5b 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -95,7 +95,7 @@ module NotesHelper
def notes_url(params = {})
if @snippet.is_a?(PersonalSnippet)
- snippet_notes_path(@snippet, params)
+ gitlab_snippet_notes_path(@snippet, params)
else
params.merge!(target_id: @noteable.id, target_type: @noteable.class.name.underscore)
@@ -105,7 +105,7 @@ module NotesHelper
def note_url(note, project = @project)
if note.noteable.is_a?(PersonalSnippet)
- snippet_note_path(note.noteable, note)
+ gitlab_snippet_note_path(note.noteable, note)
else
project_note_path(project, note)
end
@@ -126,7 +126,7 @@ module NotesHelper
def new_form_url
return unless @snippet.is_a?(PersonalSnippet)
- snippet_notes_path(@snippet)
+ gitlab_snippet_notes_path(@snippet)
end
def can_create_note?
diff --git a/app/helpers/projects/error_tracking_helper.rb b/app/helpers/projects/error_tracking_helper.rb
index c31e16e7150..de21a78f5f0 100644
--- a/app/helpers/projects/error_tracking_helper.rb
+++ b/app/helpers/projects/error_tracking_helper.rb
@@ -18,6 +18,7 @@ module Projects::ErrorTrackingHelper
opts = [project, issue_id, { format: :json }]
{
+ 'project-issues-path' => project_issues_path(project),
'issue-details-path' => details_project_error_tracking_index_path(*opts),
'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 c68b6bdea0f..d683faf6a20 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -114,8 +114,10 @@ module ProjectsHelper
source = visible_fork_source(project)
if source
- _('This will remove the fork relationship between this project and %{fork_source}.') %
+ msg = _('This will remove the fork relationship between this project and %{fork_source}.') %
{ fork_source: link_to(source.full_name, project_path(source)) }
+
+ msg.html_safe
else
_('This will remove the fork relationship between this project and other projects in the fork network.')
end
@@ -195,6 +197,7 @@ module ProjectsHelper
"cross-project:#{can?(current_user, :read_cross_project)}",
max_project_member_access_cache_key(project),
pipeline_status,
+ Gitlab::I18n.locale,
'v2.6'
]
@@ -683,6 +686,7 @@ module ProjectsHelper
error_tracking
user
gcp
+ logs
]
end
@@ -696,4 +700,8 @@ module ProjectsHelper
def vue_file_list_enabled?
Feature.enabled?(:vue_file_list, @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
end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 777fe82e4c0..a89fea4b7b8 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -31,13 +31,14 @@ module SearchHelper
from = collection.offset_value + 1
to = collection.offset_value + collection.to_a.size
count = collection.total_count
+ term_element = "<span>&nbsp;<code>#{h(term)}</code>&nbsp;</span>".html_safe
search_entries_info_template(collection) % {
from: from,
to: to,
count: count,
scope: search_entries_scope_label(scope, count),
- term: term
+ term_element: term_element
}
end
@@ -72,9 +73,9 @@ module SearchHelper
def search_entries_info_template(collection)
if collection.total_pages > 1
- s_("SearchResults|Showing %{from} - %{to} of %{count} %{scope} for \"%{term}\"")
+ s_("SearchResults|Showing %{from} - %{to} of %{count} %{scope} for%{term_element}").html_safe
else
- s_("SearchResults|Showing %{count} %{scope} for \"%{term}\"")
+ s_("SearchResults|Showing %{count} %{scope} for%{term_element}").html_safe
end
end
diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb
index 19a27ba3499..caef6dba212 100644
--- a/app/helpers/services_helper.rb
+++ b/app/helpers/services_helper.rb
@@ -31,6 +31,26 @@ module ServicesHelper
"#{event}_events"
end
+ def service_event_action_field_name(action)
+ "#{action}_on_event_enabled"
+ end
+
+ def event_action_title(action)
+ case action
+ when "comment"
+ s_("ProjectService|Comment")
+ else
+ action.humanize
+ end
+ end
+
+ def event_action_description(action)
+ case action
+ when "comment"
+ s_("ProjectService|Comment will be posted on each event")
+ end
+ end
+
def service_save_button(service)
button_tag(class: 'btn btn-success', type: 'submit', disabled: service.deprecated?, data: { qa_selector: 'save_changes_button' }) do
icon('spinner spin', class: 'hidden js-btn-spinner') +
diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb
index 10e31fb8888..1c7690f30d2 100644
--- a/app/helpers/snippets_helper.rb
+++ b/app/helpers/snippets_helper.rb
@@ -11,33 +11,9 @@ module SnippetsHelper
end
end
- def reliable_snippet_path(snippet, opts = {})
- reliable_snippet_url(snippet, opts.merge(only_path: true))
- end
-
- def reliable_raw_snippet_path(snippet, opts = {})
- reliable_raw_snippet_url(snippet, opts.merge(only_path: true))
- end
-
- def reliable_snippet_url(snippet, opts = {})
- if snippet.project_id?
- project_snippet_url(snippet.project, snippet, nil, opts)
- else
- snippet_url(snippet, nil, opts)
- end
- end
-
- def reliable_raw_snippet_url(snippet, opts = {})
- if snippet.project_id?
- raw_project_snippet_url(snippet.project, snippet, nil, opts)
- else
- raw_snippet_url(snippet, nil, opts)
- end
- end
-
def download_raw_snippet_button(snippet)
link_to(icon('download'),
- reliable_raw_snippet_path(snippet, inline: false),
+ gitlab_raw_snippet_path(snippet, inline: false),
target: '_blank',
rel: 'noopener noreferrer',
class: "btn btn-sm has-tooltip",
@@ -133,7 +109,18 @@ module SnippetsHelper
end
def snippet_embed_tag(snippet)
- content_tag(:script, nil, src: reliable_snippet_url(snippet, format: :js, only_path: false))
+ content_tag(:script, nil, src: gitlab_snippet_url(snippet, format: :js))
+ end
+
+ def snippet_embed_input(snippet)
+ content_tag(:input,
+ nil,
+ type: :text,
+ readonly: true,
+ class: 'js-snippet-url-area snippet-embed-input form-control',
+ data: { url: gitlab_snippet_url(snippet) },
+ value: snippet_embed_tag(snippet),
+ autocomplete: 'off')
end
def snippet_badge(snippet)
@@ -158,7 +145,7 @@ module SnippetsHelper
return if blob.empty? || blob.binary? || blob.stored_externally?
link_to(external_snippet_icon('doc-code'),
- reliable_raw_snippet_url(@snippet),
+ gitlab_raw_snippet_url(@snippet),
class: 'btn',
target: '_blank',
rel: 'noopener noreferrer',
@@ -167,7 +154,7 @@ module SnippetsHelper
def embedded_snippet_download_button
link_to(external_snippet_icon('download'),
- reliable_raw_snippet_url(@snippet, inline: false),
+ gitlab_raw_snippet_url(@snippet, inline: false),
class: 'btn',
target: '_blank',
title: 'Download',
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index dce0842060d..0211a22a8c4 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -205,4 +205,4 @@ module TodosHelper
end
end
-TodosHelper.prepend_if_ee('EE::NotesHelper'); TodosHelper.prepend_if_ee('EE::TodosHelper') # rubocop: disable Style/Semicolon
+TodosHelper.prepend_if_ee('EE::TodosHelper')
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index fc25b78da93..af1919eeb40 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -158,7 +158,9 @@ module TreeHelper
def breadcrumb_data_attributes
attrs = {
can_collaborate: can_collaborate_with_project?(@project).to_s,
- new_blob_path: project_new_blob_path(@project, @id),
+ new_blob_path: project_new_blob_path(@project, @ref),
+ upload_path: project_create_blob_path(@project, @ref),
+ new_dir_path: project_create_dir_path(@project, @ref),
new_branch_path: new_project_branch_path(@project),
new_tag_path: new_project_tag_path(@project),
can_edit_tree: can_edit_tree?.to_s
diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb
index cae3ec5f8d0..11b78b8fd59 100644
--- a/app/helpers/user_callouts_helper.rb
+++ b/app/helpers/user_callouts_helper.rb
@@ -4,6 +4,7 @@ module UserCalloutsHelper
GKE_CLUSTER_INTEGRATION = 'gke_cluster_integration'
GCP_SIGNUP_OFFER = 'gcp_signup_offer'
SUGGEST_POPOVER_DISMISSED = 'suggest_popover_dismissed'
+ TABS_POSITION_HIGHLIGHT = 'tabs_position_highlight'
def show_gke_cluster_integration_callout?(project)
can?(current_user, :create_cluster, project) &&
@@ -25,6 +26,10 @@ module UserCalloutsHelper
!user_dismissed?(SUGGEST_POPOVER_DISMISSED)
end
+ def show_tabs_feature_highlight?
+ !user_dismissed?(TABS_POSITION_HIGHLIGHT) && !Rails.env.test?
+ end
+
private
def user_dismissed?(feature_name)
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index ef0cb8b4bcb..e87bb27cf62 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -44,6 +44,14 @@ module UsersHelper
current_user_menu_items.include?(item)
end
+ # Used to preload when you are rendering many projects and checking access
+ #
+ # rubocop: disable CodeReuse/ActiveRecord: `projects` can be array which also responds to pluck
+ def load_max_project_member_accesses(projects)
+ current_user&.max_member_access_for_project_ids(projects.pluck(:id))
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
def max_project_member_access(project)
current_user&.max_member_access_for_project(project.id) || Gitlab::Access::NO_ACCESS
end
@@ -57,7 +65,7 @@ module UsersHelper
unless user.association(:status).loaded?
exception = RuntimeError.new("Status was not preloaded")
- Gitlab::Sentry.track_exception(exception, extra: { user: user.inspect })
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(exception, user: user.inspect)
end
return unless user.status
diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb
index a1c8c3455b5..de70d0073b3 100644
--- a/app/mailers/emails/notes.rb
+++ b/app/mailers/emails/notes.rb
@@ -38,7 +38,7 @@ module Emails
setup_note_mail(note_id, recipient_id)
@snippet = @note.noteable
- @target_url = snippet_url(@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/profile.rb b/app/mailers/emails/profile.rb
index 2ea1aea1f51..441439444d5 100644
--- a/app/mailers/emails/profile.rb
+++ b/app/mailers/emails/profile.rb
@@ -32,5 +32,19 @@ module Emails
mail(to: @user.notification_email, subject: subject("GPG key was added to your account"))
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ def access_token_about_to_expire_email(user)
+ return unless user
+
+ @user = user
+ @target_url = profile_personal_access_tokens_url
+ @days_to_expire = PersonalAccessToken::DAYS_TO_EXPIRE
+
+ Gitlab::I18n.with_locale(@user.preferred_language) do
+ mail(to: @user.notification_email, subject: subject(_("Your Personal Access Tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire }))
+ end
+ end
end
end
+
+Emails::Profile.prepend_if_ee('EE::Emails::Profile')
diff --git a/app/models/active_session.rb b/app/models/active_session.rb
index 00192b1da59..3ecc3137157 100644
--- a/app/models/active_session.rb
+++ b/app/models/active_session.rb
@@ -4,6 +4,7 @@ class ActiveSession
include ActiveModel::Model
SESSION_BATCH_SIZE = 200
+ ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100
attr_accessor :created_at, :updated_at,
:session_id, :ip_address,
@@ -65,21 +66,22 @@ class ActiveSession
def self.destroy(user, session_id)
Gitlab::Redis::SharedState.with do |redis|
- redis.srem(lookup_key_name(user.id), session_id)
+ destroy_sessions(redis, user, [session_id])
+ end
+ end
- deleted_keys = redis.del(key_name(user.id, session_id))
+ def self.destroy_sessions(redis, user, session_ids)
+ key_names = session_ids.map {|session_id| key_name(user.id, session_id) }
+ session_names = session_ids.map {|session_id| "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}" }
- # only allow deleting the devise session if we could actually find a
- # related active session. this prevents another user from deleting
- # someone else's session.
- if deleted_keys > 0
- redis.del("#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}")
- end
- end
+ redis.srem(lookup_key_name(user.id), session_ids)
+ redis.del(key_names)
+ redis.del(session_names)
end
def self.cleanup(user)
Gitlab::Redis::SharedState.with do |redis|
+ clean_up_old_sessions(redis, user)
cleaned_up_lookup_entries(redis, user)
end
end
@@ -118,19 +120,40 @@ class ActiveSession
end
end
- def self.raw_active_session_entries(session_ids, user_id)
+ def self.raw_active_session_entries(redis, session_ids, user_id)
return [] if session_ids.empty?
- Gitlab::Redis::SharedState.with do |redis|
- entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) }
+ entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) }
+
+ redis.mget(entry_keys)
+ end
- redis.mget(entry_keys)
+ def self.active_session_entries(session_ids, user_id, redis)
+ return [] if session_ids.empty?
+
+ entry_keys = raw_active_session_entries(redis, session_ids, user_id)
+
+ entry_keys.compact.map do |raw_session|
+ Marshal.load(raw_session) # rubocop:disable Security/MarshalLoad
end
end
+ def self.clean_up_old_sessions(redis, user)
+ session_ids = session_ids_for_user(user.id)
+
+ return if session_ids.count <= ALLOWED_NUMBER_OF_ACTIVE_SESSIONS
+
+ # remove sessions if there are more than ALLOWED_NUMBER_OF_ACTIVE_SESSIONS.
+ sessions = active_session_entries(session_ids, user.id, redis)
+ sessions.sort_by! {|session| session.updated_at }.reverse!
+ sessions = sessions.drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS)
+ sessions = sessions.map { |session| session.session_id }
+ destroy_sessions(redis, user, sessions) if sessions.any?
+ end
+
def self.cleaned_up_lookup_entries(redis, user)
session_ids = session_ids_for_user(user.id)
- entries = raw_active_session_entries(session_ids, user.id)
+ entries = raw_active_session_entries(redis, session_ids, user.id)
# remove expired keys.
# only the single key entries are automatically expired by redis, the
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 72605af433f..456b6430088 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -6,12 +6,6 @@ class ApplicationSetting < ApplicationRecord
include TokenAuthenticatable
include ChronicDurationAttribute
- # Only remove this >= %12.6 and >= 2019-12-01
- self.ignored_columns += %i[
- pendo_enabled
- pendo_url
- ]
-
add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption, default_enabled: true) ? :optional : :required }
add_authentication_token_field :health_check_access_token
add_authentication_token_field :static_objects_external_storage_auth_token
@@ -52,6 +46,12 @@ class ApplicationSetting < ApplicationRecord
presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :minimum_password_length,
+ presence: true,
+ numericality: { only_integer: true,
+ greater_than_or_equal_to: DEFAULT_MINIMUM_PASSWORD_LENGTH,
+ less_than_or_equal_to: Devise.password_length.max }
+
validates :home_page_url,
allow_blank: true,
addressable_url: true,
@@ -229,6 +229,8 @@ class ApplicationSetting < ApplicationRecord
validates :push_event_activities_limit,
numericality: { greater_than_or_equal_to: 0 }
+ validates :snippet_size_limit, numericality: { only_integer: true, greater_than: 0 }
+
SUPPORTED_KEY_TYPES.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index 7bb89f0d1e2..98d8bb43b93 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -26,9 +26,12 @@ module ApplicationSettingImplementation
'/users',
'/users/confirmation',
'/unsubscribes/',
- '/import/github/personal_access_token'
+ '/import/github/personal_access_token',
+ '/admin/session'
].freeze
+ DEFAULT_MINIMUM_PASSWORD_LENGTH = 8
+
class_methods do
def defaults
{
@@ -105,6 +108,7 @@ module ApplicationSettingImplementation
sourcegraph_enabled: false,
sourcegraph_url: nil,
sourcegraph_public_only: true,
+ minimum_password_length: DEFAULT_MINIMUM_PASSWORD_LENGTH,
terminal_max_session_time: 0,
throttle_authenticated_api_enabled: false,
throttle_authenticated_api_period_in_seconds: 3600,
@@ -139,7 +143,8 @@ module ApplicationSettingImplementation
snowplow_app_id: nil,
snowplow_iglu_registry_url: nil,
custom_http_clone_url_root: nil,
- productivity_analytics_start_date: Time.now
+ productivity_analytics_start_date: Time.now,
+ snippet_size_limit: 50.megabytes
}
end
diff --git a/app/models/badge.rb b/app/models/badge.rb
index 50299cd6652..eb351425e66 100644
--- a/app/models/badge.rb
+++ b/app/models/badge.rb
@@ -22,6 +22,8 @@ class Badge < ApplicationRecord
scope :order_created_at_asc, -> { reorder(created_at: :asc) }
+ scope :with_name, ->(name) { where(name: name) }
+
validates :link_url, :image_url, addressable_url: true
validates :type, presence: true
diff --git a/app/models/blob.rb b/app/models/blob.rb
index cc089715b06..0a425f2b961 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -4,6 +4,7 @@
class Blob < SimpleDelegator
include Presentable
include BlobLanguageFromGitAttributes
+ include BlobActiveModel
CACHE_TIME = 60 # Cache raw blobs referred to by a (mutable) ref for 1 minute
CACHE_TIME_IMMUTABLE = 3600 # Cache blobs referred to by an immutable reference for 1 hour
@@ -26,6 +27,7 @@ class Blob < SimpleDelegator
BlobViewer::Markup,
BlobViewer::Notebook,
BlobViewer::SVG,
+ BlobViewer::OpenApi,
BlobViewer::Image,
BlobViewer::Sketch,
diff --git a/app/models/blob_viewer/open_api.rb b/app/models/blob_viewer/open_api.rb
new file mode 100644
index 00000000000..963b7336c8d
--- /dev/null
+++ b/app/models/blob_viewer/open_api.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module BlobViewer
+ class OpenApi < Base
+ include Rich
+ include ClientSide
+
+ self.partial_name = 'openapi'
+ self.file_types = %i(openapi)
+ self.binary = false
+ # TODO: get an icon for OpenAPI
+ self.switcher_icon = 'file-pdf-o'
+ self.switcher_title = 'OpenAPI'
+ end
+end
diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb
index dfcf28763ee..b3d72ebdcf3 100644
--- a/app/models/broadcast_message.rb
+++ b/app/models/broadcast_message.rb
@@ -9,6 +9,7 @@ class BroadcastMessage < ApplicationRecord
validates :message, presence: true
validates :starts_at, presence: true
validates :ends_at, presence: true
+ validates :broadcast_type, presence: true
validates :color, allow_blank: true, color: true
validates :font, allow_blank: true, color: true
@@ -17,35 +18,62 @@ class BroadcastMessage < ApplicationRecord
default_value_for :font, '#FFFFFF'
CACHE_KEY = 'broadcast_message_current_json'
+ BANNER_CACHE_KEY = 'broadcast_message_current_banner_json'
+ NOTIFICATION_CACHE_KEY = 'broadcast_message_current_notification_json'
after_commit :flush_redis_cache
- def self.current
- messages = cache.fetch(CACHE_KEY, as: BroadcastMessage, expires_in: cache_expires_in) do
- current_and_future_messages
+ enum broadcast_type: {
+ banner: 1,
+ notification: 2
+ }
+
+ class << self
+ def current_banner_messages(current_path = nil)
+ fetch_messages BANNER_CACHE_KEY, current_path do
+ current_and_future_messages.banner
+ end
end
- return [] unless messages&.present?
+ def current_notification_messages(current_path = nil)
+ fetch_messages NOTIFICATION_CACHE_KEY, current_path do
+ current_and_future_messages.notification
+ end
+ end
- now_or_future = messages.select(&:now_or_future?)
+ def current(current_path = nil)
+ fetch_messages CACHE_KEY, current_path do
+ current_and_future_messages
+ end
+ end
- # If there are cached entries but none are to be displayed we'll purge the
- # cache so we don't keep running this code all the time.
- cache.expire(CACHE_KEY) if now_or_future.empty?
+ def current_and_future_messages
+ where('ends_at > :now', now: Time.current).order_id_asc
+ end
- now_or_future.select(&:now?)
- end
+ def cache
+ Gitlab::JsonCache.new(cache_key_with_version: false)
+ end
- def self.current_and_future_messages
- where('ends_at > :now', now: Time.zone.now).order_id_asc
- end
+ def cache_expires_in
+ 2.weeks
+ end
- def self.cache
- Gitlab::JsonCache.new(cache_key_with_version: false)
- end
+ private
- def self.cache_expires_in
- 2.weeks
+ def fetch_messages(cache_key, current_path)
+ messages = cache.fetch(cache_key, as: BroadcastMessage, expires_in: cache_expires_in) do
+ yield
+ end
+
+ now_or_future = messages.select(&:now_or_future?)
+
+ # If there are cached entries but none are to be displayed we'll purge the
+ # cache so we don't keep running this code all the time.
+ cache.expire(cache_key) if now_or_future.empty?
+
+ now_or_future.select(&:now?).select { |message| message.matches_current_path(current_path) }
+ end
end
def active?
@@ -53,27 +81,35 @@ class BroadcastMessage < ApplicationRecord
end
def started?
- Time.zone.now >= starts_at
+ Time.current >= starts_at
end
def ended?
- ends_at < Time.zone.now
+ ends_at < Time.current
end
def now?
- (starts_at..ends_at).cover?(Time.zone.now)
+ (starts_at..ends_at).cover?(Time.current)
end
def future?
- starts_at > Time.zone.now
+ starts_at > Time.current
end
def now_or_future?
now? || future?
end
+ def matches_current_path(current_path)
+ return true if current_path.blank? || target_path.blank?
+
+ current_path.match(Regexp.escape(target_path).gsub('\\*', '.*'))
+ end
+
def flush_redis_cache
- self.class.cache.expire(CACHE_KEY)
+ [CACHE_KEY, BANNER_CACHE_KEY, NOTIFICATION_CACHE_KEY].each do |key|
+ self.class.cache.expire(key)
+ end
end
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 49c5b67d600..7e7c580a48e 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -13,17 +13,11 @@ module Ci
include Importable
include Gitlab::Utils::StrongMemoize
include HasRef
+ include IgnorableColumns
BuildArchivedError = Class.new(StandardError)
- self.ignored_columns += %i[
- artifacts_file
- artifacts_file_store
- artifacts_metadata
- artifacts_metadata_store
- artifacts_size
- commands
- ]
+ ignore_columns :artifacts_file, :artifacts_file_store, :artifacts_metadata, :artifacts_metadata_store, :artifacts_size, :commands, remove_after: '2019-12-15', remove_with: '12.7'
belongs_to :project, inverse_of: :builds
belongs_to :runner
@@ -120,6 +114,20 @@ module Ci
scope :eager_load_job_artifacts, -> { includes(:job_artifacts) }
+ scope :eager_load_everything, -> do
+ includes(
+ [
+ { pipeline: [:project, :user] },
+ :job_artifacts_archive,
+ :metadata,
+ :trigger_request,
+ :project,
+ :user,
+ :tags
+ ]
+ )
+ end
+
scope :with_exposed_artifacts, -> do
joins(:metadata).merge(Ci::BuildMetadata.with_exposed_artifacts)
.includes(:metadata, :job_artifacts_metadata)
@@ -161,6 +169,7 @@ module Ci
end
scope :queued_before, ->(time) { where(arel_table[:queued_at].lt(time)) }
+ scope :order_id_desc, -> { order('ci_builds.id DESC') }
acts_as_taggable
@@ -247,10 +256,11 @@ module Ci
end
after_transition pending: :running do |build|
- build.pipeline.persistent_ref.create
build.deployment&.run
build.run_after_commit do
+ build.pipeline.persistent_ref.create
+
BuildHooksWorker.perform_async(id)
end
end
@@ -277,7 +287,7 @@ module Ci
begin
build.deployment.drop!
rescue => e
- Gitlab::Sentry.track_exception(e, extra: { build_id: build.id })
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e, build_id: build.id)
end
true
@@ -415,6 +425,18 @@ module Ci
end
end
+ def expanded_kubernetes_namespace
+ return unless has_environment?
+
+ namespace = options.dig(:environment, :kubernetes, :namespace)
+
+ if namespace.present?
+ strong_memoize(:expanded_kubernetes_namespace) do
+ ExpandVariables.expand(namespace, -> { simple_variables })
+ end
+ end
+ end
+
def has_environment?
environment.present?
end
@@ -640,9 +662,8 @@ module Ci
def execute_hooks
return unless project
- build_data = Gitlab::DataBuilder::Build.build(self)
- project.execute_hooks(build_data.dup, :job_hooks)
- project.execute_services(build_data.dup, :job_hooks)
+ project.execute_hooks(build_data.dup, :job_hooks) if project.has_active_hooks?(:job_hooks)
+ project.execute_services(build_data.dup, :job_hooks) if project.has_active_services?(:job_hooks)
end
def browsable_artifacts?
@@ -741,6 +762,10 @@ module Ci
Gitlab::Ci::Build::Credentials::Factory.new(self).create!
end
+ def all_dependencies
+ (dependencies + cross_dependencies).uniq
+ end
+
def dependencies
return [] if empty_dependencies?
@@ -748,7 +773,7 @@ module Ci
# find all jobs that are needed
if Feature.enabled?(:ci_dag_support, project, default_enabled: true) && needs.exists?
- depended_jobs = depended_jobs.where(name: needs.select(:name))
+ depended_jobs = depended_jobs.where(name: needs.artifacts.select(:name))
end
# find all jobs that are dependent on
@@ -756,9 +781,15 @@ module Ci
depended_jobs = depended_jobs.where(name: options[:dependencies])
end
+ # if both needs and dependencies are used,
+ # the end result will be an intersection between them
depended_jobs
end
+ def cross_dependencies
+ []
+ end
+
def empty_dependencies?
options[:dependencies]&.empty?
end
@@ -849,6 +880,10 @@ module Ci
private
+ def build_data
+ @build_data ||= Gitlab::DataBuilder::Build.build(self)
+ end
+
def successful_deployment_status
if deployment&.last?
:last
@@ -860,7 +895,7 @@ module Ci
def each_report(report_types)
job_artifacts_for_types(report_types).each do |report_artifact|
report_artifact.each_blob do |blob|
- yield report_artifact.file_type, blob
+ yield report_artifact.file_type, blob, report_artifact
end
end
end
diff --git a/app/models/ci/build_need.rb b/app/models/ci/build_need.rb
index 6531dfd332f..0b243c20e67 100644
--- a/app/models/ci/build_need.rb
+++ b/app/models/ci/build_need.rb
@@ -10,5 +10,6 @@ module Ci
validates :name, presence: true, length: { maximum: 128 }
scope :scoped_build, -> { where('ci_builds.id=ci_build_needs.build_id') }
+ scope :artifacts, -> { where(artifacts: true) }
end
end
diff --git a/app/models/ci/build_trace_section.rb b/app/models/ci/build_trace_section.rb
index 7fe6b753da1..8be42eb48d6 100644
--- a/app/models/ci/build_trace_section.rb
+++ b/app/models/ci/build_trace_section.rb
@@ -4,9 +4,6 @@ module Ci
class BuildTraceSection < ApplicationRecord
extend Gitlab::Ci::Model
- # Only remove > 2019-11-22 and > 12.5
- self.ignored_columns += %i[id]
-
belongs_to :build, class_name: 'Ci::Build'
belongs_to :project
belongs_to :section_name, class_name: 'Ci::BuildTraceSectionName'
diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb
index 2fd369c9aff..0a67a652e22 100644
--- a/app/models/ci/legacy_stage.rb
+++ b/app/models/ci/legacy_stage.rb
@@ -5,6 +5,7 @@ module Ci
# We should migrate this object to actual database record in the future
class LegacyStage
include StaticModel
+ include Presentable
attr_reader :pipeline, :name
diff --git a/app/models/ci/persistent_ref.rb b/app/models/ci/persistent_ref.rb
index be3d4aa3203..76139f5d676 100644
--- a/app/models/ci/persistent_ref.rb
+++ b/app/models/ci/persistent_ref.rb
@@ -14,31 +14,41 @@ module Ci
delegate :ref_exists?, :create_ref, :delete_refs, to: :repository
def exist?
+ return unless enabled?
+
ref_exists?(path)
rescue
false
end
def create
- return if exist?
+ return unless enabled?
create_ref(sha, path)
rescue => e
- Gitlab::Sentry
- .track_acceptable_exception(e, extra: { pipeline_id: pipeline.id })
+ Gitlab::ErrorTracking
+ .track_exception(e, pipeline_id: pipeline.id)
end
def delete
+ return unless enabled?
+
delete_refs(path)
rescue Gitlab::Git::Repository::NoRepository
# no-op
rescue => e
- Gitlab::Sentry
- .track_acceptable_exception(e, extra: { pipeline_id: pipeline.id })
+ Gitlab::ErrorTracking
+ .track_exception(e, pipeline_id: pipeline.id)
end
def path
"refs/#{Repository::REF_PIPELINES}/#{pipeline.id}"
end
+
+ private
+
+ def enabled?
+ Feature.enabled?(:depend_on_persistent_pipeline_ref, project, default_enabled: true)
+ end
end
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index f730b949ee9..29ec41ef1a1 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -14,6 +14,7 @@ module Ci
include HasRef
include ShaAttribute
include FromUnion
+ include UpdatedAtFilterable
sha_attribute :source_sha
sha_attribute :target_sha
@@ -204,15 +205,7 @@ module Ci
end
scope :internal, -> { where(source: internal_sources) }
- scope :ci_sources, -> { where(config_source: ci_sources_values) }
-
- scope :sort_by_merge_request_pipelines, -> do
- sql = 'CASE ci_pipelines.source WHEN (?) THEN 0 ELSE 1 END, ci_pipelines.id DESC'
- query = ApplicationRecord.send(:sanitize_sql_array, [sql, sources[:merge_request_event]]) # rubocop:disable GitlabSecurity/PublicSend
-
- order(Arel.sql(query))
- end
-
+ scope :ci_sources, -> { where(config_source: ::Ci::PipelineEnums.ci_config_sources_values) }
scope :for_user, -> (user) { where(user: user) }
scope :for_sha, -> (sha) { where(sha: sha) }
scope :for_source_sha, -> (source_sha) { where(source_sha: source_sha) }
@@ -221,22 +214,6 @@ module Ci
scope :for_id, -> (id) { where(id: id) }
scope :created_after, -> (time) { where('ci_pipelines.created_at > ?', time) }
- scope :triggered_by_merge_request, -> (merge_request) do
- where(source: :merge_request_event, merge_request: merge_request)
- end
-
- scope :detached_merge_request_pipelines, -> (merge_request, sha) do
- triggered_by_merge_request(merge_request).for_sha(sha)
- end
-
- scope :merge_request_pipelines, -> (merge_request, source_sha) do
- triggered_by_merge_request(merge_request).for_source_sha(source_sha)
- end
-
- scope :triggered_for_branch, -> (ref) do
- where(source: branch_pipeline_sources).where(ref: ref, tag: false)
- end
-
scope :with_reports, -> (reports_scope) do
where('EXISTS (?)', ::Ci::Build.latest.with_reports(reports_scope).where('ci_pipelines.id=ci_builds.commit_id').select(1))
end
@@ -323,11 +300,6 @@ module Ci
end
end
- def self.latest_for_shas(shas)
- max_id_per_sha = for_sha(shas).group(:sha).select("max(id)")
- where(id: max_id_per_sha)
- end
-
def self.latest_successful_ids_per_project
success.group(:project_id).select('max(id) as id')
end
@@ -344,14 +316,6 @@ module Ci
sources.reject { |source| source == "external" }.values
end
- def self.branch_pipeline_sources
- @branch_pipeline_sources ||= sources.reject { |source| source == 'merge_request_event' }.values
- end
-
- def self.ci_sources_values
- config_sources.values_at(:repository_source, :auto_devops_source, :unknown_source)
- end
-
def self.bridgeable_statuses
::Ci::Pipeline::AVAILABLE_STATUSES - %w[created preparing pending]
end
@@ -478,6 +442,10 @@ module Ci
end
end
+ def before_sha
+ super || Gitlab::Git::BLANK_SHA
+ end
+
def short_sha
Ci::Pipeline.truncate_sha(sha)
end
@@ -534,6 +502,10 @@ module Ci
builds.skipped.after_stage(stage_idx).find_each(&:process)
end
+ def child?
+ false
+ end
+
def latest?
return false unless git_ref && commit.present?
@@ -599,12 +571,6 @@ module Ci
project.notes.for_commit_id(sha)
end
- # rubocop: disable CodeReuse/ServiceClass
- def process!(trigger_build_ids = nil)
- Ci::ProcessPipelineService.new(project, user).execute(self, trigger_build_ids)
- end
- # rubocop: enable CodeReuse/ServiceClass
-
def update_status
retry_optimistic_lock(self) do
new_status = latest_builds_status.to_s
@@ -646,12 +612,11 @@ module Ci
def predefined_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
variables.append(key: 'CI_PIPELINE_IID', value: iid.to_s)
- variables.append(key: 'CI_CONFIG_PATH', value: config_path)
variables.append(key: 'CI_PIPELINE_SOURCE', value: source.to_s)
- variables.append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message.to_s)
- variables.append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title.to_s)
- variables.append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description.to_s)
- variables.append(key: 'CI_COMMIT_REF_PROTECTED', value: (!!protected_ref?).to_s)
+
+ variables.append(key: 'CI_CONFIG_PATH', value: config_path)
+
+ variables.concat(predefined_commit_variables)
if merge_request_event? && merge_request
variables.append(key: 'CI_MERGE_REQUEST_EVENT_TYPE', value: merge_request_event_type.to_s)
@@ -666,6 +631,29 @@ module Ci
end
end
+ def predefined_commit_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ variables.append(key: 'CI_COMMIT_SHA', value: sha)
+ variables.append(key: 'CI_COMMIT_SHORT_SHA', value: short_sha)
+ variables.append(key: 'CI_COMMIT_BEFORE_SHA', value: before_sha)
+ variables.append(key: 'CI_COMMIT_REF_NAME', value: source_ref)
+ variables.append(key: 'CI_COMMIT_REF_SLUG', value: source_ref_slug)
+ variables.append(key: 'CI_COMMIT_BRANCH', value: ref) if branch?
+ variables.append(key: 'CI_COMMIT_TAG', value: ref) if tag?
+ variables.append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message.to_s)
+ variables.append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title.to_s)
+ variables.append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description.to_s)
+ variables.append(key: 'CI_COMMIT_REF_PROTECTED', value: (!!protected_ref?).to_s)
+
+ # legacy variables
+ variables.append(key: 'CI_BUILD_REF', value: sha)
+ variables.append(key: 'CI_BUILD_BEFORE_SHA', value: before_sha)
+ variables.append(key: 'CI_BUILD_REF_NAME', value: source_ref)
+ variables.append(key: 'CI_BUILD_REF_SLUG', value: source_ref_slug)
+ variables.append(key: 'CI_BUILD_TAG', value: ref) if tag?
+ end
+ end
+
def queued_duration
return unless started_at
@@ -781,18 +769,10 @@ module Ci
triggered_by_merge_request? && target_sha.present?
end
- def merge_train_pipeline?
- merge_request_pipeline? && merge_train_ref?
- end
-
def merge_request_ref?
MergeRequest.merge_request_ref?(ref)
end
- def merge_train_ref?
- MergeRequest.merge_train_ref?(ref)
- end
-
def matches_sha_or_source_sha?(sha)
self.sha == sha || self.source_sha == sha
end
@@ -825,9 +805,7 @@ module Ci
return unless merge_request_event?
strong_memoize(:merge_request_event_type) do
- if merge_train_pipeline?
- :merge_train
- elsif merge_request_pipeline?
+ if merge_request_pipeline?
:merged_result
elsif detached_merge_request_pipeline?
:detached
@@ -839,6 +817,10 @@ module Ci
@persistent_ref ||= PersistentRef.new(pipeline: self)
end
+ def find_successful_build_ids_by_names(names)
+ statuses.latest.success.where(name: names).pluck(:id)
+ end
+
private
def pipeline_data
diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb
index 859abc4a0d5..3cd88807969 100644
--- a/app/models/ci/pipeline_enums.rb
+++ b/app/models/ci/pipeline_enums.rb
@@ -7,7 +7,8 @@ module Ci
def self.failure_reasons
{
unknown_failure: 0,
- config_error: 1
+ config_error: 1,
+ external_validation_failure: 2
}
end
@@ -35,9 +36,20 @@ module Ci
{
unknown_source: nil,
repository_source: 1,
- auto_devops_source: 2
+ auto_devops_source: 2,
+ remote_source: 4,
+ external_project_source: 5
}
end
+
+ def self.ci_config_sources_values
+ config_sources.values_at(
+ :unknown_source,
+ :repository_source,
+ :auto_devops_source,
+ :remote_source,
+ :external_project_source)
+ end
end
end
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index c4a4410e8fc..3f409b8bb22 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -8,6 +8,7 @@ module Ci
include ChronicDurationAttribute
include FromUnion
include TokenAuthenticatable
+ include IgnorableColumns
add_authentication_token_field :token, encrypted: -> { Feature.enabled?(:ci_runners_tokens_optional_encryption, default_enabled: true) ? :optional : :required }
@@ -35,7 +36,7 @@ module Ci
FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze
- self.ignored_columns += %i[is_shared]
+ ignore_column :is_shared, remove_after: '2019-12-15', remove_with: '12.6'
has_many :builds
has_many :runner_projects, inverse_of: :runner, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb
index 8589f8c00cb..9854ad2ea3e 100644
--- a/app/models/clusters/applications/elastic_stack.rb
+++ b/app/models/clusters/applications/elastic_stack.rb
@@ -71,6 +71,8 @@ module Clusters
# `proxy_url` could raise an exception because gitlab can not communicate with the cluster.
# We check for a nil client in downstream use and behaviour is equivalent to an empty state
log_exception(error, :failed_to_create_elasticsearch_client)
+
+ nil
end
end
diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb
index 1093efee85a..387503bee54 100644
--- a/app/models/clusters/applications/knative.rb
+++ b/app/models/clusters/applications/knative.rb
@@ -3,25 +3,27 @@
module Clusters
module Applications
class Knative < ApplicationRecord
- VERSION = '0.7.0'
+ VERSION = '0.9.0'
REPOSITORY = 'https://storage.googleapis.com/triggermesh-charts'
METRICS_CONFIG = 'https://storage.googleapis.com/triggermesh-charts/istio-metrics.yaml'
FETCH_IP_ADDRESS_DELAY = 30.seconds
- API_RESOURCES_PATH = 'config/knative/api_resources.yml'
+ API_GROUPS_PATH = 'config/knative/api_groups.yml'
self.table_name = 'clusters_applications_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
include ::Clusters::Concerns::ApplicationVersion
include ::Clusters::Concerns::ApplicationData
include AfterCommitQueue
+ alias_method :original_set_initial_status, :set_initial_status
def set_initial_status
- return unless not_installable?
- return unless verify_cluster?
+ return unless cluster&.platform_kubernetes_rbac?
- self.status = status_states[:installable]
+ original_set_initial_status
end
state_machine :status do
@@ -109,15 +111,15 @@ module Clusters
end
def delete_knative_and_istio_crds
- api_resources.map do |crd|
- Gitlab::Kubernetes::KubectlCmd.delete("--ignore-not-found", "crd", "#{crd}")
+ api_groups.map do |group|
+ Gitlab::Kubernetes::KubectlCmd.delete_crds_from_group(group)
end
end
# returns an array of CRDs to be postdelete since helm does not
# manage the CRDs it creates.
- def api_resources
- @api_resources ||= YAML.safe_load(File.read(Rails.root.join(API_RESOURCES_PATH)))
+ def api_groups
+ @api_groups ||= YAML.safe_load(File.read(Rails.root.join(API_GROUPS_PATH)))
end
def install_knative_metrics
@@ -131,10 +133,6 @@ module Clusters
[Gitlab::Kubernetes::KubectlCmd.delete("--ignore-not-found", "-f", METRICS_CONFIG)]
end
-
- def verify_cluster?
- cluster&.application_helm_available? && cluster&.platform_kubernetes_rbac?
- end
end
end
end
diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb
index 5e7fdd55cb6..4ac33d4e3be 100644
--- a/app/models/clusters/applications/prometheus.rb
+++ b/app/models/clusters/applications/prometheus.rb
@@ -13,15 +13,21 @@ module Clusters
include ::Clusters::Concerns::ApplicationStatus
include ::Clusters::Concerns::ApplicationVersion
include ::Clusters::Concerns::ApplicationData
+ include AfterCommitQueue
default_value_for :version, VERSION
- after_destroy :disable_prometheus_integration
+ after_destroy do
+ run_after_commit do
+ disable_prometheus_integration
+ end
+ end
state_machine :status do
after_transition any => [:installed] do |application|
- application.cluster.projects.each do |project|
- project.find_or_initialize_service('prometheus').update!(active: true)
+ application.run_after_commit do
+ Clusters::Applications::ActivateServiceWorker
+ .perform_async(application.cluster_id, ::PrometheusService.to_param) # rubocop:disable CodeReuse/ServiceClass
end
end
end
@@ -49,10 +55,10 @@ module Clusters
)
end
- def upgrade_command(values)
- ::Gitlab::Kubernetes::Helm::InstallCommand.new(
+ def patch_command(values)
+ ::Gitlab::Kubernetes::Helm::PatchCommand.new(
name: name,
- version: VERSION,
+ version: version,
rbac: cluster.platform_kubernetes_rbac?,
chart: chart,
files: files_with_replaced_values(values)
@@ -84,19 +90,22 @@ module Clusters
# ensures headers containing auth data are appended to original k8s client options
options = kube_client.rest_client.options.merge(headers: kube_client.headers)
Gitlab::PrometheusClient.new(proxy_url, options)
- rescue Kubeclient::HttpError
+ rescue Kubeclient::HttpError, Errno::ECONNRESET, Errno::ECONNREFUSED
# If users have mistakenly set parameters or removed the depended clusters,
# `proxy_url` could raise an exception because gitlab can not communicate with the cluster.
# Since `PrometheusAdapter#can_query?` is eargely loaded on environement pages in gitlab,
# we need to silence the exceptions
end
+ def configured?
+ kube_client.present? && available?
+ end
+
private
def disable_prometheus_integration
- cluster.projects.each do |project|
- project.prometheus_service&.update!(active: false)
- end
+ ::Clusters::Applications::DeactivateServiceWorker
+ .perform_async(cluster_id, ::PrometheusService.to_param) # rubocop:disable CodeReuse/ServiceClass
end
def kube_client
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index 37ba8a7c97e..fd05fd6bab9 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.10.1'
+ VERSION = '0.11.0'
self.table_name = 'clusters_applications_runners'
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index f522f3f2fdb..d2eee78f3df 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -23,6 +23,7 @@ module Clusters
}.freeze
DEFAULT_ENVIRONMENT = '*'
KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN'
+ APPLICATIONS_ASSOCIATIONS = APPLICATIONS.values.map(&:association_name).freeze
belongs_to :user
belongs_to :management_project, class_name: '::Project', optional: true
@@ -33,6 +34,7 @@ module Clusters
has_many :cluster_groups, class_name: 'Clusters::Group'
has_many :groups, through: :cluster_groups, class_name: '::Group'
+ has_many :groups_projects, through: :groups, source: :projects, class_name: '::Project'
# we force autosave to happen when we save `Cluster` model
has_one :provider_gcp, class_name: 'Clusters::Providers::Gcp', autosave: true
@@ -117,7 +119,7 @@ module Clusters
scope :aws_installed, -> { aws_provided.joins(:provider_aws).merge(Clusters::Providers::Aws.with_status(:created)) }
scope :managed, -> { where(managed: true) }
-
+ scope :with_persisted_applications, -> { eager_load(*APPLICATIONS_ASSOCIATIONS) }
scope :default_environment, -> { where(environment_scope: DEFAULT_ENVIRONMENT) }
scope :for_project_namespace, -> (namespace_id) { joins(:projects).where(projects: { namespace_id: namespace_id }) }
@@ -176,6 +178,13 @@ module Clusters
end
end
+ def all_projects
+ return projects if project_type?
+ return groups_projects if group_type?
+
+ ::Project.all
+ end
+
def status_name
return cleanup_status_name if cleanup_errored?
return :cleanup_ongoing unless cleanup_not_started?
@@ -195,9 +204,13 @@ module Clusters
{ connection_status: retrieve_connection_status }
end
+ def persisted_applications
+ APPLICATIONS_ASSOCIATIONS.map(&method(:public_send)).compact
+ end
+
def applications
- APPLICATIONS.values.map do |application_class|
- public_send(application_class.association_name) || public_send("build_#{application_class.association_name}") # rubocop:disable GitlabSecurity/PublicSend
+ APPLICATIONS_ASSOCIATIONS.map do |association_name|
+ public_send(association_name) || public_send("build_#{association_name}") # rubocop:disable GitlabSecurity/PublicSend
end
end
@@ -236,14 +249,9 @@ module Clusters
end
def kubernetes_namespace_for(environment)
- project = environment.project
- persisted_namespace = Clusters::KubernetesNamespaceFinder.new(
- self,
- project: project,
- environment_name: environment.name
- ).execute
-
- persisted_namespace&.namespace || Gitlab::Kubernetes::DefaultNamespace.new(self, project: project).from_environment_slug(environment.slug)
+ managed_namespace(environment) ||
+ ci_configured_namespace(environment) ||
+ default_namespace(environment)
end
def allow_user_defined_namespace?
@@ -262,6 +270,25 @@ module Clusters
end
end
+ def delete_cached_resources!
+ kubernetes_namespaces.delete_all(:delete_all)
+ end
+
+ def clusterable
+ return unless cluster_type
+
+ case cluster_type
+ when 'project_type'
+ project
+ when 'group_type'
+ group
+ when 'instance_type'
+ instance
+ else
+ raise NotImplementedError
+ end
+ end
+
private
def unique_management_project_environment_scope
@@ -276,6 +303,25 @@ module Clusters
end
end
+ def managed_namespace(environment)
+ Clusters::KubernetesNamespaceFinder.new(
+ self,
+ project: environment.project,
+ environment_name: environment.name
+ ).execute&.namespace
+ end
+
+ def ci_configured_namespace(environment)
+ environment.last_deployable&.expanded_kubernetes_namespace
+ end
+
+ def default_namespace(environment)
+ Gitlab::Kubernetes::DefaultNamespace.new(
+ self,
+ project: environment.project
+ ).from_environment_slug(environment.slug)
+ end
+
def instance_domain
@instance_domain ||= Gitlab::CurrentSettings.auto_devops_domain
end
@@ -289,7 +335,7 @@ module Clusters
rescue Kubeclient::HttpError => e
kubeclient_error_status(e.message)
rescue => e
- Gitlab::Sentry.track_acceptable_exception(e, extra: { cluster_id: id })
+ Gitlab::ErrorTracking.track_exception(e, cluster_id: id)
:unknown_failure
else
diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb
index 21b98534808..f6431f5bac3 100644
--- a/app/models/clusters/concerns/application_core.rb
+++ b/app/models/clusters/concerns/application_core.rb
@@ -76,7 +76,7 @@ module Clusters
message: error.message
})
- Gitlab::Sentry.track_acceptable_exception(error, extra: { cluster_id: cluster&.id, application_id: id })
+ Gitlab::ErrorTracking.track_exception(error, cluster_id: cluster&.id, application_id: id)
end
end
end
diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb
index 314ef78757d..ae720065387 100644
--- a/app/models/clusters/platforms/kubernetes.rb
+++ b/app/models/clusters/platforms/kubernetes.rb
@@ -63,7 +63,7 @@ module Clusters
default_value_for :authorization_type, :rbac
- def predefined_variables(project:, environment_name:)
+ def predefined_variables(project:, environment_name:, kubernetes_namespace: nil)
Gitlab::Ci::Variables::Collection.new.tap do |variables|
variables.append(key: 'KUBE_URL', value: api_url)
@@ -74,15 +74,15 @@ module Clusters
end
if !cluster.managed? || cluster.management_project == project
- namespace = Gitlab::Kubernetes::DefaultNamespace.new(cluster, project: project).from_environment_name(environment_name)
+ namespace = kubernetes_namespace || default_namespace(project, environment_name: environment_name)
variables
.append(key: 'KUBE_TOKEN', value: token, public: false, masked: true)
.append(key: 'KUBE_NAMESPACE', value: namespace)
.append(key: 'KUBECONFIG', value: kubeconfig(namespace), public: false, file: true)
- elsif kubernetes_namespace = find_persisted_namespace(project, environment_name: environment_name)
- variables.concat(kubernetes_namespace.predefined_variables)
+ elsif persisted_namespace = find_persisted_namespace(project, environment_name: environment_name)
+ variables.concat(persisted_namespace.predefined_variables)
end
variables.concat(cluster.predefined_variables)
@@ -107,6 +107,13 @@ module Clusters
private
+ def default_namespace(project, environment_name:)
+ Gitlab::Kubernetes::DefaultNamespace.new(
+ cluster,
+ project: project
+ ).from_environment_name(environment_name)
+ end
+
def find_persisted_namespace(project, environment_name:)
Clusters::KubernetesNamespaceFinder.new(
cluster,
diff --git a/app/models/clusters/providers/aws.rb b/app/models/clusters/providers/aws.rb
index 78eb75ddcc0..faf587fb83d 100644
--- a/app/models/clusters/providers/aws.rb
+++ b/app/models/clusters/providers/aws.rb
@@ -8,9 +8,11 @@ module Clusters
self.table_name = 'cluster_providers_aws'
+ DEFAULT_REGION = 'us-east-1'
+
belongs_to :cluster, inverse_of: :provider_aws, class_name: 'Clusters::Cluster'
- default_value_for :region, 'us-east-1'
+ default_value_for :region, DEFAULT_REGION
default_value_for :num_nodes, 3
default_value_for :instance_type, 'm5.large'
diff --git a/app/models/commit.rb b/app/models/commit.rb
index aae49c36899..460725b2016 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -12,6 +12,7 @@ class Commit
include StaticModel
include Presentable
include ::Gitlab::Utils::StrongMemoize
+ include ActsAsPaginatedDiff
include CacheMarkdownField
attr_mentionable :safe_message, pipeline: :single_line
@@ -246,7 +247,7 @@ class Commit
def lazy_author
BatchLoader.for(author_email.downcase).batch do |emails, loader|
- users = User.by_any_email(emails).includes(:emails)
+ users = User.by_any_email(emails, confirmed: true).includes(:emails)
emails.each do |email|
user = users.find { |u| u.any_email?(email) }
@@ -263,8 +264,8 @@ class Commit
end
request_cache(:author) { author_email.downcase }
- def committer
- @committer ||= User.find_by_any_email(committer_email)
+ def committer(confirmed: true)
+ @committer ||= User.find_by_any_email(committer_email, confirmed: confirmed)
end
def parents
@@ -281,6 +282,10 @@ class Commit
project.notes.for_commit_id(self.id)
end
+ def user_mentions
+ CommitUserMention.where(commit_id: self.id)
+ end
+
def discussion_notes
notes.non_diff_notes
end
@@ -464,8 +469,20 @@ class Commit
"commit:#{sha}"
end
+ def expire_note_etag_cache
+ super
+
+ expire_note_etag_cache_for_related_mrs
+ end
+
private
+ def expire_note_etag_cache_for_related_mrs
+ MergeRequest.includes(target_project: :namespace).by_commit_sha(id).find_each do |mr|
+ mr.expire_note_etag_cache
+ end
+ end
+
def commit_reference(from, referable_commit_id, full: false)
reference = project.to_reference(from, full: full)
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 39a6247b3b2..8d38835fb3b 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -17,7 +17,7 @@ class CommitStatus < ApplicationRecord
belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline'
delegate :commit, to: :pipeline
- delegate :sha, :short_sha, to: :pipeline
+ delegate :sha, :short_sha, :before_sha, to: :pipeline
validates :pipeline, presence: true, unless: :importing?
validates :name, presence: true, unless: :importing?
@@ -47,6 +47,12 @@ class CommitStatus < ApplicationRecord
scope :after_stage, -> (index) { where('stage_idx > ?', index) }
scope :processables, -> { where(type: %w[Ci::Build Ci::Bridge]) }
scope :for_ids, -> (ids) { where(id: ids) }
+ scope :for_ref, -> (ref) { where(ref: ref) }
+ scope :by_name, -> (name) { where(name: name) }
+
+ scope :for_project_paths, -> (paths) do
+ where(project: Project.where_full_path_in(Array(paths)))
+ end
scope :with_preloads, -> do
preload(:project, :user)
@@ -176,10 +182,6 @@ class CommitStatus < ApplicationRecord
will_save_change_to_status?
end
- def before_sha
- pipeline.before_sha || Gitlab::Git::BLANK_SHA
- end
-
def group_name
name.to_s.gsub(%r{\d+[\s:/\\]+\d+\s*}, '').strip
end
diff --git a/app/models/commit_user_mention.rb b/app/models/commit_user_mention.rb
new file mode 100644
index 00000000000..680d20b61cf
--- /dev/null
+++ b/app/models/commit_user_mention.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class CommitUserMention < UserMention
+ belongs_to :note
+end
diff --git a/app/models/compare.rb b/app/models/compare.rb
index f1ed84ab5a5..9b214171f07 100644
--- a/app/models/compare.rb
+++ b/app/models/compare.rb
@@ -4,6 +4,7 @@ require 'set'
class Compare
include Gitlab::Utils::StrongMemoize
+ include ActsAsPaginatedDiff
delegate :same, :head, :base, to: :@compare
diff --git a/app/models/concerns/acts_as_paginated_diff.rb b/app/models/concerns/acts_as_paginated_diff.rb
new file mode 100644
index 00000000000..4ce2f99e63f
--- /dev/null
+++ b/app/models/concerns/acts_as_paginated_diff.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module ActsAsPaginatedDiff
+ # Comparisons going back to the repository will need proper batch
+ # loading (https://gitlab.com/gitlab-org/gitlab/issues/32859).
+ # For now, we're returning all the diffs available with
+ # no pagination data.
+ def diffs_in_batch(_batch_page, _batch_size, diff_options:)
+ diffs(diff_options)
+ end
+end
diff --git a/app/models/concerns/analytics/cycle_analytics/stage.rb b/app/models/concerns/analytics/cycle_analytics/stage.rb
index 0e07806dd6f..dde73b567db 100644
--- a/app/models/concerns/analytics/cycle_analytics/stage.rb
+++ b/app/models/concerns/analytics/cycle_analytics/stage.rb
@@ -26,6 +26,7 @@ module Analytics
alias_attribute :custom_stage?, :custom
scope :default_stages, -> { where(custom: false) }
scope :ordered, -> { order(:relative_position, :id) }
+ scope :for_list, -> { includes(:start_event_label, :end_event_label).ordered }
end
def parent=(_)
diff --git a/app/models/concerns/blob_active_model.rb b/app/models/concerns/blob_active_model.rb
new file mode 100644
index 00000000000..89157e90e34
--- /dev/null
+++ b/app/models/concerns/blob_active_model.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+# To be included in blob classes which are to be
+# treated as ActiveModel.
+#
+# The blob class must respond_to `project`
+module BlobActiveModel
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def declarative_policy_class
+ 'BlobPolicy'
+ end
+ end
+
+ def to_ability_name
+ 'blob'
+ end
+end
diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb
index 49d6f3d399c..5ff537a7837 100644
--- a/app/models/concerns/ci/contextable.rb
+++ b/app/models/concerns/ci/contextable.rb
@@ -15,7 +15,7 @@ module Ci
variables.concat(project.predefined_variables)
variables.concat(pipeline.predefined_variables)
variables.concat(runner.predefined_variables) if runnable? && runner
- variables.concat(project.deployment_variables(environment: environment)) if environment
+ variables.concat(deployment_variables(environment: environment))
variables.concat(yaml_variables)
variables.concat(user_variables)
variables.concat(secret_group_variables)
@@ -54,49 +54,33 @@ module Ci
end
end
- def predefined_variables # rubocop:disable Metrics/AbcSize
+ def predefined_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
- variables.append(key: 'CI', value: 'true')
- variables.append(key: 'GITLAB_CI', value: 'true')
- variables.append(key: 'GITLAB_FEATURES', value: project.licensed_features.join(','))
- variables.append(key: 'CI_SERVER_HOST', value: Gitlab.config.gitlab.host)
- variables.append(key: 'CI_SERVER_NAME', value: 'GitLab')
- variables.append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION)
- variables.append(key: 'CI_SERVER_VERSION_MAJOR', value: Gitlab.version_info.major.to_s)
- variables.append(key: 'CI_SERVER_VERSION_MINOR', value: Gitlab.version_info.minor.to_s)
- variables.append(key: 'CI_SERVER_VERSION_PATCH', value: Gitlab.version_info.patch.to_s)
- variables.append(key: 'CI_SERVER_REVISION', value: Gitlab.revision)
variables.append(key: 'CI_JOB_NAME', value: name)
variables.append(key: 'CI_JOB_STAGE', value: stage)
- variables.append(key: 'CI_COMMIT_SHA', value: sha)
- variables.append(key: 'CI_COMMIT_SHORT_SHA', value: short_sha)
- variables.append(key: 'CI_COMMIT_BEFORE_SHA', value: before_sha)
- variables.append(key: 'CI_COMMIT_REF_NAME', value: source_ref)
- variables.append(key: 'CI_COMMIT_REF_SLUG', value: source_ref_slug)
- variables.append(key: "CI_COMMIT_TAG", value: ref) if tag?
- variables.append(key: "CI_PIPELINE_TRIGGERED", value: 'true') if trigger_request
- variables.append(key: "CI_JOB_MANUAL", value: 'true') if action?
- variables.append(key: "CI_NODE_INDEX", value: self.options[:instance].to_s) if self.options&.include?(:instance)
- variables.append(key: "CI_NODE_TOTAL", value: (self.options&.dig(:parallel) || 1).to_s)
- variables.append(key: "CI_DEFAULT_BRANCH", value: project.default_branch)
- variables.concat(legacy_variables)
- end
- end
+ variables.append(key: 'CI_JOB_MANUAL', value: 'true') if action?
+ variables.append(key: 'CI_PIPELINE_TRIGGERED', value: 'true') if trigger_request
- def legacy_variables
- Gitlab::Ci::Variables::Collection.new.tap do |variables|
- variables.append(key: 'CI_BUILD_REF', value: sha)
- variables.append(key: 'CI_BUILD_BEFORE_SHA', value: before_sha)
- variables.append(key: 'CI_BUILD_REF_NAME', value: source_ref)
- variables.append(key: 'CI_BUILD_REF_SLUG', value: source_ref_slug)
+ variables.append(key: 'CI_NODE_INDEX', value: self.options[:instance].to_s) if self.options&.include?(:instance)
+ variables.append(key: 'CI_NODE_TOTAL', value: (self.options&.dig(:parallel) || 1).to_s)
+
+ # legacy variables
variables.append(key: 'CI_BUILD_NAME', value: name)
variables.append(key: 'CI_BUILD_STAGE', value: stage)
- variables.append(key: "CI_BUILD_TAG", value: ref) if tag?
- variables.append(key: "CI_BUILD_TRIGGERED", value: 'true') if trigger_request
- variables.append(key: "CI_BUILD_MANUAL", value: 'true') if action?
+ variables.append(key: 'CI_BUILD_TRIGGERED', value: 'true') if trigger_request
+ variables.append(key: 'CI_BUILD_MANUAL', value: 'true') if action?
end
end
+ def deployment_variables(environment:)
+ return [] unless environment
+
+ project.deployment_variables(
+ environment: environment,
+ kubernetes_namespace: expanded_kubernetes_namespace
+ )
+ end
+
def secret_group_variables
return [] unless project.group
diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb
index 17d431bacf2..9bfe76728e4 100644
--- a/app/models/concerns/ci/metadatable.rb
+++ b/app/models/concerns/ci/metadatable.rb
@@ -17,6 +17,7 @@ module Ci
delegate :timeout, to: :metadata, prefix: true, allow_nil: true
delegate :interruptible, to: :metadata, prefix: false, allow_nil: true
delegate :has_exposed_artifacts?, to: :metadata, prefix: false, allow_nil: true
+ delegate :environment_auto_stop_in, to: :metadata, prefix: false, allow_nil: true
before_create :ensure_metadata
end
@@ -47,8 +48,11 @@ module Ci
def options=(value)
write_metadata_attribute(:options, :config_options, value)
- # Store presence of exposed artifacts in build metadata to make it easier to query
- ensure_metadata.has_exposed_artifacts = value&.dig(:artifacts, :expose_as).present?
+ ensure_metadata.tap do |metadata|
+ # Store presence of exposed artifacts in build metadata to make it easier to query
+ metadata.has_exposed_artifacts = value&.dig(:artifacts, :expose_as).present?
+ metadata.environment_auto_stop_in = value&.dig(:environment, :auto_stop_in)
+ end
end
def yaml_variables=(value)
diff --git a/app/models/concerns/ci/pipeline_delegator.rb b/app/models/concerns/ci/pipeline_delegator.rb
index 76e0cbc7dff..9f95dc38422 100644
--- a/app/models/concerns/ci/pipeline_delegator.rb
+++ b/app/models/concerns/ci/pipeline_delegator.rb
@@ -13,8 +13,6 @@ module Ci
included do
delegate :merge_request_event?,
:merge_request_ref?,
- :source_ref,
- :source_ref_slug,
:legacy_detached_merge_request_pipeline?,
:merge_train_pipeline?, to: :pipeline
end
diff --git a/app/models/concerns/ci/processable.rb b/app/models/concerns/ci/processable.rb
index ed0087f34d4..c229358ad17 100644
--- a/app/models/concerns/ci/processable.rb
+++ b/app/models/concerns/ci/processable.rb
@@ -14,6 +14,8 @@ module Ci
has_many :needs, class_name: 'Ci::BuildNeed', foreign_key: :build_id, inverse_of: :build
accepts_nested_attributes_for :needs
+
+ scope :preload_needs, -> { preload(:needs) }
end
def schedulable?
diff --git a/app/models/concerns/diff_positionable_note.rb b/app/models/concerns/diff_positionable_note.rb
index 195d9e107c5..6484a3157b1 100644
--- a/app/models/concerns/diff_positionable_note.rb
+++ b/app/models/concerns/diff_positionable_note.rb
@@ -5,7 +5,7 @@ module DiffPositionableNote
included do
delegate :on_text?, :on_image?, to: :position, allow_nil: true
before_validation :set_original_position, on: :create
- before_validation :update_position, on: :create, if: :on_text?
+ before_validation :update_position, on: :create, if: :on_text?, unless: :importing?
serialize :original_position, Gitlab::Diff::Position # rubocop:disable Cop/ActiveRecordSerialize
serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiveRecordSerialize
diff --git a/app/models/concerns/expirable.rb b/app/models/concerns/expirable.rb
index 1f274487935..512822089ba 100644
--- a/app/models/concerns/expirable.rb
+++ b/app/models/concerns/expirable.rb
@@ -3,6 +3,8 @@
module Expirable
extend ActiveSupport::Concern
+ DAYS_TO_EXPIRE = 7
+
included do
scope :expired, -> { where('expires_at <= ?', Time.current) }
end
@@ -16,6 +18,6 @@ module Expirable
end
def expires_soon?
- expires? && expires_at < 7.days.from_now
+ expires? && expires_at < DAYS_TO_EXPIRE.days.from_now
end
end
diff --git a/app/models/concerns/group_descendant.rb b/app/models/concerns/group_descendant.rb
index 7e6a20c27e8..67953105bed 100644
--- a/app/models/concerns/group_descendant.rb
+++ b/app/models/concerns/group_descendant.rb
@@ -48,11 +48,11 @@ module GroupDescendant
extras = {
parent: parent.inspect,
child: child.inspect,
- preloaded: preloaded.map(&:full_path)
+ preloaded: preloaded.map(&:full_path),
+ issue_url: 'https://gitlab.com/gitlab-org/gitlab-foss/issues/49404'
}
- issue_url = 'https://gitlab.com/gitlab-org/gitlab-foss/issues/49404'
- Gitlab::Sentry.track_exception(exception, issue_url: issue_url, extra: extras)
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(exception, extras)
end
if parent.nil? && hierarchy_top.present?
diff --git a/app/models/concerns/ignorable_columns.rb b/app/models/concerns/ignorable_columns.rb
new file mode 100644
index 00000000000..744a1f0b5f3
--- /dev/null
+++ b/app/models/concerns/ignorable_columns.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module IgnorableColumns
+ extend ActiveSupport::Concern
+
+ ColumnIgnore = Struct.new(:remove_after, :remove_with) do
+ def safe_to_remove?
+ Date.today > remove_after
+ end
+
+ def to_s
+ "(#{remove_after}, #{remove_with})"
+ end
+ end
+
+ class_methods do
+ # Ignore database columns in a model
+ #
+ # Indicate the earliest date and release we can stop ignoring the column with +remove_after+ (a date string) and +remove_with+ (a release)
+ def ignore_columns(*columns, remove_after:, remove_with:)
+ raise ArgumentError, 'Please indicate when we can stop ignoring columns with remove_after (date string YYYY-MM-DD), example: ignore_columns(:name, remove_after: \'2019-12-01\', remove_with: \'12.6\')' unless remove_after =~ Gitlab::Regex.utc_date_regex
+ raise ArgumentError, 'Please indicate in which release we can stop ignoring columns with remove_with, example: ignore_columns(:name, remove_after: \'2019-12-01\', remove_with: \'12.6\')' unless remove_with
+
+ self.ignored_columns += columns.flatten # rubocop:disable Cop/IgnoredColumns
+
+ columns.flatten.each do |column|
+ self.ignored_columns_details[column.to_sym] = ColumnIgnore.new(Date.parse(remove_after), remove_with)
+ end
+ end
+
+ alias_method :ignore_column, :ignore_columns
+
+ def ignored_columns_details
+ unless defined?(@ignored_columns_details)
+ IGNORE_COLUMN_MUTEX.synchronize do
+ @ignored_columns_details ||= superclass.try(:ignored_columns_details)&.dup || {}
+ end
+ end
+
+ @ignored_columns_details
+ end
+
+ IGNORE_COLUMN_MUTEX = Mutex.new
+ end
+end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 01cd1e0224b..9e3fba139e3 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -23,7 +23,6 @@ module Issuable
include Sortable
include CreatedAtFilterable
include UpdatedAtFilterable
- include IssuableStates
include ClosedAtFilterable
include VersionedDescription
@@ -100,6 +99,8 @@ module Issuable
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 }) }
+ scope :any_release, -> { joins_milestone_releases }
+ scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) }
scope :opened, -> { with_state(:opened) }
scope :only_opened, -> { with_state(:opened) }
scope :closed, -> { with_state(:closed) }
@@ -121,6 +122,16 @@ module Issuable
scope :order_milestone_due_desc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC')) }
scope :order_milestone_due_asc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC')) }
+ scope :without_release, -> do
+ joins("LEFT OUTER JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id")
+ .where('milestone_releases.release_id IS NULL')
+ end
+
+ scope :joins_milestone_releases, -> do
+ joins("JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id
+ JOIN releases ON milestone_releases.release_id = releases.id").distinct
+ end
+
scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) }
scope :any_label, -> { joins(:label_links).group(:id) }
scope :join_project, -> { joins(:project) }
@@ -137,26 +148,6 @@ module Issuable
strip_attributes :title
- # The state_machine gem will reset the value of state_id unless it
- # is a raw attribute passed in here:
- # https://gitlab.com/gitlab-org/gitlab/issues/35746#note_241148787
- #
- # This assumes another initialize isn't defined. Otherwise this
- # method may need to be prepended.
- def initialize(attributes = nil)
- if attributes.is_a?(Hash)
- attr = attributes.symbolize_keys
-
- if attr.key?(:state) && !attr.key?(:state_id)
- value = attr.delete(:state)
- state_id = self.class.available_states[value]
- attributes[:state_id] = state_id if state_id
- end
- end
-
- super(attributes)
- 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?
@@ -174,7 +165,7 @@ module Issuable
private
def milestone_is_valid
- errors.add(:milestone_id, message: "is invalid") if milestone_id.present? && !milestone_available?
+ errors.add(:milestone_id, message: "is invalid") if respond_to?(:milestone_id) && milestone_id.present? && !milestone_available?
end
def description_max_length_for_new_records_is_valid
diff --git a/app/models/concerns/issuable_states.rb b/app/models/concerns/issuable_states.rb
deleted file mode 100644
index f0b9f0d1f3a..00000000000
--- a/app/models/concerns/issuable_states.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-module IssuableStates
- extend ActiveSupport::Concern
-
- # The state:string column is being migrated to state_id:integer column
- # This is a temporary hook to keep state column in sync until it is removed.
- # Check https: https://gitlab.com/gitlab-org/gitlab/issues/33814 for more information
- # The state column can be safely removed after 2019-10-27
- included do
- before_save :sync_issuable_deprecated_state
- end
-
- def sync_issuable_deprecated_state
- return if self.is_a?(Epic)
- return unless respond_to?(:state)
- return if state_id.nil?
-
- deprecated_state = self.class.available_states.key(state_id)
-
- self.write_attribute(:state, deprecated_state)
- end
-end
diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb
index 9b6c57261d8..b43b91699ab 100644
--- a/app/models/concerns/mentionable.rb
+++ b/app/models/concerns/mentionable.rb
@@ -80,6 +80,66 @@ module Mentionable
all_references(current_user).users
end
+ def store_mentions!
+ # if store_mentioned_users_to_db feature flag is not enabled then consider storing operation as succeeded
+ # because we wrap this method in transaction with with_transaction_returning_status, and we need the status to be
+ # successful if mentionable.save is successful.
+ #
+ # This line will get removed when we remove the feature flag.
+ return true unless store_mentioned_users_to_db_enabled?
+
+ refs = all_references(self.author)
+
+ references = {}
+ references[:mentioned_users_ids] = refs.mentioned_users&.pluck(:id).presence
+ references[:mentioned_groups_ids] = refs.mentioned_groups&.pluck(:id).presence
+ references[:mentioned_projects_ids] = refs.mentioned_projects&.pluck(:id).presence
+
+ # One retry should be enough as next time `model_user_mention` should return the existing mention record, that
+ # threw the `ActiveRecord::RecordNotUnique` exception in first place.
+ self.class.safe_ensure_unique(retries: 1) do
+ user_mention = model_user_mention
+ 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?
+ user_mention.destroy!
+ end
+
+ true
+ end
+ end
+
+ def referenced_users
+ User.where(id: user_mentions.select("unnest(mentioned_users_ids)"))
+ end
+
+ def referenced_projects(current_user = nil)
+ Project.where(id: user_mentions.select("unnest(mentioned_projects_ids)")).public_or_visible_to_user(current_user)
+ end
+
+ def referenced_project_users(current_user = nil)
+ User.joins(:project_members).where(members: { source_id: referenced_projects(current_user) }).distinct
+ end
+
+ def referenced_groups(current_user = nil)
+ # TODO: IMPORTANT: Revisit before using it.
+ # Check DB data for max mentioned groups per mentionable:
+ #
+ # select issue_id, count(mentions_count.men_gr_id) gr_count from
+ # (select DISTINCT unnest(mentioned_groups_ids) as men_gr_id, issue_id
+ # from issue_user_mentions group by issue_id, mentioned_groups_ids) as mentions_count
+ # group by mentions_count.issue_id order by gr_count desc limit 10
+ Group.where(id: user_mentions.select("unnest(mentioned_groups_ids)")).public_or_visible_to_user(current_user)
+ end
+
+ def referenced_group_users(current_user = nil)
+ User.joins(:group_members).where(members: { source_id: referenced_groups }).distinct
+ end
+
def directly_addressed_users(current_user = nil)
all_references(current_user).directly_addressed_users
end
@@ -171,6 +231,26 @@ module Mentionable
def mentionable_params
{}
end
+
+ # User mention that is parsed from model description rather then its related notes.
+ # Models that have a descriprion attribute like Issue, MergeRequest, Epic, Snippet may have such a user mention.
+ # Other mentionable models like Commit, DesignManagement::Design, will never have such record as those do not have
+ # a description attribute.
+ #
+ # 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
+ user_mentions.where(note_id: nil).first_or_initialize
+ end
+
+ # We need this method to be checking that store_mentioned_users_to_db feature flag is enabled at the group level
+ # and not the project level as epics are defined at group level and we want to have epics store user mentions as well
+ # for the test period.
+ # During the test period the flag should be enabled at the group level.
+ def store_mentioned_users_to_db_enabled?
+ return Feature.enabled?(:store_mentioned_users_to_db, self.project&.group) if self.respond_to?(:project)
+ return Feature.enabled?(:store_mentioned_users_to_db, self.group) if self.respond_to?(:group)
+ end
end
Mentionable.prepend_if_ee('EE::Mentionable')
diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb
index b1a7d7ec819..88e752e51e7 100644
--- a/app/models/concerns/milestoneish.rb
+++ b/app/models/concerns/milestoneish.rb
@@ -53,7 +53,7 @@ module Milestoneish
end
def sorted_issues(user)
- issues_visible_to_user(user).preload_associations.sort_by_attribute('label_priority')
+ issues_visible_to_user(user).preload_associated_models.sort_by_attribute('label_priority')
end
def sorted_merge_requests(user)
diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb
index 9df77b565da..99da8b81398 100644
--- a/app/models/concerns/prometheus_adapter.rb
+++ b/app/models/concerns/prometheus_adapter.rb
@@ -16,6 +16,14 @@ module PrometheusAdapter
raise NotImplementedError
end
+ # This is a light-weight check if a prometheus client is properly configured.
+ def configured?
+ raise NotImplemented
+ end
+
+ # This is a heavy-weight check if a prometheus is properly configured and accesible 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?
prometheus_client.present?
end
diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb
index f9a52cd54bd..693f9ab8dc5 100644
--- a/app/models/concerns/reactive_caching.rb
+++ b/app/models/concerns/reactive_caching.rb
@@ -129,14 +129,17 @@ module ReactiveCaching
def exclusively_update_reactive_cache!(*args)
locking_reactive_cache(*args) do
+ key = full_reactive_cache_key(*args)
+
if within_reactive_cache_lifetime?(*args)
enqueuing_update(*args) do
- key = full_reactive_cache_key(*args)
new_value = calculate_reactive_cache(*args)
old_value = Rails.cache.read(key)
Rails.cache.write(key, new_value)
reactive_cache_updated(*args) if new_value != old_value
end
+ else
+ Rails.cache.delete(key)
end
end
end
diff --git a/app/models/concerns/safe_url.rb b/app/models/concerns/safe_url.rb
new file mode 100644
index 00000000000..febca7d241f
--- /dev/null
+++ b/app/models/concerns/safe_url.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module SafeUrl
+ extend ActiveSupport::Concern
+
+ def safe_url(usernames_whitelist: [])
+ return if url.nil?
+
+ uri = URI.parse(url)
+ uri.password = '*****' if uri.password
+ uri.user = '*****' if uri.user && !usernames_whitelist.include?(uri.user)
+ uri.to_s
+ rescue URI::Error
+ end
+end
diff --git a/app/models/concerns/sha256_attribute.rb b/app/models/concerns/sha256_attribute.rb
new file mode 100644
index 00000000000..1bd1ad177a2
--- /dev/null
+++ b/app/models/concerns/sha256_attribute.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Sha256Attribute
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def sha256_attribute(name)
+ return if ENV['STATIC_VERIFICATION']
+
+ validate_binary_column_exists!(name) unless Rails.env.production?
+
+ attribute(name, Gitlab::Database::Sha256Attribute.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: sha256_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: sha256_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("sha256_attribute #{name.inspect} is invalid since the column type is not :binary")
+ end
+ rescue => error
+ Gitlab::AppLogger.error "Sha256Attribute initialization: #{error.message}"
+ raise
+ end
+
+ def database_exists?
+ ApplicationRecord.connection
+
+ true
+ rescue
+ false
+ end
+ end
+end
diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb
index 9c2b0372d54..da4f2a79895 100644
--- a/app/models/concerns/storage/legacy_namespace.rb
+++ b/app/models/concerns/storage/legacy_namespace.rb
@@ -37,8 +37,10 @@ module Storage
send_update_instructions
write_projects_repository_config
rescue => e
- # Raise if development/test environment, else just notify Sentry
- Gitlab::Sentry.track_exception(e, extra: { full_path_before_last_save: full_path_before_last_save, full_path: full_path, action: 'move_dir' })
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e,
+ full_path_before_last_save: full_path_before_last_save,
+ full_path: full_path,
+ action: 'move_dir')
end
true # false would cancel later callbacks but not rollback
diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb
index 869b3490f3f..a84fb1cf56d 100644
--- a/app/models/concerns/update_project_statistics.rb
+++ b/app/models/concerns/update_project_statistics.rb
@@ -49,8 +49,7 @@ module UpdateProjectStatistics
attr = self.class.statistic_attribute
delta = read_attribute(attr).to_i - attribute_before_last_save(attr).to_i
- update_project_statistics(delta)
- schedule_namespace_aggregation_worker
+ schedule_update_project_statistic(delta)
end
def update_project_statistics_attribute_changed?
@@ -58,24 +57,35 @@ module UpdateProjectStatistics
end
def update_project_statistics_after_destroy
- update_project_statistics(-read_attribute(self.class.statistic_attribute).to_i)
+ delta = -read_attribute(self.class.statistic_attribute).to_i
- schedule_namespace_aggregation_worker
+ schedule_update_project_statistic(delta)
end
def project_destroyed?
project.pending_delete?
end
- def update_project_statistics(delta)
- ProjectStatistics.increment_statistic(project_id, self.class.project_statistics_name, delta)
- end
+ def schedule_update_project_statistic(delta)
+ return if delta.zero?
+
+ if Feature.enabled?(:update_project_statistics_after_commit, default_enabled: true)
+ # Update ProjectStatistics after the transaction
+ run_after_commit do
+ ProjectStatistics.increment_statistic(
+ project_id, self.class.project_statistics_name, delta)
+ end
+ else
+ # Use legacy-way to update within transaction
+ ProjectStatistics.increment_statistic(
+ project_id, self.class.project_statistics_name, delta)
+ end
- def schedule_namespace_aggregation_worker
run_after_commit do
next if project.nil?
- Namespaces::ScheduleAggregationWorker.perform_async(project.namespace_id)
+ Namespaces::ScheduleAggregationWorker.perform_async(
+ project.namespace_id)
end
end
end
diff --git a/app/models/container_expiration_policy.rb b/app/models/container_expiration_policy.rb
new file mode 100644
index 00000000000..f60a0179c83
--- /dev/null
+++ b/app/models/container_expiration_policy.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+class ContainerExpirationPolicy < ApplicationRecord
+ belongs_to :project, inverse_of: :container_expiration_policy
+
+ validates :project, presence: true
+ validates :enabled, inclusion: { in: [true, false] }
+ validates :cadence, presence: true, inclusion: { in: ->(_) { self.cadence_options.stringify_keys } }
+ validates :older_than, inclusion: { in: ->(_) { self.older_than_options.stringify_keys } }, allow_nil: true
+ validates :keep_n, inclusion: { in: ->(_) { self.keep_n_options.keys } }, allow_nil: true
+
+ def self.keep_n_options
+ {
+ 1 => _('%{tags} tag per image name') % { tags: 1 },
+ 5 => _('%{tags} tags per image name') % { tags: 5 },
+ 10 => _('%{tags} tags per image name') % { tags: 10 },
+ 25 => _('%{tags} tags per image name') % { tags: 25 },
+ 50 => _('%{tags} tags per image name') % { tags: 50 },
+ 100 => _('%{tags} tags per image name') % { tags: 100 }
+ }
+ end
+
+ def self.cadence_options
+ {
+ '1d': _('Every day'),
+ '7d': _('Every week'),
+ '14d': _('Every two weeks'),
+ '1month': _('Every month'),
+ '3month': _('Every three months')
+ }
+ end
+
+ def self.older_than_options
+ {
+ '7d': _('%{days} days until tags are automatically removed') % { days: 7 },
+ '14d': _('%{days} days until tags are automatically removed') % { days: 14 },
+ '30d': _('%{days} days until tags are automatically removed') % { days: 30 },
+ '90d': _('%{days} days until tags are automatically removed') % { days: 90 }
+ }
+ end
+end
diff --git a/app/models/dashboard_group_milestone.rb b/app/models/dashboard_group_milestone.rb
index cf6094682f3..48c09f4cd6b 100644
--- a/app/models/dashboard_group_milestone.rb
+++ b/app/models/dashboard_group_milestone.rb
@@ -22,4 +22,8 @@ class DashboardGroupMilestone < GlobalMilestone
def dashboard_milestone?
true
end
+
+ def merge_requests_enabled?
+ true
+ end
end
diff --git a/app/models/dashboard_milestone.rb b/app/models/dashboard_milestone.rb
index 9b377b70e5b..fd59b94b737 100644
--- a/app/models/dashboard_milestone.rb
+++ b/app/models/dashboard_milestone.rb
@@ -12,4 +12,8 @@ class DashboardMilestone < GlobalMilestone
def project_milestone?
true
end
+
+ def merge_requests_enabled?
+ project.merge_requests_enabled?
+ end
end
diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb
index 22ab326a0ab..793ea3c29c3 100644
--- a/app/models/deploy_key.rb
+++ b/app/models/deploy_key.rb
@@ -2,15 +2,16 @@
class DeployKey < Key
include FromUnion
+ include IgnorableColumns
has_many :deploy_keys_projects, inverse_of: :deploy_key, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :projects, through: :deploy_keys_projects
scope :in_projects, ->(projects) { joins(:deploy_keys_projects).where('deploy_keys_projects.project_id in (?)', projects) }
scope :are_public, -> { where(public: true) }
- scope :with_projects, -> { includes(deploy_keys_projects: { project: [:route, :namespace] }) }
+ scope :with_projects, -> { includes(deploy_keys_projects: { project: [:route, namespace: :route] }) }
- self.ignored_columns += %i[can_push]
+ ignore_column :can_push, remove_after: '2019-12-15', remove_with: '12.6'
accepts_nested_attributes_for :deploy_keys_projects
@@ -23,7 +24,7 @@ class DeployKey < Key
end
def almost_orphaned?
- self.deploy_keys_projects.count == 1
+ self.deploy_keys_projects.size == 1
end
def destroyed_when_orphaned?
@@ -43,7 +44,11 @@ class DeployKey < Key
end
def deploy_keys_project_for(project)
- deploy_keys_projects.find_by(project: project)
+ if association(:deploy_keys_projects).loaded?
+ deploy_keys_projects.find { |dkp| dkp.project_id.eql?(project&.id) }
+ else
+ deploy_keys_projects.find_by(project: project)
+ end
end
def projects_with_write_access
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 4a38912db9b..994e69912b6 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -4,6 +4,8 @@ class Deployment < ApplicationRecord
include AtomicInternalId
include IidRoutes
include AfterCommitQueue
+ include UpdatedAtFilterable
+ include Gitlab::Utils::StrongMemoize
belongs_to :project, required: true
belongs_to :environment, required: true
@@ -125,6 +127,12 @@ class Deployment < ApplicationRecord
@scheduled_actions ||= deployable.try(:other_scheduled_actions)
end
+ def playable_build
+ strong_memoize(:playable_build) do
+ deployable.try(:playable?) ? deployable : nil
+ end
+ end
+
def includes_commit?(commit)
return false unless commit
@@ -209,6 +217,23 @@ class Deployment < ApplicationRecord
SQL
end
+ # Changes the status of a deployment and triggers the correspinding state
+ # machine events.
+ def update_status(status)
+ case status
+ when 'running'
+ run
+ when 'success'
+ succeed
+ when 'failed'
+ drop
+ when 'canceled'
+ cancel
+ else
+ raise ArgumentError, "The status #{status.inspect} is invalid"
+ end
+ end
+
private
def ref_path
diff --git a/app/models/conversational_development_index/card.rb b/app/models/dev_ops_score/card.rb
index f9180bdd97b..b1894cf4138 100644
--- a/app/models/conversational_development_index/card.rb
+++ b/app/models/dev_ops_score/card.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-module ConversationalDevelopmentIndex
+module DevOpsScore
class Card
attr_accessor :metric, :title, :description, :feature, :blog, :docs
diff --git a/app/models/conversational_development_index/idea_to_production_step.rb b/app/models/dev_ops_score/idea_to_production_step.rb
index e78a734693c..d892793cf97 100644
--- a/app/models/conversational_development_index/idea_to_production_step.rb
+++ b/app/models/dev_ops_score/idea_to_production_step.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-module ConversationalDevelopmentIndex
+module DevOpsScore
class IdeaToProductionStep
attr_accessor :metric, :title, :features
diff --git a/app/models/conversational_development_index/metric.rb b/app/models/dev_ops_score/metric.rb
index b91123be87e..a9133128ce9 100644
--- a/app/models/conversational_development_index/metric.rb
+++ b/app/models/dev_ops_score/metric.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-module ConversationalDevelopmentIndex
+module DevOpsScore
class Metric < ApplicationRecord
include Presentable
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index 65e87bb08a7..686d06d3ee0 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -21,8 +21,8 @@ class DiffNote < Note
validate :positions_complete
validate :verify_supported
- before_validation :set_line_code, if: :on_text?
- after_save :keep_around_commits
+ before_validation :set_line_code, if: :on_text?, unless: :importing?
+ after_save :keep_around_commits, unless: :importing?
after_commit :create_diff_file, on: :create
def discussion_class(*)
@@ -88,10 +88,6 @@ class DiffNote < Note
line&.suggestible?
end
- def discussion_first_note?
- self == discussion.first_note
- end
-
def banzai_render_context(field)
super.merge(suggestions_filter_enabled: true)
end
@@ -108,7 +104,7 @@ class DiffNote < Note
end
def should_create_diff_file?
- on_text? && note_diff_file.nil? && discussion_first_note?
+ on_text? && note_diff_file.nil? && start_of_discussion?
end
def fetch_diff_file
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index b8525f7b135..d0a7db39a30 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -139,10 +139,6 @@ class Discussion
false
end
- def new_discussion?
- notes.length == 1
- end
-
def last_note
@last_note ||= notes.last
end
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 327b1e594d7..b928dcb21a6 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -162,6 +162,10 @@ class Environment < ApplicationRecord
stop_action&.play(current_user)
end
+ def reset_auto_stop
+ update_column(:auto_stop_at, nil)
+ end
+
def actions_for(environment)
return [] unless manual_actions
@@ -193,11 +197,11 @@ class Environment < ApplicationRecord
end
def has_metrics?
- available? && prometheus_adapter&.can_query?
+ available? && prometheus_adapter&.configured?
end
def metrics
- prometheus_adapter.query(:environment, self) if has_metrics?
+ prometheus_adapter.query(:environment, self) if has_metrics? && prometheus_adapter.can_query?
end
def prometheus_status
@@ -261,6 +265,17 @@ class Environment < ApplicationRecord
end
end
+ def auto_stop_in
+ auto_stop_at - Time.now if auto_stop_at
+ end
+
+ def auto_stop_in=(value)
+ return unless value
+ return unless parsed_result = ChronicDuration.parse(value)
+
+ self.auto_stop_at = parsed_result.seconds.from_now
+ end
+
private
def generate_slug
diff --git a/app/models/environment_status.rb b/app/models/environment_status.rb
index d7dc64190d6..5fdb5af2d9b 100644
--- a/app/models/environment_status.rb
+++ b/app/models/environment_status.rb
@@ -20,6 +20,28 @@ class EnvironmentStatus
build_environments_status(mr, user, mr.merge_pipeline)
end
+ def self.for_deployed_merge_request(mr, user)
+ statuses = []
+
+ mr.recent_visible_deployments.each do |deploy|
+ env = deploy.environment
+
+ next unless Ability.allowed?(user, :read_environment, env)
+
+ statuses <<
+ EnvironmentStatus.new(deploy.project, env, mr, deploy.sha)
+ end
+
+ # Existing projects that used deployments prior to the introduction of
+ # explicitly linked merge requests won't have any data using this new
+ # approach, so we fall back to retrieving deployments based on CI pipelines.
+ if statuses.any?
+ statuses
+ else
+ after_merge_request(mr, user)
+ end
+ end
+
def initialize(project, environment, merge_request, sha)
@project = project
@environment = environment
@@ -78,7 +100,7 @@ class EnvironmentStatus
def self.build_environments_status(mr, user, pipeline)
return [] unless pipeline
- pipeline.environments.available.map do |environment|
+ pipeline.environments.includes(:project).available.map do |environment|
next unless Ability.allowed?(user, :read_environment, environment)
EnvironmentStatus.new(pipeline.project, environment, mr, pipeline.sha)
diff --git a/app/models/epic.rb b/app/models/epic.rb
index 46723462590..8222bbf9656 100644
--- a/app/models/epic.rb
+++ b/app/models/epic.rb
@@ -3,6 +3,10 @@
# Placeholder class for model that is implemented in EE
# It reserves '&' as a reference prefix, but the table does not exists in CE
class Epic < ApplicationRecord
+ include IgnorableColumns
+
+ ignore_column :milestone_id, remove_after: '2019-12-15', remove_with: '12.7'
+
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 2aa058a243f..6a9986e806b 100644
--- a/app/models/error_tracking/project_error_tracking_setting.rb
+++ b/app/models/error_tracking/project_error_tracking_setting.rb
@@ -5,6 +5,7 @@ module ErrorTracking
include Gitlab::Utils::StrongMemoize
include ReactiveCaching
+ SENTRY_API_ERROR_TYPE_BAD_REQUEST = 'bad_request_for_sentry_api'
SENTRY_API_ERROR_TYPE_MISSING_KEYS = 'missing_keys_in_sentry_response'
SENTRY_API_ERROR_TYPE_NON_20X_RESPONSE = 'non_20x_response_from_sentry'
SENTRY_API_ERROR_INVALID_SIZE = 'invalid_size_of_sentry_response'
@@ -85,7 +86,7 @@ module ErrorTracking
end
def list_sentry_projects
- { projects: sentry_client.list_projects }
+ { projects: sentry_client.projects }
end
def issue_details(opts = {})
@@ -103,7 +104,7 @@ module ErrorTracking
def calculate_reactive_cache(request, opts)
case request
when 'list_issues'
- { issues: sentry_client.list_issues(**opts.symbolize_keys) }
+ sentry_client.list_issues(**opts.symbolize_keys)
when 'issue_details'
{
issue: sentry_client.issue_details(**opts.symbolize_keys)
@@ -119,6 +120,8 @@ module ErrorTracking
{ error: e.message, error_type: SENTRY_API_ERROR_TYPE_MISSING_KEYS }
rescue Sentry::Client::ResponseInvalidSizeError => e
{ error: e.message, error_type: SENTRY_API_ERROR_INVALID_SIZE }
+ rescue Sentry::Client::BadRequestError => e
+ { error: e.message, error_type: SENTRY_API_ERROR_TYPE_BAD_REQUEST }
end
# http://HOST/api/0/projects/ORG/PROJECT
diff --git a/app/models/group_milestone.rb b/app/models/group_milestone.rb
index bfda603c3cb..87338512d99 100644
--- a/app/models/group_milestone.rb
+++ b/app/models/group_milestone.rb
@@ -41,4 +41,8 @@ class GroupMilestone < GlobalMilestone
def legacy_group_milestone?
true
end
+
+ def merge_requests_enabled?
+ true
+ end
end
diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb
index 65e3eaf31e7..a5f68831f34 100644
--- a/app/models/hooks/project_hook.rb
+++ b/app/models/hooks/project_hook.rb
@@ -2,6 +2,7 @@
class ProjectHook < WebHook
include TriggerableHooks
+ include Presentable
triggerable_hooks [
:push_hooks,
@@ -18,6 +19,10 @@ class ProjectHook < WebHook
belongs_to :project
validates :project, presence: true
+
+ def pluralized_name
+ _('Project Hooks')
+ end
end
ProjectHook.prepend_if_ee('EE::ProjectHook')
diff --git a/app/models/hooks/service_hook.rb b/app/models/hooks/service_hook.rb
index 8f305dd7c22..4caa45a13d4 100644
--- a/app/models/hooks/service_hook.rb
+++ b/app/models/hooks/service_hook.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class ServiceHook < WebHook
+ include Presentable
+
belongs_to :service
validates :service, presence: true
diff --git a/app/models/hooks/system_hook.rb b/app/models/hooks/system_hook.rb
index 3d54d17e787..c8a0cc05912 100644
--- a/app/models/hooks/system_hook.rb
+++ b/app/models/hooks/system_hook.rb
@@ -20,4 +20,12 @@ class SystemHook < WebHook
def allow_local_requests?
Gitlab::CurrentSettings.allow_local_requests_from_system_hooks?
end
+
+ def pluralized_name
+ _('System Hooks')
+ end
+
+ def help_path
+ 'system_hooks/system_hooks'
+ end
end
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index e51b1c41059..dbd5a1b032a 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -37,4 +37,8 @@ class WebHook < ApplicationRecord
def allow_local_requests?
Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services?
end
+
+ def help_path
+ 'user/project/integrations/webhooks'
+ end
end
diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb
index cfb1f3ec63b..df0e7b30f84 100644
--- a/app/models/hooks/web_hook_log.rb
+++ b/app/models/hooks/web_hook_log.rb
@@ -1,6 +1,9 @@
# frozen_string_literal: true
class WebHookLog < ApplicationRecord
+ include SafeUrl
+ include Presentable
+
belongs_to :web_hook
serialize :request_headers, Hash # rubocop:disable Cop/ActiveRecordSerialize
@@ -9,6 +12,8 @@ class WebHookLog < ApplicationRecord
validates :web_hook, presence: true
+ before_save :obfuscate_basic_auth
+
def self.recent
where('created_at >= ?', 2.days.ago.beginning_of_day)
.order(created_at: :desc)
@@ -17,4 +22,10 @@ class WebHookLog < ApplicationRecord
def success?
response_status =~ /^2/
end
+
+ private
+
+ def obfuscate_basic_auth
+ self.url = safe_url
+ end
end
diff --git a/app/models/import_failure.rb b/app/models/import_failure.rb
new file mode 100644
index 00000000000..998572853d3
--- /dev/null
+++ b/app/models/import_failure.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class ImportFailure < ApplicationRecord
+ belongs_to :project
+
+ validates :project, presence: true
+end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 948cadc34e5..88df3baa809 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -14,6 +14,7 @@ class Issue < ApplicationRecord
include TimeTrackable
include ThrottledTouch
include LabelEventable
+ include IgnorableColumns
DueDateStruct = Struct.new(:title, :name).freeze
NoDueDate = DueDateStruct.new('No Due Date', '0').freeze
@@ -41,6 +42,10 @@ 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_one :sentry_issue
+
+ accepts_nested_attributes_for :sentry_issue
validates :project, presence: true
@@ -60,13 +65,15 @@ class Issue < ApplicationRecord
scope :order_closest_future_date, -> { reorder(Arel.sql('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC')) }
scope :order_relative_position_asc, -> { reorder(::Gitlab::Database.nulls_last_order('relative_position', 'ASC')) }
- scope :preload_associations, -> { preload(:labels, project: :namespace) }
+ scope :preload_associated_models, -> { preload(:labels, project: :namespace) }
scope :with_api_entity_associations, -> { preload(:timelogs, :assignees, :author, :notes, :labels, project: [:route, { namespace: :route }] ) }
scope :public_only, -> { where(confidential: false) }
scope :confidential_only, -> { where(confidential: true) }
- scope :counts_by_state, -> { reorder(nil).group(:state).count }
+ scope :counts_by_state, -> { reorder(nil).group(:state_id).count }
+
+ ignore_column :state, remove_with: '12.7', remove_after: '2019-12-22'
after_commit :expire_etag_cache
after_save :ensure_metrics, unless: :imported?
@@ -74,7 +81,7 @@ class Issue < ApplicationRecord
attr_spammable :title, spam_title: true
attr_spammable :description, spam_description: true
- state_machine :state_id, initial: :opened do
+ state_machine :state_id, initial: :opened, initialize: false do
event :close do
transition [:opened] => :closed
end
@@ -235,7 +242,7 @@ class Issue < ApplicationRecord
return false unless readable_by?(user)
- user.full_private_access? ||
+ user.can_read_all_resources? ||
::Gitlab::ExternalAuthorization.access_allowed?(
user, project.external_authorization_classification_label)
end
diff --git a/app/models/issue/metrics.rb b/app/models/issue/metrics.rb
index 8010cbc3d78..d4e51dcfbca 100644
--- a/app/models/issue/metrics.rb
+++ b/app/models/issue/metrics.rb
@@ -3,6 +3,12 @@
class Issue::Metrics < ApplicationRecord
belongs_to :issue
+ scope :for_issues, ->(issues) { where(issue: issues) }
+ scope :with_first_mention_not_earlier_than, -> (timestamp) {
+ where(first_mentioned_in_commit_at: nil)
+ .or(where(arel_table['first_mentioned_in_commit_at'].gteq(timestamp)))
+ }
+
def record!
if issue.milestone_id.present? && self.first_associated_with_milestone_at.blank?
self.first_associated_with_milestone_at = Time.now
diff --git a/app/models/issue_user_mention.rb b/app/models/issue_user_mention.rb
new file mode 100644
index 00000000000..3eadd580f7f
--- /dev/null
+++ b/app/models/issue_user_mention.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class IssueUserMention < UserMention
+ belongs_to :issue
+ belongs_to :note
+end
diff --git a/app/models/key.rb b/app/models/key.rb
index ff601966c26..e549c59b58f 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -5,6 +5,9 @@ require 'digest/md5'
class Key < ApplicationRecord
include AfterCommitQueue
include Sortable
+ include Sha256Attribute
+
+ sha256_attribute :fingerprint_sha256
belongs_to :user
@@ -34,6 +37,12 @@ class Key < ApplicationRecord
after_destroy :post_destroy_hook
after_destroy :refresh_user_cache
+ alias_attribute :fingerprint_md5, :fingerprint
+
+ scope :preload_users, -> { preload(:user) }
+ scope :for_user, -> (user) { where(user: user) }
+ scope :order_last_used_at_desc, -> { reorder(::Gitlab::Database.nulls_last_order('last_used_at', 'DESC')) }
+
def self.regular_keys
where(type: ['Key', nil])
end
@@ -114,10 +123,12 @@ class Key < ApplicationRecord
def generate_fingerprint
self.fingerprint = nil
+ self.fingerprint_sha256 = nil
return unless public_key.valid?
- self.fingerprint = public_key.fingerprint
+ self.fingerprint_md5 = public_key.fingerprint
+ self.fingerprint_sha256 = public_key.fingerprint("SHA256").gsub("SHA256:", "")
end
def key_meets_restrictions
diff --git a/app/models/list.rb b/app/models/list.rb
index 13c42b55bf7..b2ba796e3dc 100644
--- a/app/models/list.rb
+++ b/app/models/list.rb
@@ -21,7 +21,7 @@ class List < ApplicationRecord
scope :destroyable, -> { where(list_type: list_types.slice(*destroyable_types).values) }
scope :movable, -> { where(list_type: list_types.slice(*movable_types).values) }
- scope :preload_associations, -> { preload(:board, label: :priorities) }
+ scope :preload_associated_models, -> { preload(:board, label: :priorities) }
scope :ordered, -> { order(:list_type, :position) }
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 7e1898e7142..2280c5280d5 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -17,6 +17,7 @@ class MergeRequest < ApplicationRecord
include FromUnion
include DeprecatedAssignee
include ShaAttribute
+ include IgnorableColumns
sha_attribute :squash_commit_sha
@@ -26,8 +27,6 @@ class MergeRequest < ApplicationRecord
SORTING_PREFERENCE_FIELD = :merge_requests_sort
- prepend_if_ee('::EE::MergeRequest') # rubocop: disable Cop/InjectEnterpriseEditionModule
-
belongs_to :target_project, class_name: "Project"
belongs_to :source_project, class_name: "Project"
belongs_to :merge_user, class_name: "User"
@@ -72,6 +71,15 @@ 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 :deployment_merge_requests
+
+ # These are deployments created after the merge request has been merged, and
+ # the merge request was tracked explicitly (instead of implicitly using a CI
+ # build).
+ has_many :deployments,
+ through: :deployment_merge_requests
KNOWN_MERGE_PARAMS = [
:auto_merge_strategy,
@@ -103,7 +111,7 @@ class MergeRequest < ApplicationRecord
super + [:merged, :locked]
end
- state_machine :state_id, initial: :opened do
+ state_machine :state_id, initial: :opened, initialize: false do
event :close do
transition [:opened] => :closed
end
@@ -199,6 +207,9 @@ class MergeRequest < ApplicationRecord
scope :by_milestone, ->(milestone) { where(milestone_id: milestone) }
scope :of_projects, ->(ids) { where(target_project_id: ids) }
scope :from_project, ->(project) { where(source_project_id: project.id) }
+ scope :from_and_to_forks, ->(project) do
+ where('source_project_id <> target_project_id AND (source_project_id = ? OR target_project_id = ?)', project.id, project.id)
+ end
scope :merged, -> { with_state(:merged) }
scope :closed_and_merged, -> { with_states(:closed, :merged) }
scope :open_and_closed, -> { with_states(:opened, :closed) }
@@ -228,7 +239,9 @@ class MergeRequest < ApplicationRecord
with_state(:opened).where(auto_merge_enabled: true)
end
- after_save :keep_around_commit
+ ignore_column :state, remove_with: '12.7', remove_after: '2019-12-22'
+
+ after_save :keep_around_commit, unless: :importing?
alias_attribute :project, :target_project
alias_attribute :project_id, :target_project_id
@@ -241,6 +254,9 @@ class MergeRequest < ApplicationRecord
alias_attribute :auto_merge_enabled, :merge_when_pipeline_succeeds
alias_method :issuing_parent, :target_project
+ delegate :active?, to: :head_pipeline, prefix: true, allow_nil: true
+ delegate :success?, to: :actual_head_pipeline, prefix: true, allow_nil: true
+
RebaseLockTimeout = Class.new(StandardError)
REBASE_LOCK_MESSAGE = _("Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later.")
@@ -260,7 +276,7 @@ class MergeRequest < ApplicationRecord
def self.recent_target_branches(limit: 100)
group(:target_branch)
.select(:target_branch)
- .reorder('MAX(merge_requests.updated_at) DESC')
+ .reorder(arel_table[:updated_at].maximum.desc)
.limit(limit)
.pluck(:target_branch)
end
@@ -414,15 +430,6 @@ class MergeRequest < ApplicationRecord
limit ? shas.take(limit) : shas
end
- # Returns true if there are commits that match at least one commit SHA.
- def includes_any_commits?(shas)
- if persisted?
- merge_request_diff.commits_by_shas(shas).exists?
- else
- (commit_shas & shas).present?
- end
- end
-
def supports_suggestion?
true
end
@@ -1060,7 +1067,7 @@ class MergeRequest < ApplicationRecord
# Returns the oldest multi-line commit message, or the MR title if none found
def default_squash_commit_message
strong_memoize(:default_squash_commit_message) do
- commits.without_merge_commits.reverse.find(&:description?)&.safe_message || title
+ recent_commits.without_merge_commits.reverse_each.find(&:description?)&.safe_message || title
end
end
@@ -1143,26 +1150,6 @@ class MergeRequest < ApplicationRecord
actual_head_pipeline.environments
end
- def state_human_name
- if merged?
- "Merged"
- elsif closed?
- "Closed"
- else
- "Open"
- end
- end
-
- def state_icon_name
- if merged?
- "git-merge"
- elsif closed?
- "close"
- else
- "issue-open-m"
- end
- end
-
def fetch_ref!
target_project.repository.fetch_source_branch!(source_project.repository, source_branch, ref_path)
end
@@ -1239,16 +1226,8 @@ class MergeRequest < ApplicationRecord
end
def all_pipelines
- return Ci::Pipeline.none unless source_project
-
- shas = all_commit_shas
-
strong_memoize(:all_pipelines) do
- Ci::Pipeline.from_union(
- [source_project.ci_pipelines.merge_request_pipelines(self, shas),
- source_project.ci_pipelines.detached_merge_request_pipelines(self, shas),
- source_project.ci_pipelines.triggered_for_branch(source_branch).for_sha(shas)],
- remove_duplicates: false).sort_by_merge_request_pipelines
+ MergeRequest::Pipelines.new(self).all
end
end
@@ -1444,6 +1423,12 @@ class MergeRequest < ApplicationRecord
true
end
+ def pipeline_coverage_delta
+ if base_pipeline&.coverage && head_pipeline&.coverage
+ '%.2f' % (head_pipeline.coverage.to_f - base_pipeline.coverage.to_f)
+ end
+ end
+
def base_pipeline
@base_pipeline ||= project.ci_pipelines
.order(id: :desc)
@@ -1499,6 +1484,14 @@ class MergeRequest < ApplicationRecord
all_pipelines.for_sha_or_source_sha(diff_head_sha).first
end
+ def etag_caching_enabled?
+ true
+ end
+
+ def recent_visible_deployments
+ deployments.visible.includes(:environment).order(id: :desc).limit(10)
+ end
+
private
def with_rebase_lock
@@ -1521,7 +1514,7 @@ class MergeRequest < ApplicationRecord
end
end
rescue ActiveRecord::LockWaitTimeout => e
- Gitlab::Sentry.track_acceptable_exception(e)
+ Gitlab::ErrorTracking.track_exception(e)
raise RebaseLockTimeout, REBASE_LOCK_MESSAGE
end
@@ -1543,3 +1536,5 @@ class MergeRequest < ApplicationRecord
Gitlab::EtagCaching::Store.new.touch(key)
end
end
+
+MergeRequest.prepend_if_ee('::EE::MergeRequest')
diff --git a/app/models/merge_request/pipelines.rb b/app/models/merge_request/pipelines.rb
new file mode 100644
index 00000000000..c32f29a9304
--- /dev/null
+++ b/app/models/merge_request/pipelines.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+# A state object to centralize logic related to merge request pipelines
+class MergeRequest::Pipelines
+ include Gitlab::Utils::StrongMemoize
+
+ EVENT = 'merge_request_event'
+
+ def initialize(merge_request)
+ @merge_request = merge_request
+ end
+
+ attr_reader :merge_request
+
+ delegate :commit_shas, :source_project, :source_branch, to: :merge_request
+
+ def all
+ strong_memoize(:all_pipelines) do
+ next Ci::Pipeline.none unless source_project
+
+ pipelines =
+ if merge_request.persisted?
+ pipelines_using_cte
+ else
+ triggered_for_branch.for_sha(commit_shas)
+ end
+
+ sort(pipelines)
+ end
+ end
+
+ private
+
+ def pipelines_using_cte
+ cte = Gitlab::SQL::CTE.new(:shas, merge_request.all_commits.select(:sha))
+
+ source_pipelines_join = cte.table[:sha].eq(Ci::Pipeline.arel_table[:source_sha])
+ source_pipelines = filter_by(triggered_by_merge_request, cte, source_pipelines_join)
+ detached_pipelines = filter_by_sha(triggered_by_merge_request, cte)
+ pipelines_for_branch = filter_by_sha(triggered_for_branch, cte)
+
+ Ci::Pipeline.with(cte.to_arel)
+ .from_union([source_pipelines, detached_pipelines, pipelines_for_branch])
+ end
+
+ def filter_by_sha(pipelines, cte)
+ hex = Arel::Nodes::SqlLiteral.new("'hex'")
+ string_sha = Arel::Nodes::NamedFunction.new('encode', [cte.table[:sha], hex])
+ join_condition = string_sha.eq(Ci::Pipeline.arel_table[:sha])
+
+ filter_by(pipelines, cte, join_condition)
+ end
+
+ def filter_by(pipelines, cte, join_condition)
+ shas_table =
+ Ci::Pipeline.arel_table
+ .join(cte.table, Arel::Nodes::InnerJoin)
+ .on(join_condition)
+ .join_sources
+
+ pipelines.joins(shas_table)
+ end
+
+ def triggered_by_merge_request
+ source_project.ci_pipelines
+ .where(source: :merge_request_event, merge_request: merge_request)
+ end
+
+ def triggered_for_branch
+ source_project.ci_pipelines
+ .where(source: branch_pipeline_sources, ref: source_branch, tag: false)
+ end
+
+ def branch_pipeline_sources
+ strong_memoize(:branch_pipeline_sources) do
+ Ci::Pipeline.sources.reject { |source| source == EVENT }.values
+ end
+ end
+
+ def sort(pipelines)
+ sql = 'CASE ci_pipelines.source WHEN (?) THEN 0 ELSE 1 END, ci_pipelines.id DESC'
+ query = ApplicationRecord.send(:sanitize_sql_array, [sql, Ci::Pipeline.sources[:merge_request_event]]) # rubocop:disable GitlabSecurity/PublicSend
+
+ pipelines.order(Arel.sql(query))
+ end
+end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 70ce4df5678..71a344e69e3 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -10,6 +10,7 @@ class MergeRequestDiff < ApplicationRecord
# Don't display more than 100 commits at once
COMMITS_SAFE_SIZE = 100
+ BATCH_SIZE = 1000
# Applies to closed or merged MRs when determining whether to migrate their
# diffs to external storage
@@ -49,13 +50,14 @@ class MergeRequestDiff < ApplicationRecord
scope :by_commit_sha, ->(sha) do
joins(:merge_request_diff_commits).where(merge_request_diff_commits: { sha: sha }).reorder(nil)
end
+ scope :has_diff_files, -> { where(id: MergeRequestDiffFile.select(:merge_request_diff_id)) }
scope :by_project_id, -> (project_id) do
joins(:merge_request).where(merge_requests: { target_project_id: project_id })
end
scope :recent, -> { order(id: :desc).limit(100) }
- scope :files_in_database, -> { where(stored_externally: [false, nil]) }
+ scope :files_in_database, -> { has_diff_files.where(stored_externally: [false, nil]) }
scope :not_latest_diffs, -> do
merge_requests = MergeRequest.arel_table
@@ -162,7 +164,7 @@ class MergeRequestDiff < ApplicationRecord
# hooks that run when an attribute was changed are run twice.
reset
- keep_around_commits
+ keep_around_commits unless importing?
end
def set_as_latest_diff
@@ -253,10 +255,14 @@ class MergeRequestDiff < ApplicationRecord
merge_request_diff_commits.limit(limit).pluck(:sha)
end
- def commits_by_shas(shas)
- return MergeRequestDiffCommit.none unless shas.present?
+ def includes_any_commits?(shas)
+ return false if shas.blank?
- merge_request_diff_commits.where(sha: shas)
+ # when the number of shas is huge (1000+) we don't want
+ # to pass them all as an SQL param, let's pass them in batches
+ shas.each_slice(BATCH_SIZE).any? do |batched_shas|
+ merge_request_diff_commits.where(sha: batched_shas).exists?
+ end
end
def diff_refs=(new_diff_refs)
@@ -303,20 +309,25 @@ class MergeRequestDiff < ApplicationRecord
end
def diffs_in_batch(batch_page, batch_size, diff_options:)
- Gitlab::Diff::FileCollection::MergeRequestDiffBatch.new(self,
- batch_page,
- batch_size,
- diff_options: diff_options)
+ fetching_repository_diffs(diff_options) do |comparison|
+ if comparison
+ comparison.diffs_in_batch(batch_page, batch_size, diff_options: diff_options)
+ else
+ diffs_in_batch_collection(batch_page, batch_size, diff_options: diff_options)
+ end
+ end
end
def diffs(diff_options = nil)
- if without_files? && comparison = diff_refs&.compare_in(project)
+ fetching_repository_diffs(diff_options) do |comparison|
# It should fetch the repository when diffs are cleaned by the system.
# We don't keep these for storage overload purposes.
# See https://gitlab.com/gitlab-org/gitlab-foss/issues/37639
- comparison.diffs(diff_options)
- else
- diffs_collection(diff_options)
+ if comparison
+ comparison.diffs(diff_options)
+ else
+ diffs_collection(diff_options)
+ end
end
end
@@ -424,6 +435,13 @@ class MergeRequestDiff < ApplicationRecord
private
+ def diffs_in_batch_collection(batch_page, batch_size, diff_options:)
+ Gitlab::Diff::FileCollection::MergeRequestDiffBatch.new(self,
+ batch_page,
+ batch_size,
+ diff_options: diff_options)
+ end
+
def encode_in_base64?(diff_text)
(diff_text.encoding == Encoding::BINARY && !diff_text.ascii_only?) ||
diff_text.include?("\0")
@@ -481,6 +499,25 @@ class MergeRequestDiff < ApplicationRecord
end
end
+ # Yields the block with the repository Compare object if it should
+ # fetch diffs from the repository instead DB.
+ def fetching_repository_diffs(diff_options)
+ return unless block_given?
+
+ diff_options ||= {}
+
+ # Can be read as: fetch the persisted diffs if yielded without the
+ # Compare object.
+ return yield unless without_files? || diff_options[:ignore_whitespace_change]
+ return yield unless diff_refs&.complete?
+
+ comparison = diff_refs.compare_in(repository.project)
+
+ return yield unless comparison
+
+ yield(comparison)
+ end
+
def use_external_diff?
return false unless has_attribute?(:external_diff)
return false unless Gitlab.config.external_diffs.enabled
diff --git a/app/models/merge_request_user_mention.rb b/app/models/merge_request_user_mention.rb
new file mode 100644
index 00000000000..222d9c1aa8c
--- /dev/null
+++ b/app/models/merge_request_user_mention.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class MergeRequestUserMention < UserMention
+ belongs_to :merge_request
+ belongs_to :note
+end
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index d0be54eed02..987373aaf1b 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -274,6 +274,16 @@ class Milestone < ApplicationRecord
project_id.present?
end
+ def merge_requests_enabled?
+ if group_milestone?
+ # Assume that groups have at least one project with merge requests enabled.
+ # Otherwise, we would need to load all of the projects from the database.
+ true
+ elsif project_milestone?
+ project&.merge_requests_enabled?
+ end
+ end
+
private
# Milestone titles must be unique across project milestones and group milestones
@@ -331,6 +341,6 @@ class Milestone < ApplicationRecord
end
def issues_finder_params
- { project_id: project_id, group_id: group_id }.compact
+ { project_id: project_id, group_id: group_id, include_subgroups: group_id.present? }.compact
end
end
diff --git a/app/models/milestone_release.rb b/app/models/milestone_release.rb
index f7127df339d..713c8ef7b94 100644
--- a/app/models/milestone_release.rb
+++ b/app/models/milestone_release.rb
@@ -6,9 +6,6 @@ class MilestoneRelease < ApplicationRecord
validate :same_project_between_milestone_and_release
- # Keep until 2019-11-29
- self.ignored_columns += %i[id]
-
private
def same_project_between_milestone_and_release
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 5663ebf8ba1..d5a7c172fec 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -123,8 +123,10 @@ class Namespace < ApplicationRecord
def find_by_pages_host(host)
gitlab_host = "." + Settings.pages.host.downcase
- name = host.downcase.delete_suffix(gitlab_host)
+ host = host.downcase
+ return unless host.ends_with?(gitlab_host)
+ name = host.delete_suffix(gitlab_host)
Namespace.find_by_full_path(name)
end
end
diff --git a/app/models/note.rb b/app/models/note.rb
index 493132e30cc..cfa7ba98081 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -155,9 +155,9 @@ class Note < ApplicationRecord
after_initialize :ensure_discussion_id
before_validation :nullify_blank_type, :nullify_blank_line_code
before_validation :set_discussion_id, on: :create
- after_save :keep_around_commit, if: :for_project_noteable?
- after_save :expire_etag_cache
- after_save :touch_noteable
+ after_save :keep_around_commit, if: :for_project_noteable?, unless: :importing?
+ after_save :expire_etag_cache, unless: :importing?
+ after_save :touch_noteable, unless: :importing?
after_destroy :expire_etag_cache
class << self
@@ -413,6 +413,10 @@ class Note < ApplicationRecord
full_discussion || to_discussion
end
+ def start_of_discussion?
+ discussion.first_note == self
+ end
+
def part_of_discussion?
!to_discussion.individual_note?
end
@@ -495,8 +499,18 @@ class Note < ApplicationRecord
project
end
+ def user_mentions
+ noteable.user_mentions.where(note: self)
+ end
+
private
+ # 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
+ user_mentions.first_or_initialize
+ end
+
def system_note_viewable_by?(user)
return true unless system_note_metadata
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index 3869d86b667..dd2cafd9a35 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -6,6 +6,7 @@ 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
belongs_to :project
has_many :acme_orders, class_name: "PagesDomainAcmeOrder"
@@ -25,6 +26,8 @@ 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 :wildcard, allow_nil: false, value: false
attr_encrypted :key,
mode: :per_attribute_iv_and_salt,
@@ -217,6 +220,8 @@ class PagesDomain < ApplicationRecord
# rubocop: disable CodeReuse/ServiceClass
def update_daemon
+ return if domain_type_instance?
+
::Projects::UpdatePagesConfigurationService.new(project).execute
end
# rubocop: enable CodeReuse/ServiceClass
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index 7ae431eaad7..af079f7ebc4 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -3,6 +3,7 @@
class PersonalAccessToken < ApplicationRecord
include Expirable
include TokenAuthenticatable
+ include Sortable
add_authentication_token_field :token, digest: true
@@ -16,9 +17,12 @@ class PersonalAccessToken < ApplicationRecord
before_save :ensure_token
scope :active, -> { where("revoked = false AND (expires_at >= NOW() OR expires_at IS NULL)") }
+ scope :expiring_and_not_notified, ->(date) { where(["revoked = false AND expire_notification_delivered = false AND expires_at >= NOW() AND expires_at <= ?", date]) }
scope :inactive, -> { where("revoked = true OR expires_at < NOW()") }
scope :with_impersonation, -> { where(impersonation: true) }
scope :without_impersonation, -> { where(impersonation: false) }
+ scope :for_user, -> (user) { where(user: user) }
+ scope :preload_users, -> { preload(:user) }
validates :scopes, presence: true
validate :validate_scopes
@@ -70,3 +74,5 @@ class PersonalAccessToken < ApplicationRecord
"gitlab:personal_access_token:#{user_id}"
end
end
+
+PersonalAccessToken.prepend_if_ee('EE::PersonalAccessToken')
diff --git a/app/models/project.rb b/app/models/project.rb
index 7ae4e2a4cd7..cfdcdbed502 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -31,6 +31,7 @@ class Project < ApplicationRecord
include FeatureGate
include OptionallySearch
include FromUnion
+ include IgnorableColumns
extend Gitlab::Cache::RequestCache
extend Gitlab::ConfigHelper
@@ -62,23 +63,9 @@ class Project < ApplicationRecord
cache_markdown_field :description, pipeline: :description
- delegate :feature_available?, :builds_enabled?, :wiki_enabled?, :merge_requests_enabled?,
- :issues_enabled?, :pages_enabled?, :public_pages?, :private_pages?,
- :merge_requests_access_level, :issues_access_level, :wiki_access_level,
- :snippets_access_level, :builds_access_level, :repository_access_level,
- to: :project_feature, allow_nil: true
-
- delegate :base_dir, :disk_path, to: :storage
-
- delegate :scheduled?, :started?, :in_progress?,
- :failed?, :finished?,
- prefix: :import, to: :import_state, allow_nil: true
-
- delegate :no_import?, to: :import_state, allow_nil: true
-
# TODO: remove once GitLab 12.5 is released
# https://gitlab.com/gitlab-org/gitlab/issues/34638
- self.ignored_columns += %i[merge_requests_require_code_owner_approval]
+ ignore_column :merge_requests_require_code_owner_approval, remove_after: '2019-12-01', remove_with: '12.6'
default_value_for :archived, false
default_value_for :resolve_outdated_diff_discussions, false
@@ -110,8 +97,11 @@ class Project < ApplicationRecord
unless: :ci_cd_settings,
if: proc { ProjectCiCdSetting.available? }
+ after_create :create_container_expiration_policy,
+ unless: :container_expiration_policy
+
after_create :create_pages_metadatum,
- unless: :pages_metadatum
+ unless: :pages_metadatum
after_create :set_timestamps_for_create
after_update :update_forks_visibility_level
@@ -183,6 +173,7 @@ class Project < ApplicationRecord
has_one :microsoft_teams_service
has_one :packagist_service
has_one :hangouts_chat_service
+ has_one :unify_circuit_service
has_one :root_of_fork_network,
foreign_key: 'root_project_id',
@@ -193,6 +184,7 @@ class Project < ApplicationRecord
has_one :forked_from_project, through: :fork_network_member
has_many :forked_to_members, class_name: 'ForkNetworkMember', foreign_key: 'forked_from_project_id'
has_many :forks, through: :forked_to_members, source: :project, inverse_of: :forked_from_project
+ has_many :fork_network_projects, through: :fork_network, source: :projects
has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project
has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -259,6 +251,7 @@ class Project < ApplicationRecord
# which is not managed by the DB. Hence we're still using dependent: :destroy
# here.
has_many :container_repositories, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_one :container_expiration_policy, inverse_of: :project
has_many :commit_statuses
# The relation :all_pipelines is intended to be used when we want to get the
@@ -309,11 +302,14 @@ class Project < ApplicationRecord
has_one :pages_metadatum, class_name: 'ProjectPagesMetadatum', inverse_of: :project
+ has_many :import_failures, inverse_of: :project
+
accepts_nested_attributes_for :variables, allow_destroy: true
accepts_nested_attributes_for :project_feature, update_only: true
accepts_nested_attributes_for :import_data
accepts_nested_attributes_for :auto_devops, update_only: true
accepts_nested_attributes_for :ci_cd_settings, update_only: true
+ accepts_nested_attributes_for :container_expiration_policy, update_only: true
accepts_nested_attributes_for :remote_mirrors,
allow_destroy: true,
@@ -323,13 +319,22 @@ class Project < ApplicationRecord
accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true
accepts_nested_attributes_for :grafana_integration, update_only: true, allow_destroy: true
+ delegate :feature_available?, :builds_enabled?, :wiki_enabled?, :merge_requests_enabled?,
+ :issues_enabled?, :pages_enabled?, :public_pages?, :private_pages?,
+ :merge_requests_access_level, :issues_access_level, :wiki_access_level,
+ :snippets_access_level, :builds_access_level, :repository_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
delegate :add_user, :add_users, to: :team
delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_role, to: :team
delegate :add_master, to: :team # @deprecated
delegate :group_runners_enabled, :group_runners_enabled=, :group_runners_enabled?, to: :ci_cd_settings
- delegate :root_ancestor, to: :namespace, allow_nil: true
+ delegate :root_ancestor, :actual_limits, to: :namespace, allow_nil: true
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
@@ -374,9 +379,17 @@ class Project < ApplicationRecord
scope :pending_delete, -> { where(pending_delete: true) }
scope :without_deleted, -> { where(pending_delete: false) }
- scope :with_storage_feature, ->(feature) { where('storage_version >= :version', version: HASHED_STORAGE_FEATURES[feature]) }
- scope :without_storage_feature, ->(feature) { where('storage_version < :version OR storage_version IS NULL', version: HASHED_STORAGE_FEATURES[feature]) }
- scope :with_unmigrated_storage, -> { where('storage_version < :version OR storage_version IS NULL', version: LATEST_STORAGE_VERSION) }
+ scope :with_storage_feature, ->(feature) do
+ where(arel_table[:storage_version].gteq(HASHED_STORAGE_FEATURES[feature]))
+ end
+ scope :without_storage_feature, ->(feature) do
+ where(arel_table[:storage_version].lt(HASHED_STORAGE_FEATURES[feature])
+ .or(arel_table[:storage_version].eq(nil)))
+ end
+ scope :with_unmigrated_storage, -> do
+ where(arel_table[:storage_version].lt(LATEST_STORAGE_VERSION)
+ .or(arel_table[:storage_version].eq(nil)))
+ end
# 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")) }
@@ -395,7 +408,9 @@ class Project < ApplicationRecord
scope :for_milestones, ->(ids) { joins(:milestones).where('milestones.id' => ids).distinct }
scope :with_push, -> { joins(:events).where('events.action = ?', Event::PUSHED) }
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_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) }
scope :inside_path, ->(path) do
@@ -435,6 +450,7 @@ class Project < ApplicationRecord
scope :with_merge_requests_available_for_user, ->(current_user) { with_feature_available_for_user(:merge_requests, current_user) }
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) }
scope :with_group_runners_enabled, -> do
joins(:ci_cd_settings)
@@ -543,7 +559,11 @@ class Project < ApplicationRecord
#
# query - The search query as a String.
def search(query)
- fuzzy_search(query, [:path, :name, :description])
+ if Feature.enabled?(:project_search_by_full_path, default_enabled: true)
+ joins(:route).fuzzy_search(query, [Route.arel_table[:path], :name, :description])
+ else
+ fuzzy_search(query, [:path, :name, :description])
+ end
end
def search_by_title(query)
@@ -720,6 +740,10 @@ class Project < ApplicationRecord
Feature.enabled?(:project_daily_statistics, self, default_enabled: true)
end
+ def unlink_forks_upon_visibility_decrease_enabled?
+ Feature.enabled?(:unlink_fork_network_upon_visibility_decrease, self, default_enabled: true)
+ end
+
def empty_repo?
repository.empty?
end
@@ -1239,8 +1263,9 @@ class Project < ApplicationRecord
def all_clusters
group_clusters = Clusters::Cluster.joins(:groups).where(cluster_groups: { group_id: ancestors_upto } )
+ instance_clusters = Clusters::Cluster.instance_type
- Clusters::Cluster.from_union([clusters, group_clusters])
+ Clusters::Cluster.from_union([clusters, group_clusters, instance_clusters])
end
def items_for(entity)
@@ -1538,6 +1563,7 @@ class Project < ApplicationRecord
# update visibility_level of forks
def update_forks_visibility_level
+ return if unlink_forks_upon_visibility_decrease_enabled?
return unless visibility_level < visibility_level_before_last_save
forks.each do |forked_project|
@@ -1557,7 +1583,9 @@ class Project < ApplicationRecord
end
def wiki
- @wiki ||= ProjectWiki.new(self, self.owner)
+ strong_memoize(:wiki) do
+ ProjectWiki.new(self, self.owner)
+ end
end
def jira_tracker_active?
@@ -1777,7 +1805,6 @@ class Project < ApplicationRecord
InternalId.flush_records!(project: self)
import_state.finish
- import_state.remove_jid
update_project_counter_caches
after_create_default_branch
join_pool_repository
@@ -1872,9 +1899,18 @@ class Project < ApplicationRecord
end
def predefined_variables
- visibility = Gitlab::VisibilityLevel.string_level(visibility_level)
+ Gitlab::Ci::Variables::Collection.new
+ .concat(predefined_ci_server_variables)
+ .concat(predefined_project_variables)
+ .concat(pages_variables)
+ .concat(container_registry_variables)
+ .concat(auto_devops_variables)
+ .concat(api_variables)
+ end
+ def predefined_project_variables
Gitlab::Ci::Variables::Collection.new
+ .append(key: 'GITLAB_FEATURES', value: licensed_features.join(','))
.append(key: 'CI_PROJECT_ID', value: id.to_s)
.append(key: 'CI_PROJECT_NAME', value: path)
.append(key: 'CI_PROJECT_TITLE', value: title)
@@ -1882,16 +1918,28 @@ class Project < ApplicationRecord
.append(key: 'CI_PROJECT_PATH_SLUG', value: full_path_slug)
.append(key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path)
.append(key: 'CI_PROJECT_URL', value: web_url)
- .append(key: 'CI_PROJECT_VISIBILITY', value: visibility)
+ .append(key: 'CI_PROJECT_VISIBILITY', value: Gitlab::VisibilityLevel.string_level(visibility_level))
.append(key: 'CI_PROJECT_REPOSITORY_LANGUAGES', value: repository_languages.map(&:name).join(',').downcase)
- .concat(pages_variables)
- .concat(container_registry_variables)
- .concat(auto_devops_variables)
- .concat(api_variables)
+ .append(key: 'CI_DEFAULT_BRANCH', value: default_branch)
+ end
+
+ def predefined_ci_server_variables
+ Gitlab::Ci::Variables::Collection.new
+ .append(key: 'CI', value: 'true')
+ .append(key: 'GITLAB_CI', value: 'true')
+ .append(key: 'CI_SERVER_HOST', value: Gitlab.config.gitlab.host)
+ .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)
+ .append(key: 'CI_SERVER_VERSION_MINOR', value: Gitlab.version_info.minor.to_s)
+ .append(key: 'CI_SERVER_VERSION_PATCH', value: Gitlab.version_info.patch.to_s)
+ .append(key: 'CI_SERVER_REVISION', value: Gitlab.revision)
end
def pages_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ break unless pages_enabled?
+
variables.append(key: 'CI_PAGES_DOMAIN', value: Gitlab.config.pages.host)
variables.append(key: 'CI_PAGES_URL', value: pages_url)
end
@@ -1957,12 +2005,16 @@ class Project < ApplicationRecord
end
end
- def deployment_variables(environment:)
+ def deployment_variables(environment:, kubernetes_namespace: nil)
platform = deployment_platform(environment: environment)
return [] unless platform.present?
- platform.predefined_variables(project: self, environment_name: environment)
+ platform.predefined_variables(
+ project: self,
+ environment_name: environment,
+ kubernetes_namespace: kubernetes_namespace
+ )
end
def auto_devops_variables
@@ -2027,10 +2079,16 @@ class Project < ApplicationRecord
end
def default_merge_request_target
- if forked_from_project&.merge_requests_enabled?
- forked_from_project
- else
+ return self unless forked_from_project
+ return self unless forked_from_project.merge_requests_enabled?
+
+ # When our current visibility is more restrictive than the source project,
+ # (e.g., the fork is `private` but the parent is `public`), target the less
+ # permissive project
+ if visibility_level_value < forked_from_project.visibility_level_value
self
+ else
+ forked_from_project
end
end
@@ -2227,12 +2285,13 @@ class Project < ApplicationRecord
# Git objects are only poolable when the project is or has:
# - Hashed storage -> The object pool will have a remote to its members, using relative paths.
# If the repository path changes we would have to update the remote.
- # - Public -> User will be able to fetch Git objects that might not exist
- # in their own repository.
+ # - not private -> The visibility level or repository access level has to be greater than private
+ # to prevent fetching objects that might not exist
# - Repository -> Else the disk path will be empty, and there's nothing to pool
def git_objects_poolable?
hashed_storage?(:repository) &&
- public? &&
+ visibility_level > Gitlab::VisibilityLevel::PRIVATE &&
+ repository_access_level > ProjectFeature::PRIVATE &&
repository_exists? &&
Gitlab::CurrentSettings.hashed_storage_enabled
end
diff --git a/app/models/project_auto_devops.rb b/app/models/project_auto_devops.rb
index e11d0c48b4b..275fe81583f 100644
--- a/app/models/project_auto_devops.rb
+++ b/app/models/project_auto_devops.rb
@@ -16,6 +16,7 @@ class ProjectAutoDevops < ApplicationRecord
def predefined_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ variables.append(key: 'AUTO_DEVOPS_EXPLICITLY_ENABLED', value: '1') if enabled?
variables.concat(deployment_strategy_default_variables)
end
end
diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb
index d089a004d3d..b292d39dae7 100644
--- a/app/models/project_ci_cd_setting.rb
+++ b/app/models/project_ci_cd_setting.rb
@@ -1,9 +1,10 @@
# frozen_string_literal: true
class ProjectCiCdSetting < ApplicationRecord
- # TODO: remove once GitLab 12.7 is released
+ include IgnorableColumns
# https://gitlab.com/gitlab-org/gitlab/issues/36651
- self.ignored_columns += %i[merge_trains_enabled]
+ 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.
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index caa65d32c86..4973c7761c1 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -186,7 +186,7 @@ class ProjectFeature < ApplicationRecord
def team_access?(user, feature)
return unless user
- return true if user.full_private_access?
+ return true if user.can_read_all_resources?
project.team.member?(user, ProjectFeature.required_minimum_access_level(feature))
end
diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb
index bff00816e15..b79e3554926 100644
--- a/app/models/project_import_state.rb
+++ b/app/models/project_import_state.rb
@@ -42,6 +42,14 @@ class ProjectImportState < ApplicationRecord
end
end
+ after_transition any => :finished do |state, _|
+ if state.jid.present?
+ Gitlab::SidekiqStatus.unset(state.jid)
+
+ state.update_column(:jid, nil)
+ end
+ end
+
after_transition started: :finished do |state, _|
project = state.project
@@ -81,14 +89,6 @@ class ProjectImportState < ApplicationRecord
status == 'started' && project.import?
end
- def remove_jid
- return unless jid
-
- Gitlab::SidekiqStatus.unset(jid)
-
- update_column(:jid, nil)
- end
-
# Refreshes the expiration time of the associated import job ID.
#
# This method can be used by asynchronous importers to refresh the status,
diff --git a/app/models/project_services/asana_service.rb b/app/models/project_services/asana_service.rb
index 757b2f17fb9..c4fcdcc05c5 100644
--- a/app/models/project_services/asana_service.rb
+++ b/app/models/project_services/asana_service.rb
@@ -24,7 +24,7 @@ get the commit comment added to it.
You can also close a task with a message containing: `fix #123456`.
You can create a Personal Access Token here:
-http://app.asana.com/-/account_api'
+https://app.asana.com/0/developer-console'
end
def self.to_param
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index ba61810e26f..128cbc6fa82 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -32,6 +32,10 @@ class JiraService < IssueTrackerService
%w(commit merge_request)
end
+ def self.supported_event_actions
+ %w(comment)
+ end
+
# {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1
def self.reference_pattern(only_long: true)
@reference_pattern ||= /(?<issue>\b#{Gitlab::Regex.jira_issue_key_regex})/
@@ -268,19 +272,27 @@ class JiraService < IssueTrackerService
return unless client_url.present?
jira_request do
- remote_link = find_remote_link(issue, remote_link_props[:object][:url])
- if remote_link
- remote_link.save!(remote_link_props)
- elsif issue.comments.build.save!(body: message)
- new_remote_link = issue.remotelink.build
- new_remote_link.save!(remote_link_props)
- end
+ create_issue_link(issue, remote_link_props)
+ create_issue_comment(issue, message)
log_info("Successfully posted", client_url: client_url)
"SUCCESS: Successfully posted to #{client_url}."
end
end
+ def create_issue_link(issue, remote_link_props)
+ remote_link = find_remote_link(issue, remote_link_props[:object][:url])
+ remote_link ||= issue.remotelink.build
+
+ remote_link.save!(remote_link_props)
+ end
+
+ def create_issue_comment(issue, message)
+ return unless comment_on_event_enabled
+
+ issue.comments.build.save!(body: message)
+ end
+
def find_remote_link(issue, url)
links = jira_request { issue.remotelink.all }
return unless links
diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb
index a0273fe0e5a..3d5967de41e 100644
--- a/app/models/project_services/prometheus_service.rb
+++ b/app/models/project_services/prometheus_service.rb
@@ -22,6 +22,8 @@ class PrometheusService < MonitoringService
after_save :clear_reactive_cache!
+ after_commit :track_events
+
def initialize_properties
if properties.nil?
self.properties = {}
@@ -86,13 +88,17 @@ class PrometheusService < MonitoringService
return false if template?
return false unless project
- project.clusters.enabled.any? { |cluster| cluster.application_prometheus_available? }
+ project.all_clusters.enabled.any? { |cluster| cluster.application_prometheus_available? }
end
def allow_local_api_url?
self_monitoring_project? && internal_prometheus_url?
end
+ def configured?
+ should_return_client?
+ end
+
private
def self_monitoring_project?
@@ -116,4 +122,22 @@ class PrometheusService < MonitoringService
true
end
+
+ def track_events
+ if enabled_manual_prometheus?
+ Gitlab::Tracking.event('cluster:services:prometheus', 'enabled_manual_prometheus')
+ elsif disabled_manual_prometheus?
+ Gitlab::Tracking.event('cluster:services:prometheus', 'disabled_manual_prometheus')
+ end
+
+ true
+ end
+
+ def enabled_manual_prometheus?
+ manual_configuration_changed? && manual_configuration?
+ end
+
+ def disabled_manual_prometheus?
+ manual_configuration_changed? && !manual_configuration?
+ end
end
diff --git a/app/models/project_services/unify_circuit_service.rb b/app/models/project_services/unify_circuit_service.rb
new file mode 100644
index 00000000000..06f2d10f83b
--- /dev/null
+++ b/app/models/project_services/unify_circuit_service.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+class UnifyCircuitService < ChatNotificationService
+ def title
+ 'Unify Circuit'
+ end
+
+ def description
+ 'Receive event notifications in Unify Circuit'
+ end
+
+ def self.to_param
+ 'unify_circuit'
+ end
+
+ def help
+ 'This service sends notifications about projects events to a Unify Circuit conversation.<br />
+ To set up this service:
+ <ol>
+ <li><a href="https://www.circuit.com/unifyportalfaqdetail?articleId=164448">Set up an incoming webhook for your conversation</a>. All notifications will come to this conversation.</li>
+ <li>Paste the <strong>Webhook URL</strong> into the field below.</li>
+ <li>Select events below to enable notifications.</li>
+ </ol>'
+ end
+
+ def event_field(event)
+ end
+
+ def default_channel_placeholder
+ end
+
+ def self.supported_events
+ %w[push issue confidential_issue merge_request note confidential_note tag_push
+ pipeline wiki_page]
+ end
+
+ def default_fields
+ [
+ { type: 'text', name: 'webhook', placeholder: "e.g. https://circuit.com/rest/v2/webhooks/incoming/…", required: true },
+ { type: 'checkbox', name: 'notify_only_broken_pipelines' },
+ { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES }
+ ]
+ end
+
+ private
+
+ def notify(message, opts)
+ response = Gitlab::HTTP.post(webhook, body: {
+ subject: message.project_name,
+ text: message.pretext,
+ markdown: true
+ }.to_json)
+
+ response if response.success?
+ end
+
+ def custom_data(data)
+ super(data).merge(markdown: true)
+ end
+end
diff --git a/app/models/project_snippet.rb b/app/models/project_snippet.rb
index e732c1bd86f..ffb08e10f1f 100644
--- a/app/models/project_snippet.rb
+++ b/app/models/project_snippet.rb
@@ -4,4 +4,5 @@ class ProjectSnippet < Snippet
belongs_to :project
validates :project, presence: true
+ validates :secret, inclusion: { in: [false] }
end
diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb
index f02ccd9e55e..48c96203921 100644
--- a/app/models/project_wiki.rb
+++ b/app/models/project_wiki.rb
@@ -58,7 +58,7 @@ class ProjectWiki
end
def wiki_base_path
- [Gitlab.config.gitlab.relative_url_root, '/', @project.full_path, '/wikis'].join('')
+ [Gitlab.config.gitlab.relative_url_root, '/', @project.full_path, '/-', '/wikis'].join('')
end
# Returns the Gitlab::Git::Wiki object.
diff --git a/app/models/readme_blob.rb b/app/models/readme_blob.rb
index 7b49fa632f6..695b4e3ffe3 100644
--- a/app/models/readme_blob.rb
+++ b/app/models/readme_blob.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class ReadmeBlob < SimpleDelegator
+ include BlobActiveModel
+
attr_reader :repository
def initialize(blob, repository)
diff --git a/app/models/release.rb b/app/models/release.rb
index 401e8359f47..4fac64689ab 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -73,6 +73,14 @@ class Release < ApplicationRecord
self.read_attribute(:name) || tag
end
+ def evidence_sha
+ evidence&.summary_sha
+ end
+
+ def evidence_summary
+ evidence&.summary || {}
+ end
+
private
def actual_sha
diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb
index c165a1a9b0d..1e5c93cd913 100644
--- a/app/models/remote_mirror.rb
+++ b/app/models/remote_mirror.rb
@@ -3,6 +3,7 @@
class RemoteMirror < ApplicationRecord
include AfterCommitQueue
include MirrorAuthentication
+ include SafeUrl
MAX_FIRST_RUNTIME = 3.hours
MAX_INCREMENTAL_RUNTIME = 1.hour
@@ -194,13 +195,7 @@ class RemoteMirror < ApplicationRecord
end
def safe_url
- return if url.nil?
-
- result = URI.parse(url)
- result.password = '*****' if result.password
- result.user = '*****' if result.user && result.user != 'git' # tokens or other data may be saved as user
- result.to_s
- rescue URI::Error
+ super(usernames_whitelist: %w[git])
end
def ensure_remote!
diff --git a/app/models/repository.rb b/app/models/repository.rb
index b9f57169ea5..2a67c26d840 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -925,7 +925,22 @@ class Repository
def ancestor?(ancestor_id, descendant_id)
return false if ancestor_id.nil? || descendant_id.nil?
- raw_repository.ancestor?(ancestor_id, descendant_id)
+ 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
+ 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)
@@ -1052,18 +1067,19 @@ class Repository
return rebase_deprecated(user, merge_request)
end
- MergeRequest.transaction do
- raw.rebase(
- 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
- ) do |commit_id|
- merge_request.update!(rebase_commit_sha: commit_id, merge_error: nil)
- end
+ raw.rebase(
+ 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
+ ) do |commit_id|
+ merge_request.update!(rebase_commit_sha: commit_id, merge_error: nil)
end
+ rescue StandardError => error
+ merge_request.update!(rebase_commit_sha: nil)
+ raise error
end
def squash(user, merge_request, message)
diff --git a/app/models/sentry_issue.rb b/app/models/sentry_issue.rb
new file mode 100644
index 00000000000..6be52f99562
--- /dev/null
+++ b/app/models/sentry_issue.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class SentryIssue < ApplicationRecord
+ belongs_to :issue
+
+ validates :issue, uniqueness: true, presence: true
+ validates :sentry_issue_identifier,
+ uniqueness: true,
+ presence: true
+end
diff --git a/app/models/serverless/domain_cluster.rb b/app/models/serverless/domain_cluster.rb
new file mode 100644
index 00000000000..a8365649dd1
--- /dev/null
+++ b/app/models/serverless/domain_cluster.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Serverless
+ class DomainCluster < ApplicationRecord
+ self.table_name = 'serverless_domain_cluster'
+
+ 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 }
+ end
+end
diff --git a/app/models/service.rb b/app/models/service.rb
index 6d5b974dd31..95b7c6927cf 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -155,6 +155,14 @@ class Service < ApplicationRecord
end
end
+ def configurable_event_actions
+ self.class.supported_event_actions
+ end
+
+ def self.supported_event_actions
+ %w()
+ end
+
def supported_events
self.class.supported_events
end
@@ -281,6 +289,7 @@ class Service < ApplicationRecord
slack
teamcity
microsoft_teams
+ unify_circuit
]
if Rails.env.development?
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 4010a3e2167..92746d28f05 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -37,6 +37,7 @@ class Snippet < ApplicationRecord
belongs_to :project
has_many :notes, as: :noteable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :user_mentions, class_name: "SnippetUserMention"
delegate :name, :email, to: :author, prefix: true, allow_nil: true
@@ -46,23 +47,42 @@ class Snippet < ApplicationRecord
length: { maximum: 255 }
validates :content, presence: true
+ validates :content,
+ length: {
+ maximum: ->(_) { Gitlab::CurrentSettings.snippet_size_limit },
+ message: -> (_, data) do
+ current_value = ActiveSupport::NumberHelper.number_to_human_size(data[:value].size)
+ max_size = ActiveSupport::NumberHelper.number_to_human_size(Gitlab::CurrentSettings.snippet_size_limit)
+
+ _("is too long (%{current_value}). The maximum size is %{max_size}.") % { current_value: current_value, max_size: max_size }
+ end
+ },
+ if: :content_changed?
+
validates :visibility_level, inclusion: { in: Gitlab::VisibilityLevel.values }
# Scopes
scope :are_internal, -> { where(visibility_level: Snippet::INTERNAL) }
scope :are_private, -> { where(visibility_level: Snippet::PRIVATE) }
- scope :are_public, -> { where(visibility_level: Snippet::PUBLIC) }
- scope :public_and_internal, -> { where(visibility_level: [Snippet::PUBLIC, Snippet::INTERNAL]) }
+ scope :are_public, -> { public_only }
+ scope :are_secret, -> { public_only.where(secret: true) }
scope :fresh, -> { order("created_at DESC") }
scope :inc_author, -> { includes(:author) }
scope :inc_relations_for_view, -> { includes(author: :status) }
+ attr_mentionable :description
+
participant :author
participant :notes_with_associations
attr_spammable :title, spam_title: true
attr_spammable :content, spam_description: true
+ attr_encrypted :secret_token,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ mode: :per_attribute_iv,
+ algorithm: 'aes-256-cbc'
+
def self.with_optional_visibility(value = nil)
if value
where(visibility_level: value)
@@ -112,11 +132,8 @@ class Snippet < ApplicationRecord
end
def self.visible_to_or_authored_by(user)
- where(
- 'snippets.visibility_level IN (?) OR snippets.author_id = ?',
- Gitlab::VisibilityLevel.levels_for_user(user),
- user.id
- )
+ query = where(visibility_level: Gitlab::VisibilityLevel.levels_for_user(user))
+ query.or(where(author_id: user.id))
end
def self.reference_prefix
@@ -222,6 +239,19 @@ class Snippet < ApplicationRecord
model_name.singular
end
+ def valid_secret_token?(token)
+ return false unless token && secret_token
+
+ ActiveSupport::SecurityUtils.secure_compare(token.to_s, secret_token.to_s)
+ end
+
+ def as_json(options = {})
+ options[:except] = Array.wrap(options[:except])
+ options[:except] << :secret_token
+
+ super
+ end
+
class << self
# Searches for snippets with a matching title or file name.
#
diff --git a/app/models/snippet_user_mention.rb b/app/models/snippet_user_mention.rb
new file mode 100644
index 00000000000..87ce77a5787
--- /dev/null
+++ b/app/models/snippet_user_mention.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class SnippetUserMention < UserMention
+ belongs_to :snippet
+ belongs_to :note
+end
diff --git a/app/models/timelog.rb b/app/models/timelog.rb
index 048134fbf04..4ddaf6bcb86 100644
--- a/app/models/timelog.rb
+++ b/app/models/timelog.rb
@@ -8,6 +8,18 @@ class Timelog < ApplicationRecord
belongs_to :merge_request, touch: true
belongs_to :user
+ scope :for_issues_in_group, -> (group) do
+ joins(:issue).where(
+ 'EXISTS (?)',
+ Project.select(1).where(namespace: group.self_and_descendants)
+ .where('issues.project_id = projects.id')
+ )
+ end
+
+ scope :between_dates, -> (start_date, end_date) do
+ where('spent_at BETWEEN ? AND ?', start_date, end_date)
+ end
+
def issuable
issue || merge_request
end
diff --git a/app/models/upload.rb b/app/models/upload.rb
index 8c409641452..46ae924bf8c 100644
--- a/app/models/upload.rb
+++ b/app/models/upload.rb
@@ -23,6 +23,21 @@ class Upload < ApplicationRecord
after_destroy :delete_file!, if: -> { uploader_class <= FileUploader }
class << self
+ def inner_join_local_uploads_projects
+ upload_table = Upload.arel_table
+ project_table = Project.arel_table
+
+ join_statement = upload_table.project(upload_table[Arel.star])
+ .join(project_table)
+ .on(
+ upload_table[:model_type].eq('Project')
+ .and(upload_table[:model_id].eq(project_table[:id]))
+ .and(upload_table[:store].eq(ObjectStorage::Store::LOCAL))
+ )
+
+ joins(join_statement.join_sources)
+ end
+
##
# FastDestroyAll concerns
def begin_fast_destroy
@@ -88,10 +103,8 @@ class Upload < ApplicationRecord
# Help sysadmins find missing upload files
if persisted? && !exist
- if Gitlab::Sentry.enabled?
- Raven.capture_message(_("Upload file does not exist"), extra: self.attributes)
- end
-
+ exception = RuntimeError.new("Uploaded file does not exist")
+ Gitlab::ErrorTracking.track_exception(exception, self.attributes)
Gitlab::Metrics.counter(:upload_file_does_not_exist_total, _('The number of times an upload record could not find its file')).increment
end
diff --git a/app/models/uploads/local.rb b/app/models/uploads/local.rb
index 2901c33c359..bd295a66838 100644
--- a/app/models/uploads/local.rb
+++ b/app/models/uploads/local.rb
@@ -23,7 +23,8 @@ module Uploads
unless in_uploads?(path)
message = "Path '#{path}' is not in uploads dir, skipping"
logger.warn(message)
- Gitlab::Sentry.track_exception(RuntimeError.new(message), extra: { uploads_dir: storage_dir })
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(
+ RuntimeError.new(message), uploads_dir: storage_dir)
return
end
diff --git a/app/models/user.rb b/app/models/user.rb
index d0e758b0055..18bf5ceaa0e 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -110,6 +110,10 @@ class User < ApplicationRecord
through: :group_members,
source: :group
alias_attribute :masters_groups, :maintainers_groups
+ has_many :reporter_developer_maintainer_owned_groups,
+ -> { where(members: { access_level: [Gitlab::Access::REPORTER, Gitlab::Access::DEVELOPER, Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) },
+ through: :group_members,
+ source: :group
# Projects
has_many :groups_projects, through: :groups, source: :projects
@@ -310,6 +314,13 @@ class User < ApplicationRecord
scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) }
scope :with_public_profile, -> { where(private_profile: false) }
+ scope :with_expiring_and_not_notified_personal_access_tokens, ->(at) do
+ where('EXISTS (?)',
+ ::PersonalAccessToken
+ .where('personal_access_tokens.user_id = users.id')
+ .expiring_and_not_notified(at).select(1))
+ end
+
def self.with_visible_profile(user)
return with_public_profile if user.nil?
@@ -370,6 +381,11 @@ class User < ApplicationRecord
# Class methods
#
class << self
+ # Devise method overridden to allow support for dynamic password lengths
+ def password_length
+ Gitlab::CurrentSettings.minimum_password_length..Devise.password_length.max
+ end
+
# Devise method overridden to allow sign in with email or username
def find_for_database_authentication(warden_conditions)
conditions = warden_conditions.dup
@@ -989,8 +1005,12 @@ class User < ApplicationRecord
@ldap_identity ||= identities.find_by(["provider LIKE ?", "ldap%"])
end
+ def matches_identity?(provider, extern_uid)
+ identities.where(provider: provider, extern_uid: extern_uid).exists?
+ end
+
def project_deploy_keys
- DeployKey.in_projects(authorized_projects.select(:id)).distinct(:id)
+ @project_deploy_keys ||= DeployKey.in_projects(authorized_projects.select(:id)).distinct(:id)
end
def highest_role
@@ -1453,9 +1473,7 @@ class User < ApplicationRecord
self.admin = (new_level == 'admin')
end
- # Does the user have access to all private groups & projects?
- # Overridden in EE to also check auditor?
- def full_private_access?
+ def can_read_all_resources?
can?(:read_all_resources)
end
diff --git a/app/models/user_callout_enums.rb b/app/models/user_callout_enums.rb
index e9f25d833d0..ef0b2407e23 100644
--- a/app/models/user_callout_enums.rb
+++ b/app/models/user_callout_enums.rb
@@ -14,7 +14,8 @@ module UserCalloutEnums
gke_cluster_integration: 1,
gcp_signup_offer: 2,
cluster_security_warning: 3,
- suggest_popover_dismissed: 9
+ suggest_popover_dismissed: 9,
+ tabs_position_highlight: 10
}
end
end
diff --git a/app/models/user_mention.rb b/app/models/user_mention.rb
new file mode 100644
index 00000000000..a85c6168cea
--- /dev/null
+++ b/app/models/user_mention.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class UserMention < ApplicationRecord
+ self.abstract_class = true
+
+ def has_mentions?
+ mentioned_users_ids.present? || mentioned_groups_ids.present? || mentioned_projects_ids.present?
+ end
+
+ private
+
+ def mentioned_users
+ User.where(id: mentioned_users_ids)
+ end
+
+ def mentioned_groups
+ Group.where(id: mentioned_groups_ids)
+ end
+
+ def mentioned_projects
+ Project.where(id: mentioned_projects_ids)
+ end
+end
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index f9c562364cb..c6867e48cbf 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -274,6 +274,10 @@ class WikiPage
@attributes.merge!(attrs)
end
+ def to_ability_name
+ 'wiki_page'
+ end
+
private
# Process and format the title based on the user input.
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index 8f5c6957a20..3a16f7dc239 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -40,6 +40,7 @@ class BasePolicy < DeclarativePolicy::Base
prevent :read_cross_project
end
+ # Policy extended in EE to also enable auditors
rule { admin }.enable :read_all_resources
rule { default }.enable :read_cross_project
diff --git a/app/policies/blob_policy.rb b/app/policies/blob_policy.rb
new file mode 100644
index 00000000000..639b9dfeea7
--- /dev/null
+++ b/app/policies/blob_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class BlobPolicy < BasePolicy
+ delegate { @subject.project }
+
+ rule { can?(:download_code) }.enable :read_blob
+end
diff --git a/app/policies/deploy_key_policy.rb b/app/policies/deploy_key_policy.rb
index 7f0ec011e79..b117bb57921 100644
--- a/app/policies/deploy_key_policy.rb
+++ b/app/policies/deploy_key_policy.rb
@@ -3,10 +3,7 @@
class DeployKeyPolicy < BasePolicy
with_options scope: :subject, score: 0
condition(:private_deploy_key) { @subject.private? }
-
- # rubocop: disable CodeReuse/ActiveRecord
- condition(:has_deploy_key) { @user.project_deploy_keys.exists?(id: @subject.id) }
- # rubocop: enable CodeReuse/ActiveRecord
+ condition(:has_deploy_key) { @user.project_deploy_keys.any? { |pdk| pdk.id.eql?(@subject.id) } }
rule { anonymous }.prevent_all
diff --git a/app/policies/error_tracking/detailed_error_policy.rb b/app/policies/error_tracking/detailed_error_policy.rb
new file mode 100644
index 00000000000..cb74242d46a
--- /dev/null
+++ b/app/policies/error_tracking/detailed_error_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module ErrorTracking
+ class DetailedErrorPolicy < BasePolicy
+ delegate { @subject.gitlab_project }
+ end
+end
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index eca73f0a241..f212bb06bc9 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -75,12 +75,15 @@ class GlobalPolicy < BasePolicy
rule { ~anonymous }.policy do
enable :read_instance_metadata
+ enable :create_personal_snippet
end
rule { admin }.policy do
enable :read_custom_attribute
enable :update_custom_attribute
end
+
+ rule { external_user }.prevent :create_personal_snippet
end
GlobalPolicy.prepend_if_ee('EE::GlobalPolicy')
diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb
index 91a8f3a7133..c2fcf1a1010 100644
--- a/app/policies/personal_snippet_policy.rb
+++ b/app/policies/personal_snippet_policy.rb
@@ -13,14 +13,10 @@ class PersonalSnippetPolicy < BasePolicy
rule { is_author | admin }.policy do
enable :read_personal_snippet
enable :update_personal_snippet
- enable :destroy_personal_snippet
enable :admin_personal_snippet
enable :create_note
end
- rule { ~anonymous }.enable :create_personal_snippet
- rule { external_user }.prevent :create_personal_snippet
-
rule { internal_snippet & ~external_user }.policy do
enable :read_personal_snippet
enable :create_note
@@ -31,4 +27,7 @@ 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
end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index ff70c6e6aeb..7b0297ea81b 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -262,6 +262,7 @@ class ProjectPolicy < BasePolicy
enable :update_container_image
enable :destroy_container_image
enable :create_environment
+ enable :update_environment
enable :create_deployment
enable :update_deployment
enable :create_release
@@ -278,8 +279,6 @@ class ProjectPolicy < BasePolicy
enable :admin_board
enable :push_to_delete_protected_branch
enable :update_project_snippet
- enable :update_environment
- enable :update_deployment
enable :admin_project_snippet
enable :admin_project_member
enable :admin_note
diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb
index d9d09eb04cd..a9094fbd958 100644
--- a/app/policies/project_snippet_policy.rb
+++ b/app/policies/project_snippet_policy.rb
@@ -38,6 +38,10 @@ class ProjectSnippetPolicy < BasePolicy
rule { public_snippet }.enable :read_project_snippet
+ rule { is_author & ~project.reporter & ~admin }.policy do
+ prevent :admin_project_snippet
+ end
+
rule { is_author | admin }.policy do
enable :read_project_snippet
enable :update_project_snippet
@@ -45,6 +49,9 @@ class ProjectSnippetPolicy < BasePolicy
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
end
ProjectSnippetPolicy.prepend_if_ee('EE::ProjectSnippetPolicy')
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index e1efd84e510..d092a2de882 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -10,6 +10,9 @@ class UserPolicy < BasePolicy
desc "The profile is private"
condition(:private_profile, scope: :subject, score: 0) { @subject.private_profile? }
+ desc "The user is blocked"
+ condition(:blocked_user, scope: :subject, score: 0) { @subject.blocked? }
+
rule { ~restricted_public_level }.enable :read_user
rule { ~anonymous }.enable :read_user
@@ -20,5 +23,5 @@ class UserPolicy < BasePolicy
end
rule { default }.enable :read_user_profile
- rule { private_profile & ~(user_is_self | admin) }.prevent :read_user_profile
+ rule { (private_profile | blocked_user) & ~(user_is_self | admin) }.prevent :read_user_profile
end
diff --git a/app/policies/wiki_page_policy.rb b/app/policies/wiki_page_policy.rb
new file mode 100644
index 00000000000..468632c9085
--- /dev/null
+++ b/app/policies/wiki_page_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class WikiPagePolicy < BasePolicy
+ delegate { @subject.wiki.project }
+
+ rule { can?(:read_wiki) }.enable :read_wiki_page
+end
diff --git a/app/presenters/ci/legacy_stage_presenter.rb b/app/presenters/ci/legacy_stage_presenter.rb
new file mode 100644
index 00000000000..56e268cff9f
--- /dev/null
+++ b/app/presenters/ci/legacy_stage_presenter.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Ci
+ class LegacyStagePresenter < Gitlab::View::Presenter::Delegated
+ presents :legacy_stage
+
+ def latest_ordered_statuses
+ preload_statuses(legacy_stage.statuses.latest_ordered)
+ end
+
+ def retried_ordered_statuses
+ preload_statuses(legacy_stage.statuses.retried_ordered)
+ end
+
+ private
+
+ def preload_statuses(statuses)
+ loaded_statuses = statuses.load
+ statuses.tap do |statuses|
+ # rubocop: disable CodeReuse/ActiveRecord
+ ActiveRecord::Associations::Preloader.new.preload(preloadable_statuses(loaded_statuses), %w[tags job_artifacts_archive metadata])
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+ end
+
+ def preloadable_statuses(statuses)
+ statuses.reject do |status|
+ status.instance_of?(::GenericCommitStatus) || status.instance_of?(::Ci::Bridge)
+ end
+ end
+ end
+end
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
index d81b1e6c522..f01ff56540a 100644
--- a/app/presenters/ci/pipeline_presenter.rb
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -8,7 +8,9 @@ module Ci
# We use a class method here instead of a constant, allowing EE to redefine
# the returned `Hash` more easily.
def self.failure_reasons
- { config_error: 'CI/CD YAML configuration error!' }
+ { unknown_failure: 'Unknown pipeline failure!',
+ config_error: 'CI/CD YAML configuration error!',
+ external_validation_failure: 'External pipeline validation failed!' }
end
presents :pipeline
diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb
index 2306f55f1f4..6b1d82e7557 100644
--- a/app/presenters/clusterable_presenter.rb
+++ b/app/presenters/clusterable_presenter.rb
@@ -29,18 +29,10 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated
new_polymorphic_path([clusterable, :cluster], options)
end
- def aws_api_proxy_path(resource)
- polymorphic_path([clusterable, :clusters], action: :aws_proxy, resource: resource)
- end
-
def authorize_aws_role_path
polymorphic_path([clusterable, :clusters], action: :authorize_aws_role)
end
- def revoke_aws_role_path
- polymorphic_path([clusterable, :clusters], action: :revoke_aws_role)
- end
-
def create_user_clusters_path
polymorphic_path([clusterable, :clusters], action: :create_user)
end
@@ -65,6 +57,10 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated
raise NotImplementedError
end
+ def clear_cluster_cache_path(cluster)
+ raise NotImplementedError
+ end
+
def cluster_path(cluster, params = {})
raise NotImplementedError
end
diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb
index 1634d2479a0..97771d84031 100644
--- a/app/presenters/clusters/cluster_presenter.rb
+++ b/app/presenters/clusters/cluster_presenter.rb
@@ -18,8 +18,20 @@ module Clusters
end
end
- def gke_cluster_url
- "https://console.cloud.google.com/kubernetes/clusters/details/#{provider.zone}/#{name}" if gcp?
+ def provider_label
+ if aws?
+ s_('ClusterIntegration|Elastic Kubernetes Service')
+ elsif gcp?
+ s_('ClusterIntegration|Google Kubernetes Engine')
+ end
+ end
+
+ def provider_management_url
+ if aws?
+ "https://console.aws.amazon.com/eks/home?region=#{provider.region}\#/clusters/#{name}"
+ elsif gcp?
+ "https://console.cloud.google.com/kubernetes/clusters/details/#{provider.zone}/#{name}"
+ end
end
def can_read_cluster?
diff --git a/app/presenters/conversational_development_index/metric_presenter.rb b/app/presenters/dev_ops_score/metric_presenter.rb
index 9639b84cf56..d22beefee54 100644
--- a/app/presenters/conversational_development_index/metric_presenter.rb
+++ b/app/presenters/dev_ops_score/metric_presenter.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-module ConversationalDevelopmentIndex
+module DevOpsScore
class MetricPresenter < Gitlab::View::Presenter::Simple
def cards
[
diff --git a/app/presenters/group_clusterable_presenter.rb b/app/presenters/group_clusterable_presenter.rb
index 54cea19b18e..21db2f6f96b 100644
--- a/app/presenters/group_clusterable_presenter.rb
+++ b/app/presenters/group_clusterable_presenter.rb
@@ -19,6 +19,11 @@ class GroupClusterablePresenter < ClusterablePresenter
update_applications_group_cluster_path(clusterable, cluster, application)
end
+ override :clear_cluster_cache_path
+ def clear_cluster_cache_path(cluster)
+ clear_cache_group_cluster_path(clusterable, cluster)
+ end
+
override :cluster_path
def cluster_path(cluster, params = {})
group_cluster_path(clusterable, cluster, params)
diff --git a/app/presenters/hooks/project_hook_presenter.rb b/app/presenters/hooks/project_hook_presenter.rb
new file mode 100644
index 00000000000..a65c7221b5a
--- /dev/null
+++ b/app/presenters/hooks/project_hook_presenter.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class ProjectHookPresenter < Gitlab::View::Presenter::Delegated
+ presents :project_hook
+
+ def logs_details_path(log)
+ project_hook_hook_log_path(project, self, log)
+ end
+
+ def logs_retry_path(log)
+ retry_project_hook_hook_log_path(project, self, log)
+ end
+end
diff --git a/app/presenters/hooks/service_hook_presenter.rb b/app/presenters/hooks/service_hook_presenter.rb
new file mode 100644
index 00000000000..bc20d5b1a3b
--- /dev/null
+++ b/app/presenters/hooks/service_hook_presenter.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class ServiceHookPresenter < Gitlab::View::Presenter::Delegated
+ presents :service_hook
+
+ def logs_details_path(log)
+ project_service_hook_log_path(service.project, service, log)
+ end
+
+ def logs_retry_path(log)
+ retry_project_service_hook_log_path(service.project, service, log)
+ end
+end
diff --git a/app/presenters/instance_clusterable_presenter.rb b/app/presenters/instance_clusterable_presenter.rb
index c6572e8ce71..0c267fd5735 100644
--- a/app/presenters/instance_clusterable_presenter.rb
+++ b/app/presenters/instance_clusterable_presenter.rb
@@ -37,6 +37,11 @@ class InstanceClusterablePresenter < ClusterablePresenter
update_applications_admin_cluster_path(cluster, application)
end
+ override :clear_cluster_cache_path
+ def clear_cluster_cache_path(cluster)
+ clear_cache_admin_cluster_path(cluster)
+ end
+
override :cluster_path
def cluster_path(cluster, params = {})
admin_cluster_path(cluster, params)
@@ -62,16 +67,6 @@ class InstanceClusterablePresenter < ClusterablePresenter
authorize_aws_role_admin_clusters_path
end
- override :revoke_aws_role_path
- def revoke_aws_role_path
- revoke_aws_role_admin_clusters_path
- end
-
- override :aws_api_proxy_path
- def aws_api_proxy_path(resource)
- aws_proxy_admin_clusters_path(resource: resource)
- end
-
override :empty_state_help_text
def empty_state_help_text
s_('ClusterIntegration|Adding an integration will share the cluster across all projects.')
diff --git a/app/presenters/project_clusterable_presenter.rb b/app/presenters/project_clusterable_presenter.rb
index 3fab69fff7a..5c56d42ed27 100644
--- a/app/presenters/project_clusterable_presenter.rb
+++ b/app/presenters/project_clusterable_presenter.rb
@@ -19,6 +19,11 @@ class ProjectClusterablePresenter < ClusterablePresenter
update_applications_project_cluster_path(clusterable, cluster, application)
end
+ override :clear_cluster_cache_path
+ def clear_cluster_cache_path(cluster)
+ clear_cache_project_cluster_path(clusterable, cluster)
+ end
+
override :cluster_path
def cluster_path(cluster, params = {})
project_cluster_path(clusterable, cluster, params)
diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb
index 9bb7fe13593..66211d02696 100644
--- a/app/presenters/projects/settings/deploy_keys_presenter.rb
+++ b/app/presenters/projects/settings/deploy_keys_presenter.rb
@@ -3,6 +3,8 @@
module Projects
module Settings
class DeployKeysPresenter < Gitlab::View::Presenter::Simple
+ include Gitlab::Utils::StrongMemoize
+
presents :project
delegate :size, to: :enabled_keys, prefix: true
delegate :size, to: :available_project_keys, prefix: true
@@ -13,37 +15,45 @@ module Projects
end
def enabled_keys
- project.deploy_keys
+ strong_memoize(:enabled_keys) do
+ project.deploy_keys.with_projects
+ end
end
def available_keys
- current_user
- .accessible_deploy_keys
- .id_not_in(enabled_keys.select(:id))
- .with_projects
+ strong_memoize(:available_keys) do
+ current_user
+ .accessible_deploy_keys
+ .id_not_in(enabled_keys.select(:id))
+ .with_projects
+ end
end
def available_project_keys
- current_user
- .project_deploy_keys
- .id_not_in(enabled_keys.select(:id))
- .with_projects
+ strong_memoize(:available_project_keys) do
+ current_user
+ .project_deploy_keys
+ .id_not_in(enabled_keys.select(:id))
+ .with_projects
+ end
end
def available_public_keys
- DeployKey
- .are_public
- .id_not_in(enabled_keys.select(:id))
- .id_not_in(available_project_keys.select(:id))
- .with_projects
+ strong_memoize(:available_public_keys) do
+ DeployKey
+ .are_public
+ .id_not_in(enabled_keys.select(:id))
+ .id_not_in(available_project_keys.select(:id))
+ .with_projects
+ end
end
def as_json
serializer = DeployKeySerializer.new # rubocop: disable CodeReuse/Serializer
- opts = { user: current_user, project: project }
+ opts = { user: current_user, project: project, readable_project_ids: readable_project_ids }
{
- enabled_keys: serializer.represent(enabled_keys.with_projects, opts),
+ enabled_keys: serializer.represent(enabled_keys, opts),
available_project_keys: serializer.represent(available_project_keys, opts),
public_keys: serializer.represent(available_public_keys, opts)
}
@@ -56,6 +66,26 @@ module Projects
def form_partial_path
'projects/deploy_keys/form'
end
+
+ private
+
+ # Caching all readable project ids for the user that are associated with the queried deploy keys
+ def readable_project_ids
+ strong_memoize(:readable_projects_by_id) do
+ Set.new(user_readable_project_ids)
+ end
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def user_readable_project_ids
+ project_ids = (available_keys + available_project_keys + available_public_keys)
+ .flat_map { |deploy_key| deploy_key.deploy_keys_projects.map(&:project_id) }
+ .compact
+ .uniq
+
+ current_user.authorized_projects(Gitlab::Access::GUEST).id_in(project_ids).pluck(:id)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
end
end
end
diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb
index 42463d6dbda..b38bbc8d96c 100644
--- a/app/presenters/release_presenter.rb
+++ b/app/presenters/release_presenter.rb
@@ -37,6 +37,12 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated
edit_project_release_url(project, release)
end
+ def evidence_file_path
+ return unless release.evidence.present?
+
+ evidence_project_release_url(project, tag, format: :json)
+ end
+
private
def can_download_code?
@@ -52,6 +58,6 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated
end
def release_edit_page_available?
- ::Feature.enabled?(:release_edit_page, project, default_enabled: true)
+ can?(current_user, :update_release, release)
end
end
diff --git a/app/presenters/sentry_detailed_error_presenter.rb b/app/presenters/sentry_detailed_error_presenter.rb
new file mode 100644
index 00000000000..9329f987879
--- /dev/null
+++ b/app/presenters/sentry_detailed_error_presenter.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class SentryDetailedErrorPresenter < Gitlab::View::Presenter::Delegated
+ presents :error
+
+ FrequencyStruct = Struct.new(:time, :count, keyword_init: true)
+
+ def frequency
+ utc_offset = Time.zone_offset('UTC')
+
+ error.frequency.map do |f|
+ FrequencyStruct.new(time: Time.at(f[0], in: utc_offset), count: f[1])
+ end
+ end
+end
diff --git a/app/presenters/snippet_presenter.rb b/app/presenters/snippet_presenter.rb
new file mode 100644
index 00000000000..a453be18b95
--- /dev/null
+++ b/app/presenters/snippet_presenter.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+class SnippetPresenter < Gitlab::View::Presenter::Delegated
+ presents :snippet
+
+ def web_url
+ Gitlab::UrlBuilder.build(snippet)
+ end
+
+ def raw_url
+ Gitlab::UrlBuilder.build(snippet, raw: true)
+ end
+
+ def can_read_snippet?
+ can_access_resource?("read")
+ end
+
+ def can_update_snippet?
+ can_access_resource?("update")
+ end
+
+ def can_admin_snippet?
+ can_access_resource?("admin")
+ end
+
+ def can_report_as_spam?
+ snippet.submittable_as_spam_by?(current_user)
+ end
+
+ private
+
+ def can_access_resource?(ability_prefix)
+ can?(current_user, ability_name(ability_prefix), snippet)
+ end
+
+ def ability_name(ability_prefix)
+ "#{ability_prefix}_#{snippet.to_ability_name}".to_sym
+ end
+end
diff --git a/app/presenters/web_hook_log_presenter.rb b/app/presenters/web_hook_log_presenter.rb
new file mode 100644
index 00000000000..fca03ddb5d7
--- /dev/null
+++ b/app/presenters/web_hook_log_presenter.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class WebHookLogPresenter < Gitlab::View::Presenter::Delegated
+ presents :web_hook_log
+
+ def details_path
+ web_hook.present.logs_details_path(self)
+ end
+
+ def retry_path
+ web_hook.present.logs_retry_path(self)
+ end
+end
diff --git a/app/serializers/analytics_merge_request_entity.rb b/app/serializers/analytics_merge_request_entity.rb
index 21d7eeb81b0..4344de2b9b4 100644
--- a/app/serializers/analytics_merge_request_entity.rb
+++ b/app/serializers/analytics_merge_request_entity.rb
@@ -1,7 +1,9 @@
# frozen_string_literal: true
class AnalyticsMergeRequestEntity < AnalyticsIssueEntity
- expose :state
+ expose :state do |object|
+ MergeRequest.available_states.key(object[:state_id])
+ end
expose :url do |object|
url_to(:namespace_project_merge_request, object)
diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb
index 9a558d12bec..2682a47fbaa 100644
--- a/app/serializers/deploy_key_entity.rb
+++ b/app/serializers/deploy_key_entity.rb
@@ -11,8 +11,7 @@ class DeployKeyEntity < Grape::Entity
expose :updated_at
expose :deploy_keys_projects, using: DeployKeysProjectEntity do |deploy_key|
deploy_key.deploy_keys_projects.select do |deploy_key_project|
- !deploy_key_project.project&.pending_delete? &&
- Ability.allowed?(options[:user], :read_project, deploy_key_project.project)
+ !deploy_key_project.project&.pending_delete? && (allowed_to_read_project?(deploy_key_project.project) || options[:user].admin?)
end
end
expose :can_edit
@@ -23,4 +22,12 @@ class DeployKeyEntity < Grape::Entity
Ability.allowed?(options[:user], :update_deploy_key, object) ||
Ability.allowed?(options[:user], :update_deploy_keys_project, object.deploy_keys_project_for(options[:project]))
end
+
+ def allowed_to_read_project?(project)
+ if options[:readable_project_ids]
+ options[:readable_project_ids].include?(project.id)
+ else
+ Ability.allowed?(options[:user], :read_project, project)
+ end
+ end
end
diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb
index e6421315b34..94773eeebd0 100644
--- a/app/serializers/deployment_entity.rb
+++ b/app/serializers/deployment_entity.rb
@@ -37,6 +37,9 @@ class DeploymentEntity < Grape::Entity
expose :commit, using: CommitEntity, if: -> (*) { include_details? }
expose :manual_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? }
expose :scheduled_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? }
+ expose :playable_build, expose_nil: false, if: -> (*) { include_details? && can_create_deployment? } do |deployment, options|
+ JobEntity.represent(deployment.playable_build, options.merge(only: [:play_path, :retry_path]))
+ end
expose :cluster, using: ClusterBasicEntity
@@ -47,7 +50,7 @@ class DeploymentEntity < Grape::Entity
end
def can_create_deployment?
- can?(request.current_user, :create_deployment, request.project)
+ can?(request.current_user, :create_deployment, project)
end
def can_read_deployables?
@@ -56,6 +59,10 @@ class DeploymentEntity < Grape::Entity
# because it triggers a policy evaluation that involves multiple
# Gitaly calls that might not be cached.
#
- can?(request.current_user, :read_build, request.project)
+ can?(request.current_user, :read_build, project)
+ end
+
+ def project
+ request.try(:project) || options[:project]
end
end
diff --git a/app/serializers/diff_file_metadata_entity.rb b/app/serializers/diff_file_metadata_entity.rb
index 500a844b170..05280518f39 100644
--- a/app/serializers/diff_file_metadata_entity.rb
+++ b/app/serializers/diff_file_metadata_entity.rb
@@ -7,4 +7,7 @@ class DiffFileMetadataEntity < Grape::Entity
expose :old_path
expose :new_file?, as: :new_file
expose :deleted_file?, as: :deleted_file
+ expose :file_hash do |diff_file|
+ Digest::SHA1.hexdigest(diff_file.file_path)
+ end
end
diff --git a/app/serializers/diffs_entity.rb b/app/serializers/diffs_entity.rb
index 19875a1287c..88e09ae8c0b 100644
--- a/app/serializers/diffs_entity.rb
+++ b/app/serializers/diffs_entity.rb
@@ -42,13 +42,13 @@ class DiffsEntity < Grape::Entity
# rubocop: disable CodeReuse/ActiveRecord
expose :added_lines do |diffs|
- diffs.diff_files.sum(&:added_lines)
+ diffs.raw_diff_files.sum(&:added_lines)
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
expose :removed_lines do |diffs|
- diffs.diff_files.sum(&:removed_lines)
+ diffs.raw_diff_files.sum(&:removed_lines)
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/serializers/diffs_metadata_entity.rb b/app/serializers/diffs_metadata_entity.rb
index c82c686e8ef..b7024721ea9 100644
--- a/app/serializers/diffs_metadata_entity.rb
+++ b/app/serializers/diffs_metadata_entity.rb
@@ -2,5 +2,5 @@
class DiffsMetadataEntity < DiffsEntity
unexpose :diff_files
- expose :diff_files, using: DiffFileMetadataEntity
+ expose :raw_diff_files, as: :diff_files, using: DiffFileMetadataEntity
end
diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb
index bffd9de4978..74d6806e83f 100644
--- a/app/serializers/environment_entity.rb
+++ b/app/serializers/environment_entity.rb
@@ -24,6 +24,10 @@ class EnvironmentEntity < Grape::Entity
stop_project_environment_path(environment.project, environment)
end
+ expose :cancel_auto_stop_path, if: -> (*) { can_update_environment? } do |environment|
+ cancel_auto_stop_project_environment_path(environment.project, environment)
+ end
+
expose :cluster_type, if: ->(environment, _) { cluster_platform_kubernetes? } do |environment|
cluster.cluster_type
end
@@ -37,6 +41,7 @@ class EnvironmentEntity < Grape::Entity
end
expose :created_at, :updated_at
+ expose :auto_stop_at, expose_nil: false
expose :can_stop do |environment|
environment.available? && can?(current_user, :stop_environment, environment)
@@ -54,6 +59,10 @@ class EnvironmentEntity < Grape::Entity
can?(request.current_user, :create_environment_terminal, environment)
end
+ def can_update_environment?
+ can?(current_user, :update_environment, environment)
+ end
+
def cluster_platform_kubernetes?
deployment_platform && deployment_platform.is_a?(Clusters::Platforms::Kubernetes)
end
diff --git a/app/serializers/environment_status_entity.rb b/app/serializers/environment_status_entity.rb
index 811cc2ad5af..40db23c143e 100644
--- a/app/serializers/environment_status_entity.rb
+++ b/app/serializers/environment_status_entity.rb
@@ -37,6 +37,10 @@ class EnvironmentStatusEntity < Grape::Entity
es.deployment.try(:formatted_deployment_time)
end
+ expose :deployment, as: :details do |es, options|
+ DeploymentEntity.represent(es.deployment, options.merge(project: es.project, only: [:playable_build]))
+ end
+
expose :changes
private
diff --git a/app/serializers/error_tracking/detailed_error_entity.rb b/app/serializers/error_tracking/detailed_error_entity.rb
index 8f08f84aa41..dd0cac8e4cd 100644
--- a/app/serializers/error_tracking/detailed_error_entity.rb
+++ b/app/serializers/error_tracking/detailed_error_entity.rb
@@ -10,6 +10,7 @@ module ErrorTracking
:first_release_short_version,
:first_seen,
:frequency,
+ :gitlab_issue,
:id,
:last_release_last_commit,
:last_release_short_version,
diff --git a/app/serializers/group_child_entity.rb b/app/serializers/group_child_entity.rb
index 20d7032c970..a7fe4d3f9b9 100644
--- a/app/serializers/group_child_entity.rb
+++ b/app/serializers/group_child_entity.rb
@@ -99,3 +99,5 @@ class GroupChildEntity < Grape::Entity
end
end
end
+
+GroupChildEntity.prepend_if_ee('EE::GroupChildEntity')
diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb
index a3d0298a495..98c0c703584 100644
--- a/app/serializers/issue_entity.rb
+++ b/app/serializers/issue_entity.rb
@@ -64,4 +64,12 @@ class IssueEntity < IssuableEntity
expose :locked_discussion_docs_path, if: -> (issue) { issue.discussion_locked? } do |issue|
help_page_path('user/discussions/index.md', anchor: 'lock-discussions')
end
+
+ expose :is_project_archived do |issue|
+ issue.project.archived?
+ end
+
+ expose :archived_project_docs_path, if: -> (issue) { issue.project.archived? } do |issue|
+ help_page_path('user/project/settings/index.md', anchor: 'archiving-a-project')
+ end
end
diff --git a/app/serializers/merge_request_noteable_entity.rb b/app/serializers/merge_request_noteable_entity.rb
index 9504fdd8eac..8e7456ce059 100644
--- a/app/serializers/merge_request_noteable_entity.rb
+++ b/app/serializers/merge_request_noteable_entity.rb
@@ -42,6 +42,18 @@ class MergeRequestNoteableEntity < IssuableEntity
end
end
+ expose :locked_discussion_docs_path, if: -> (merge_request) { merge_request.discussion_locked? } do |merge_request|
+ help_page_path('user/discussions/index.md', anchor: 'lock-discussions')
+ end
+
+ expose :is_project_archived do |merge_request|
+ merge_request.project.archived?
+ end
+
+ expose :archived_project_docs_path, if: -> (merge_request) { merge_request.project.archived? } do |merge_request|
+ help_page_path('user/project/settings/index.md', anchor: 'archiving-a-project')
+ end
+
private
delegate :current_user, to: :request
diff --git a/app/serializers/merge_request_poll_cached_widget_entity.rb b/app/serializers/merge_request_poll_cached_widget_entity.rb
index a3186ecbcdf..2f8eb6650e8 100644
--- a/app/serializers/merge_request_poll_cached_widget_entity.rb
+++ b/app/serializers/merge_request_poll_cached_widget_entity.rb
@@ -15,7 +15,7 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
expose :target_project_id
expose :squash
expose :rebase_in_progress?, as: :rebase_in_progress
- expose :default_squash_commit_message
+ expose :default_squash_commit_message, if: -> (merge_request, _) { merge_request.mergeable? }
expose :commits_count
expose :merge_ongoing?, as: :merge_ongoing
expose :work_in_progress?, as: :work_in_progress
@@ -25,8 +25,9 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
expose :source_branch_exists?, as: :source_branch_exists
expose :branch_missing?, as: :branch_missing
- expose :commits_without_merge_commits, using: MergeRequestWidgetCommitEntity do |merge_request|
- merge_request.commits.without_merge_commits
+ expose :commits_without_merge_commits, using: MergeRequestWidgetCommitEntity,
+ if: -> (merge_request, _) { merge_request.mergeable? } do |merge_request|
+ merge_request.recent_commits.without_merge_commits
end
expose :diff_head_sha do |merge_request|
merge_request.diff_head_sha.presence
@@ -69,6 +70,10 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
presenter(merge_request).source_branch_with_namespace_link
end
+ expose :diffs_path do |merge_request|
+ diffs_project_merge_request_path(merge_request.project, merge_request)
+ end
+
private
delegate :current_user, to: :request
@@ -101,3 +106,5 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
merged_by: merge_event&.author)
end
end
+
+MergeRequestPollCachedWidgetEntity.prepend_if_ee('EE::MergeRequestPollCachedWidgetEntity')
diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb
index 2a61187a856..a45026ea016 100644
--- a/app/serializers/merge_request_poll_widget_entity.rb
+++ b/app/serializers/merge_request_poll_widget_entity.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
-class MergeRequestPollWidgetEntity < IssuableEntity
+class MergeRequestPollWidgetEntity < Grape::Entity
+ include RequestAwareEntity
+
expose :auto_merge_strategy
expose :available_auto_merge_strategies do |merge_request|
AutoMergeService.new(merge_request.project, current_user).available_strategies(merge_request) # rubocop: disable CodeReuse/ServiceClass
@@ -55,6 +57,10 @@ class MergeRequestPollWidgetEntity < IssuableEntity
presenter(merge_request).ci_status
end
+ expose :pipeline_coverage_delta do |merge_request|
+ presenter(merge_request).pipeline_coverage_delta
+ end
+
expose :cancel_auto_merge_path do |merge_request|
presenter(merge_request).cancel_auto_merge_path
end
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index eda7a36c2ee..2a81931c49f 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -3,6 +3,9 @@
class MergeRequestWidgetEntity < Grape::Entity
include RequestAwareEntity
+ expose :id
+ expose :iid
+
expose :source_project_full_path do |merge_request|
merge_request.source_project&.full_path
end
@@ -65,6 +68,8 @@ class MergeRequestWidgetEntity < Grape::Entity
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))
diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb
index 94e8b174f0f..cddb894fd64 100644
--- a/app/serializers/pipeline_entity.rb
+++ b/app/serializers/pipeline_entity.rb
@@ -77,6 +77,10 @@ class PipelineEntity < Grape::Entity
cancel_project_pipeline_path(pipeline.project, pipeline)
end
+ expose :failed_builds, if: -> (*) { can_retry? }, using: JobEntity do |pipeline|
+ pipeline.builds.failed
+ end
+
private
alias_method :pipeline, :object
diff --git a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb
index 6a33ec071db..7c0e9228b28 100644
--- a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb
+++ b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb
@@ -11,7 +11,7 @@ module AutoMerge
end
def process(merge_request)
- return unless merge_request.actual_head_pipeline&.success?
+ return unless merge_request.actual_head_pipeline_success?
return unless merge_request.mergeable?
merge_request.merge_async(merge_request.merge_user_id, merge_request.merge_params)
diff --git a/app/services/boards/lists/list_service.rb b/app/services/boards/lists/list_service.rb
index 82cba1b68c4..c96ea970943 100644
--- a/app/services/boards/lists/list_service.rb
+++ b/app/services/boards/lists/list_service.rb
@@ -6,7 +6,7 @@ module Boards
def execute(board)
board.lists.create(list_type: :backlog) unless board.lists.backlog.exists?
- board.lists.preload_associations
+ board.lists.preload_associated_models
end
end
end
diff --git a/app/services/branches/create_service.rb b/app/services/branches/create_service.rb
new file mode 100644
index 00000000000..c8afd97e6bf
--- /dev/null
+++ b/app/services/branches/create_service.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Branches
+ class CreateService < BaseService
+ def execute(branch_name, ref, create_master_if_empty: true)
+ create_master_branch if create_master_if_empty && project.empty_repo?
+
+ result = ::Branches::ValidateNewService.new(project).execute(branch_name)
+
+ return result if result[:status] == :error
+
+ new_branch = repository.add_branch(current_user, branch_name, ref)
+
+ if new_branch
+ success(new_branch)
+ else
+ error("Invalid reference name: #{branch_name}")
+ end
+ rescue Gitlab::Git::PreReceiveError => ex
+ error(ex.message)
+ end
+
+ def success(branch)
+ super().merge(branch: branch)
+ end
+
+ private
+
+ def create_master_branch
+ project.repository.create_file(
+ current_user,
+ '/README.md',
+ '',
+ message: 'Add README.md',
+ branch_name: 'master'
+ )
+ end
+ end
+end
diff --git a/app/services/branches/delete_merged_service.rb b/app/services/branches/delete_merged_service.rb
new file mode 100644
index 00000000000..9fd5964bf94
--- /dev/null
+++ b/app/services/branches/delete_merged_service.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Branches
+ class DeleteMergedService < BaseService
+ def async_execute
+ DeleteMergedBranchesWorker.perform_async(project.id, current_user.id)
+ end
+
+ def execute
+ raise Gitlab::Access::AccessDeniedError unless can?(current_user, :push_code, project)
+
+ branches = project.repository.merged_branch_names
+ # Prevent deletion of branches relevant to open merge requests
+ branches -= merge_request_branch_names
+ # Prevent deletion of protected branches
+ branches = branches.reject { |branch| ProtectedBranch.protected?(project, branch) }
+
+ branches.each do |branch|
+ ::Branches::DeleteService.new(project, current_user).execute(branch)
+ end
+ end
+
+ private
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def merge_request_branch_names
+ # reorder(nil) is necessary for SELECT DISTINCT because default scope adds an ORDER BY
+ source_names = project.origin_merge_requests.opened.reorder(nil).distinct.pluck(:source_branch)
+ target_names = project.merge_requests.opened.reorder(nil).distinct.pluck(:target_branch)
+ (source_names + target_names).uniq
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
diff --git a/app/services/branches/delete_service.rb b/app/services/branches/delete_service.rb
new file mode 100644
index 00000000000..ca2b4556b58
--- /dev/null
+++ b/app/services/branches/delete_service.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Branches
+ class DeleteService < BaseService
+ def execute(branch_name)
+ repository = project.repository
+ branch = repository.find_branch(branch_name)
+
+ unless current_user.can?(:push_code, project)
+ return ServiceResponse.error(
+ message: 'You dont have push access to repo',
+ http_status: 405)
+ end
+
+ unless branch
+ return ServiceResponse.error(
+ message: 'No such branch',
+ http_status: 404)
+ end
+
+ if repository.rm_branch(current_user, branch_name)
+ ServiceResponse.success(message: 'Branch was deleted')
+ else
+ ServiceResponse.error(
+ message: 'Failed to remove branch',
+ http_status: 400)
+ end
+ rescue Gitlab::Git::PreReceiveError => ex
+ ServiceResponse.error(message: ex.message, http_status: 400)
+ end
+ end
+end
diff --git a/app/services/branches/validate_new_service.rb b/app/services/branches/validate_new_service.rb
new file mode 100644
index 00000000000..e45183d160f
--- /dev/null
+++ b/app/services/branches/validate_new_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Branches
+ class ValidateNewService < BaseService
+ def initialize(project)
+ @project = project
+ end
+
+ def execute(branch_name, force: false)
+ return error('Branch name is invalid') unless valid_name?(branch_name)
+
+ if branch_exist?(branch_name) && !force
+ return error('Branch already exists')
+ end
+
+ success
+ rescue Gitlab::Git::PreReceiveError => ex
+ error(ex.message)
+ end
+
+ private
+
+ def valid_name?(branch_name)
+ Gitlab::GitRefValidator.validate(branch_name)
+ end
+
+ def branch_exist?(branch_name)
+ project.repository.branch_exists?(branch_name)
+ end
+ end
+end
diff --git a/app/services/ci/archive_trace_service.rb b/app/services/ci/archive_trace_service.rb
index 8fad9e9c869..f143736ddc1 100644
--- a/app/services/ci/archive_trace_service.rb
+++ b/app/services/ci/archive_trace_service.rb
@@ -46,10 +46,10 @@ module Ci
message: "Failed to archive trace. message: #{error.message}.",
job_id: job.id)
- Gitlab::Sentry
- .track_exception(error,
+ Gitlab::ErrorTracking
+ .track_and_raise_for_dev_exception(error,
issue_url: 'https://gitlab.com/gitlab-org/gitlab-foss/issues/51502',
- extra: { job_id: job.id })
+ job_id: job.id )
end
end
end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 5778a48bce6..ce3a9eb0772 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -16,6 +16,7 @@ module Ci
Gitlab::Ci::Pipeline::Chain::EvaluateWorkflowRules,
Gitlab::Ci::Pipeline::Chain::Seed,
Gitlab::Ci::Pipeline::Chain::Limit::Size,
+ Gitlab::Ci::Pipeline::Chain::Validate::External,
Gitlab::Ci::Pipeline::Chain::Populate,
Gitlab::Ci::Pipeline::Chain::Create,
Gitlab::Ci::Pipeline::Chain::Limit::Activity,
@@ -57,7 +58,9 @@ module Ci
cancel_pending_pipelines if project.auto_cancel_pending_pipelines?
pipeline_created_counter.increment(source: source)
- pipeline.process!
+ Ci::ProcessPipelineService
+ .new(pipeline)
+ .execute
end
end
diff --git a/app/services/ci/generate_exposed_artifacts_report_service.rb b/app/services/ci/generate_exposed_artifacts_report_service.rb
index b9bf580bcbc..1dbcd192279 100644
--- a/app/services/ci/generate_exposed_artifacts_report_service.rb
+++ b/app/services/ci/generate_exposed_artifacts_report_service.rb
@@ -15,7 +15,7 @@ module Ci
data: data
}
rescue => e
- Gitlab::Sentry.track_acceptable_exception(e, extra: { project_id: project.id })
+ Gitlab::ErrorTracking.track_exception(e, project_id: project.id)
{
status: :error,
key: key(base_pipeline, head_pipeline),
diff --git a/app/services/ci/prepare_build_service.rb b/app/services/ci/prepare_build_service.rb
index 3722faeb020..5d024c45e5f 100644
--- a/app/services/ci/prepare_build_service.rb
+++ b/app/services/ci/prepare_build_service.rb
@@ -13,7 +13,7 @@ module Ci
build.enqueue!
rescue => e
- Gitlab::Sentry.track_acceptable_exception(e, extra: { build_id: build.id })
+ Gitlab::ErrorTracking.track_exception(e, build_id: build.id)
build.drop(:unmet_prerequisites)
end
diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb
index 039670f58c8..f33cbf7ab29 100644
--- a/app/services/ci/process_pipeline_service.rb
+++ b/app/services/ci/process_pipeline_service.rb
@@ -1,14 +1,16 @@
# frozen_string_literal: true
module Ci
- class ProcessPipelineService < BaseService
+ class ProcessPipelineService
include Gitlab::Utils::StrongMemoize
attr_reader :pipeline
- def execute(pipeline, trigger_build_ids = nil)
+ def initialize(pipeline)
@pipeline = pipeline
+ end
+ def execute(trigger_build_ids = nil)
update_retried
success = process_stages_without_needs
@@ -72,7 +74,7 @@ module Ci
def process_build(build, current_status)
Gitlab::OptimisticLocking.retry_lock(build) do |subject|
- Ci::ProcessBuildService.new(project, @user)
+ Ci::ProcessBuildService.new(project, build.user)
.execute(subject, current_status)
end
end
@@ -129,5 +131,9 @@ module Ci
.update_all(retried: true) if latest_statuses.any?
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ def project
+ pipeline.project
+ end
end
end
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index 30e2a66e04a..57c0cdd0602 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -128,13 +128,13 @@ module Ci
end
def track_exception_for_build(ex, build)
- Gitlab::Sentry.track_acceptable_exception(ex, extra: {
+ Gitlab::ErrorTracking.track_exception(ex,
build_id: build.id,
build_name: build.name,
build_stage: build.stage,
pipeline_id: build.pipeline_id,
project_id: build.project_id
- })
+ )
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb
index 42a13367a99..7d01de9ee68 100644
--- a/app/services/ci/retry_pipeline_service.rb
+++ b/app/services/ci/retry_pipeline_service.rb
@@ -9,13 +9,23 @@ module Ci
raise Gitlab::Access::AccessDeniedError
end
- pipeline.retryable_builds.find_each do |build|
+ needs = Set.new
+
+ pipeline.retryable_builds.preload_needs.find_each do |build|
next unless can?(current_user, :update_build, build)
Ci::RetryBuildService.new(project, current_user)
.reprocess!(build)
+
+ needs += build.needs.map(&:name)
end
+ # In a DAG, the dependencies may have already completed. Figure out
+ # which builds have succeeded and use them to update the pipeline. If we don't
+ # do this, then builds will be stuck in the created state since their dependencies
+ # will never run.
+ completed_build_ids = pipeline.find_successful_build_ids_by_names(needs) if needs.any?
+
pipeline.builds.latest.skipped.find_each do |skipped|
retry_optimistic_lock(skipped) { |build| build.process }
end
@@ -24,7 +34,9 @@ module Ci
.new(project, current_user)
.close_all(pipeline)
- pipeline.process!
+ Ci::ProcessPipelineService
+ .new(pipeline)
+ .execute(completed_build_ids)
end
end
end
diff --git a/app/services/clusters/applications/base_helm_service.rb b/app/services/clusters/applications/base_helm_service.rb
index 3e7f55f0c63..57bc8bc0d9b 100644
--- a/app/services/clusters/applications/base_helm_service.rb
+++ b/app/services/clusters/applications/base_helm_service.rb
@@ -21,14 +21,7 @@ module Clusters
group_ids: app.cluster.group_ids
}
- logger_meta = meta.merge(
- exception: error.class.name,
- message: error.message,
- backtrace: Gitlab::Profiler.clean_backtrace(error.backtrace)
- )
-
- logger.error(logger_meta)
- Gitlab::Sentry.track_acceptable_exception(error, extra: meta)
+ Gitlab::ErrorTracking.track_exception(error, meta)
end
def log_event(event)
@@ -68,8 +61,8 @@ module Clusters
@update_command ||= app.update_command
end
- def upgrade_command(new_values = "")
- app.upgrade_command(new_values)
+ def patch_command(new_values = "")
+ app.patch_command(new_values)
end
end
end
diff --git a/app/services/clusters/applications/ingress_modsecurity_usage_service.rb b/app/services/clusters/applications/ingress_modsecurity_usage_service.rb
new file mode 100644
index 00000000000..4aac8bb3cbd
--- /dev/null
+++ b/app/services/clusters/applications/ingress_modsecurity_usage_service.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+# rubocop: disable CodeReuse/ActiveRecord
+module Clusters
+ module Applications
+ ##
+ # This service measures usage of the Modsecurity Web Application Firewall across the entire
+ # instance's deployed environments.
+ #
+ # The default configuration is`AUTO_DEVOPS_MODSECURITY_SEC_RULE_ENGINE=DetectionOnly` so we
+ # measure non-default values via definition of either ci_variables or ci_pipeline_variables.
+ # Since both these values are encrypted, we must decrypt and count them in memory.
+ #
+ # NOTE: this service is an approximation as it does not yet take into account `environment_scope` or `ci_group_variables`.
+ ##
+ class IngressModsecurityUsageService
+ ADO_MODSEC_KEY = "AUTO_DEVOPS_MODSECURITY_SEC_RULE_ENGINE"
+
+ def initialize(blocking_count: 0, disabled_count: 0)
+ @blocking_count = blocking_count
+ @disabled_count = disabled_count
+ end
+
+ def execute
+ conditions = -> { merge(::Environment.available).merge(::Deployment.success).where(key: ADO_MODSEC_KEY) }
+
+ ci_pipeline_var_enabled =
+ ::Ci::PipelineVariable
+ .joins(pipeline: { environments: :last_visible_deployment })
+ .merge(conditions)
+ .order('deployments.environment_id, deployments.id DESC')
+
+ ci_var_enabled =
+ ::Ci::Variable
+ .joins(project: { environments: :last_visible_deployment })
+ .merge(conditions)
+ .merge(
+ # Give priority to pipeline variables by excluding from dataset
+ ::Ci::Variable.joins(project: :environments).where.not(
+ environments: { id: ci_pipeline_var_enabled.select('DISTINCT ON (deployments.environment_id) deployments.environment_id') }
+ )
+ ).select('DISTINCT ON (deployments.environment_id) ci_variables.*')
+
+ sum_modsec_config_counts(
+ ci_pipeline_var_enabled.select('DISTINCT ON (deployments.environment_id) ci_pipeline_variables.*')
+ )
+ sum_modsec_config_counts(ci_var_enabled)
+
+ {
+ ingress_modsecurity_blocking: @blocking_count,
+ ingress_modsecurity_disabled: @disabled_count
+ }
+ end
+
+ private
+
+ # These are encrypted so we must decrypt and count in memory
+ def sum_modsec_config_counts(dataset)
+ dataset.each do |var|
+ case var.value
+ when "On" then @blocking_count += 1
+ when "Off" then @disabled_count += 1
+ # `else` could be default or any unsupported user input
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/aws/authorize_role_service.rb b/app/services/clusters/aws/authorize_role_service.rb
new file mode 100644
index 00000000000..6eafce0597e
--- /dev/null
+++ b/app/services/clusters/aws/authorize_role_service.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Aws
+ class AuthorizeRoleService
+ attr_reader :user
+
+ Response = Struct.new(:status, :body)
+
+ ERRORS = [
+ ActiveRecord::RecordInvalid,
+ Clusters::Aws::FetchCredentialsService::MissingRoleError,
+ ::Aws::Errors::MissingCredentialsError,
+ ::Aws::STS::Errors::ServiceError
+ ].freeze
+
+ def initialize(user, params:)
+ @user = user
+ @params = params
+ end
+
+ def execute
+ @role = create_or_update_role!
+
+ Response.new(:ok, credentials)
+ rescue *ERRORS
+ Response.new(:unprocessable_entity, {})
+ end
+
+ private
+
+ attr_reader :role, :params
+
+ def create_or_update_role!
+ if role = user.aws_role
+ role.update!(params)
+
+ role
+ else
+ user.create_aws_role!(params)
+ end
+ end
+
+ def credentials
+ Clusters::Aws::FetchCredentialsService.new(role).execute
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/aws/fetch_credentials_service.rb b/app/services/clusters/aws/fetch_credentials_service.rb
index 2724d4b657b..33efc4cc120 100644
--- a/app/services/clusters/aws/fetch_credentials_service.rb
+++ b/app/services/clusters/aws/fetch_credentials_service.rb
@@ -7,9 +7,8 @@ module Clusters
MissingRoleError = Class.new(StandardError)
- def initialize(provision_role, region:, provider: nil)
+ def initialize(provision_role, provider: nil)
@provision_role = provision_role
- @region = region
@provider = provider
end
@@ -20,13 +19,14 @@ module Clusters
client: client,
role_arn: provision_role.role_arn,
role_session_name: session_name,
- external_id: provision_role.role_external_id
+ external_id: provision_role.role_external_id,
+ policy: session_policy
).credentials
end
private
- attr_reader :provider, :region
+ attr_reader :provider
def client
::Aws::STS::Client.new(credentials: gitlab_credentials, region: region)
@@ -44,6 +44,26 @@ module Clusters
Gitlab::CurrentSettings.eks_secret_access_key
end
+ def region
+ provider&.region || Clusters::Providers::Aws::DEFAULT_REGION
+ end
+
+ ##
+ # If we haven't created a provider record yet,
+ # we restrict ourselves to read only access so
+ # that we can safely expose credentials to the
+ # frontend (to be used when populating the
+ # creation form).
+ def session_policy
+ if provider.nil?
+ File.read(read_only_policy)
+ end
+ end
+
+ def read_only_policy
+ Rails.root.join('vendor', 'aws', 'iam', "eks_cluster_read_only_policy.json")
+ end
+
def session_name
if provider.present?
"gitlab-eks-cluster-#{provider.cluster_id}-user-#{provision_role.user_id}"
diff --git a/app/services/clusters/aws/proxy_service.rb b/app/services/clusters/aws/proxy_service.rb
deleted file mode 100644
index df8fc480005..00000000000
--- a/app/services/clusters/aws/proxy_service.rb
+++ /dev/null
@@ -1,134 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Aws
- class ProxyService
- DEFAULT_REGION = 'us-east-1'
-
- BadRequest = Class.new(StandardError)
- Response = Struct.new(:status, :body)
-
- def initialize(role, params:)
- @role = role
- @params = params
- end
-
- def execute
- api_response = request_from_api!
-
- Response.new(:ok, api_response.to_hash)
- rescue *service_errors
- Response.new(:bad_request, {})
- end
-
- private
-
- attr_reader :role, :params
-
- def request_from_api!
- case requested_resource
- when 'key_pairs'
- ec2_client.describe_key_pairs
-
- when 'instance_types'
- instance_types
-
- when 'roles'
- iam_client.list_roles
-
- when 'regions'
- ec2_client.describe_regions
-
- when 'security_groups'
- raise BadRequest unless vpc_id.present?
-
- ec2_client.describe_security_groups(vpc_filter)
-
- when 'subnets'
- raise BadRequest unless vpc_id.present?
-
- ec2_client.describe_subnets(vpc_filter)
-
- when 'vpcs'
- ec2_client.describe_vpcs
-
- else
- raise BadRequest
- end
- end
-
- def requested_resource
- params[:resource]
- end
-
- def vpc_id
- params[:vpc_id]
- end
-
- def region
- params[:region] || DEFAULT_REGION
- end
-
- def vpc_filter
- {
- filters: [{
- name: "vpc-id",
- values: [vpc_id]
- }]
- }
- end
-
- ##
- # Unfortunately the EC2 API doesn't provide a list of
- # possible instance types. There is a workaround, using
- # the Pricing API, but instead of requiring the
- # user to grant extra permissions for this we use the
- # values that validate the CloudFormation template.
- def instance_types
- {
- instance_types: cluster_stack_instance_types.map { |type| Hash(instance_type_name: type) }
- }
- end
-
- def cluster_stack_instance_types
- YAML.safe_load(stack_template).dig('Parameters', 'NodeInstanceType', 'AllowedValues')
- end
-
- def stack_template
- File.read(Rails.root.join('vendor', 'aws', 'cloudformation', 'eks_cluster.yaml'))
- end
-
- def ec2_client
- ::Aws::EC2::Client.new(client_options)
- end
-
- def iam_client
- ::Aws::IAM::Client.new(client_options)
- end
-
- def credentials
- Clusters::Aws::FetchCredentialsService.new(role, region: region).execute
- end
-
- def client_options
- {
- credentials: credentials,
- region: region,
- http_open_timeout: 5,
- http_read_timeout: 10
- }
- end
-
- def service_errors
- [
- BadRequest,
- Clusters::Aws::FetchCredentialsService::MissingRoleError,
- ::Aws::Errors::MissingCredentialsError,
- ::Aws::EC2::Errors::ServiceError,
- ::Aws::IAM::Errors::ServiceError,
- ::Aws::STS::Errors::ServiceError
- ]
- end
- end
- end
-end
diff --git a/app/services/clusters/cleanup/app_service.rb b/app/services/clusters/cleanup/app_service.rb
new file mode 100644
index 00000000000..a7e29c78ea0
--- /dev/null
+++ b/app/services/clusters/cleanup/app_service.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Cleanup
+ class AppService < Clusters::Cleanup::BaseService
+ def execute
+ persisted_applications = @cluster.persisted_applications
+
+ persisted_applications.each do |app|
+ next unless app.available?
+ next unless app.can_uninstall?
+
+ log_event(:uninstalling_app, application: app.class.application_name)
+ uninstall_app_async(app)
+ end
+
+ # Keep calling the worker untill all dependencies are uninstalled
+ return schedule_next_execution(Clusters::Cleanup::AppWorker) if persisted_applications.any?
+
+ log_event(:schedule_remove_project_namespaces)
+ cluster.continue_cleanup!
+ end
+
+ private
+
+ def uninstall_app_async(application)
+ application.make_scheduled!
+
+ Clusters::Applications::UninstallWorker.perform_async(application.name, application.id)
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/cleanup/base_service.rb b/app/services/clusters/cleanup/base_service.rb
new file mode 100644
index 00000000000..f99e54cfc40
--- /dev/null
+++ b/app/services/clusters/cleanup/base_service.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Cleanup
+ class BaseService
+ DEFAULT_EXECUTION_INTERVAL = 1.minute
+
+ def initialize(cluster, execution_count = 0)
+ @cluster = cluster
+ @execution_count = execution_count
+ end
+
+ private
+
+ attr_reader :cluster
+
+ def logger
+ @logger ||= Gitlab::Kubernetes::Logger.build
+ end
+
+ def log_event(event, extra_data = {})
+ meta = {
+ service: self.class.name,
+ cluster_id: cluster.id,
+ execution_count: @execution_count,
+ event: event
+ }
+
+ logger.info(meta.merge(extra_data))
+ end
+
+ def schedule_next_execution(worker_class)
+ log_event(:scheduling_execution, next_execution: @execution_count + 1)
+ worker_class.perform_in(execution_interval, cluster.id, @execution_count + 1)
+ end
+
+ # Override this method to customize the execution interval
+ def execution_interval
+ DEFAULT_EXECUTION_INTERVAL
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/cleanup/project_namespace_service.rb b/app/services/clusters/cleanup/project_namespace_service.rb
new file mode 100644
index 00000000000..7621be565ff
--- /dev/null
+++ b/app/services/clusters/cleanup/project_namespace_service.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Cleanup
+ class ProjectNamespaceService < BaseService
+ KUBERNETES_NAMESPACE_BATCH_SIZE = 100
+
+ def execute
+ delete_project_namespaces_in_batches
+
+ # Keep calling the worker untill all namespaces are deleted
+ if cluster.kubernetes_namespaces.exists?
+ return schedule_next_execution(Clusters::Cleanup::ProjectNamespaceWorker)
+ end
+
+ cluster.continue_cleanup!
+ end
+
+ private
+
+ def delete_project_namespaces_in_batches
+ kubernetes_namespaces_batch = cluster.kubernetes_namespaces.first(KUBERNETES_NAMESPACE_BATCH_SIZE)
+
+ kubernetes_namespaces_batch.each do |kubernetes_namespace|
+ log_event(:deleting_project_namespace, namespace: kubernetes_namespace.namespace)
+
+ begin
+ kubeclient_delete_namespace(kubernetes_namespace)
+ rescue Kubeclient::HttpError
+ next
+ end
+
+ kubernetes_namespace.destroy!
+ end
+ end
+
+ def kubeclient_delete_namespace(kubernetes_namespace)
+ cluster.kubeclient.delete_namespace(kubernetes_namespace.namespace)
+ rescue Kubeclient::ResourceNotFoundError
+ # no-op: nothing to delete
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/cleanup/service_account_service.rb b/app/services/clusters/cleanup/service_account_service.rb
new file mode 100644
index 00000000000..d60bd76d388
--- /dev/null
+++ b/app/services/clusters/cleanup/service_account_service.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Cleanup
+ class ServiceAccountService < BaseService
+ def execute
+ delete_gitlab_service_account
+
+ log_event(:destroying_cluster)
+
+ cluster.destroy!
+ end
+
+ private
+
+ def delete_gitlab_service_account
+ log_event(:deleting_gitlab_service_account)
+
+ cluster.kubeclient.delete_service_account(
+ ::Clusters::Kubernetes::GITLAB_SERVICE_ACCOUNT_NAME,
+ ::Clusters::Kubernetes::GITLAB_SERVICE_ACCOUNT_NAMESPACE
+ )
+ rescue Kubeclient::ResourceNotFoundError
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/kubernetes/kubernetes.rb b/app/services/clusters/kubernetes.rb
index d29519999b2..59cb1c4b3a9 100644
--- a/app/services/clusters/kubernetes/kubernetes.rb
+++ b/app/services/clusters/kubernetes.rb
@@ -12,5 +12,8 @@ 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'
+ GITLAB_KNATIVE_VERSION_ROLE_NAME = 'gitlab-knative-version-role'
+ GITLAB_KNATIVE_VERSION_ROLE_BINDING_NAME = 'gitlab-knative-version-rolebinding'
+ KNATIVE_SERVING_NAMESPACE = 'knative-serving'
end
end
diff --git a/app/services/clusters/kubernetes/create_or_update_service_account_service.rb b/app/services/clusters/kubernetes/create_or_update_service_account_service.rb
index d798dcdcfd3..046046bf5a3 100644
--- a/app/services/clusters/kubernetes/create_or_update_service_account_service.rb
+++ b/app/services/clusters/kubernetes/create_or_update_service_account_service.rb
@@ -49,8 +49,14 @@ module Clusters
create_or_update_knative_serving_role
create_or_update_knative_serving_role_binding
+
create_or_update_crossplane_database_role
create_or_update_crossplane_database_role_binding
+
+ return unless knative_serving_namespace
+
+ create_or_update_knative_version_role
+ create_or_update_knative_version_role_binding
end
private
@@ -64,6 +70,12 @@ module Clusters
).ensure_exists!
end
+ def knative_serving_namespace
+ kubeclient.get_namespace(Clusters::Kubernetes::KNATIVE_SERVING_NAMESPACE)
+ rescue Kubeclient::ResourceNotFoundError
+ nil
+ end
+
def create_role_or_cluster_role_binding
if namespace_creator
kubeclient.create_or_update_role_binding(role_binding_resource)
@@ -88,6 +100,14 @@ module Clusters
kubeclient.update_role_binding(crossplane_database_role_binding_resource)
end
+ def create_or_update_knative_version_role
+ kubeclient.update_cluster_role(knative_version_role_resource)
+ end
+
+ def create_or_update_knative_version_role_binding
+ kubeclient.update_cluster_role_binding(knative_version_role_binding_resource)
+ end
+
def service_account_resource
Gitlab::Kubernetes::ServiceAccount.new(
service_account_name,
@@ -166,6 +186,27 @@ module Clusters
service_account_name: service_account_name
).generate
end
+
+ def knative_version_role_resource
+ Gitlab::Kubernetes::ClusterRole.new(
+ name: Clusters::Kubernetes::GITLAB_KNATIVE_VERSION_ROLE_NAME,
+ rules: [{
+ apiGroups: %w(apps),
+ resources: %w(deployments),
+ verbs: %w(list get)
+ }]
+ ).generate
+ end
+
+ def knative_version_role_binding_resource
+ subjects = [{ kind: 'ServiceAccount', name: service_account_name, namespace: service_account_namespace }]
+
+ Gitlab::Kubernetes::ClusterRoleBinding.new(
+ Clusters::Kubernetes::GITLAB_KNATIVE_VERSION_ROLE_BINDING_NAME,
+ Clusters::Kubernetes::GITLAB_KNATIVE_VERSION_ROLE_NAME,
+ subjects
+ ).generate
+ end
end
end
end
diff --git a/app/services/cohorts_service.rb b/app/services/cohorts_service.rb
index dbbe89ef260..03be87f4cc1 100644
--- a/app/services/cohorts_service.rb
+++ b/app/services/cohorts_service.rb
@@ -38,7 +38,7 @@ class CohortsService
{
registration_month: registration_month,
- activity_months: activity_months,
+ activity_months: activity_months[1..-1],
total: activity_months.first[:total],
inactive: inactive
}
diff --git a/app/services/commits/commit_patch_service.rb b/app/services/commits/commit_patch_service.rb
index 49113c3c691..4fa6c30e901 100644
--- a/app/services/commits/commit_patch_service.rb
+++ b/app/services/commits/commit_patch_service.rb
@@ -32,7 +32,7 @@ module Commits
end
def prepare_branch!
- branch_result = CreateBranchService.new(project, current_user)
+ branch_result = ::Branches::CreateService.new(project, current_user)
.execute(@branch_name, @start_branch)
if branch_result[:status] != :success
diff --git a/app/services/commits/create_service.rb b/app/services/commits/create_service.rb
index b42494563b2..bd238605ac1 100644
--- a/app/services/commits/create_service.rb
+++ b/app/services/commits/create_service.rb
@@ -101,7 +101,7 @@ module Commits
end
def validate_new_branch_name!
- result = ValidateNewBranchService.new(project, current_user).execute(@branch_name, force: force?)
+ result = ::Branches::ValidateNewService.new(project).execute(@branch_name, force: force?)
if result[:status] == :error
raise_error("Something went wrong when we tried to create '#{@branch_name}' for you: #{result[:message]}")
diff --git a/app/services/concerns/users/participable_service.rb b/app/services/concerns/users/participable_service.rb
index 1c828234f1b..6fde9abfdb0 100644
--- a/app/services/concerns/users/participable_service.rb
+++ b/app/services/concerns/users/participable_service.rb
@@ -55,7 +55,8 @@ module Users
username: group.full_path,
name: group.full_name,
avatar_url: group.avatar_url,
- count: group_counts.fetch(group.id, 0)
+ count: group_counts.fetch(group.id, 0),
+ mentionsDisabled: group.mentions_disabled
}
end
end
diff --git a/app/services/create_branch_service.rb b/app/services/create_branch_service.rb
deleted file mode 100644
index d58cb0f9e2b..00000000000
--- a/app/services/create_branch_service.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-class CreateBranchService < BaseService
- def execute(branch_name, ref, create_master_if_empty: true)
- create_master_branch if create_master_if_empty && project.empty_repo?
-
- result = ValidateNewBranchService.new(project, current_user)
- .execute(branch_name)
-
- return result if result[:status] == :error
-
- new_branch = repository.add_branch(current_user, branch_name, ref)
-
- if new_branch
- success(new_branch)
- else
- error("Invalid reference name: #{branch_name}")
- end
- rescue Gitlab::Git::PreReceiveError => ex
- error(ex.message)
- end
-
- def success(branch)
- super().merge(branch: branch)
- end
-
- private
-
- def create_master_branch
- project.repository.create_file(
- current_user,
- '/README.md',
- '',
- message: 'Add README.md',
- branch_name: 'master'
- )
- end
-end
diff --git a/app/services/create_snippet_service.rb b/app/services/create_snippet_service.rb
index 0aa76df35ba..eacea7d94c7 100644
--- a/app/services/create_snippet_service.rb
+++ b/app/services/create_snippet_service.rb
@@ -21,7 +21,11 @@ class CreateSnippetService < BaseService
spam_check(snippet, current_user)
- if snippet.save
+ snippet_saved = snippet.with_transaction_returning_status do
+ snippet.save && snippet.store_mentions!
+ end
+
+ if snippet_saved
UserAgentDetailService.new(snippet, @request).create
Gitlab::UsageDataCounters::SnippetCounter.count(:create)
end
diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb
deleted file mode 100644
index fd41ce54486..00000000000
--- a/app/services/delete_branch_service.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-# frozen_string_literal: true
-
-class DeleteBranchService < BaseService
- def execute(branch_name)
- repository = project.repository
- branch = repository.find_branch(branch_name)
-
- unless current_user.can?(:push_code, project)
- return ServiceResponse.error(
- message: 'You dont have push access to repo',
- http_status: 405)
- end
-
- unless branch
- return ServiceResponse.error(
- message: 'No such branch',
- http_status: 404)
- end
-
- if repository.rm_branch(current_user, branch_name)
- ServiceResponse.success(message: 'Branch was deleted')
- else
- ServiceResponse.error(
- message: 'Failed to remove branch',
- http_status: 400)
- end
- rescue Gitlab::Git::PreReceiveError => ex
- ServiceResponse.error(message: ex.message, http_status: 400)
- end
-end
diff --git a/app/services/delete_merged_branches_service.rb b/app/services/delete_merged_branches_service.rb
deleted file mode 100644
index 80de897e94b..00000000000
--- a/app/services/delete_merged_branches_service.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-# frozen_string_literal: true
-
-class DeleteMergedBranchesService < BaseService
- def async_execute
- DeleteMergedBranchesWorker.perform_async(project.id, current_user.id)
- end
-
- def execute
- raise Gitlab::Access::AccessDeniedError unless can?(current_user, :push_code, project)
-
- branches = project.repository.merged_branch_names
- # Prevent deletion of branches relevant to open merge requests
- branches -= merge_request_branch_names
- # Prevent deletion of protected branches
- branches = branches.reject { |branch| ProtectedBranch.protected?(project, branch) }
-
- branches.each do |branch|
- DeleteBranchService.new(project, current_user).execute(branch)
- end
- end
-
- private
-
- # rubocop: disable CodeReuse/ActiveRecord
- def merge_request_branch_names
- # reorder(nil) is necessary for SELECT DISTINCT because default scope adds an ORDER BY
- source_names = project.origin_merge_requests.opened.reorder(nil).distinct.pluck(:source_branch)
- target_names = project.merge_requests.opened.reorder(nil).distinct.pluck(:target_branch)
- (source_names + target_names).uniq
- end
- # rubocop: enable CodeReuse/ActiveRecord
-end
diff --git a/app/services/deployments/after_create_service.rb b/app/services/deployments/after_create_service.rb
index e0a4e5419cc..1d9cb666cff 100644
--- a/app/services/deployments/after_create_service.rb
+++ b/app/services/deployments/after_create_service.rb
@@ -29,6 +29,7 @@ module Deployments
environment.external_url = url
end
+ renew_auto_stop_in
environment.fire_state_event(action)
if environment.save && !environment.stopped?
@@ -63,6 +64,12 @@ module Deployments
def action
environment_options[:action] || 'start'
end
+
+ def renew_auto_stop_in
+ return unless deployable
+
+ environment.auto_stop_in = deployable.environment_auto_stop_in
+ end
end
end
diff --git a/app/services/deployments/create_service.rb b/app/services/deployments/create_service.rb
index 89e3f7c8b83..7355747d778 100644
--- a/app/services/deployments/create_service.rb
+++ b/app/services/deployments/create_service.rb
@@ -11,15 +11,17 @@ module Deployments
end
def execute
- create_deployment.tap do |deployment|
- AfterCreateService.new(deployment).execute if deployment.persisted?
+ environment.deployments.build(deployment_attributes).tap do |deployment|
+ # Deployment#change_status already saves the model, so we only need to
+ # call #save ourselves if no status is provided.
+ if (status = params[:status])
+ deployment.update_status(status)
+ else
+ deployment.save
+ end
end
end
- def create_deployment
- environment.deployments.create(deployment_attributes)
- end
-
def deployment_attributes
# We use explicit parameters here so we never by accident allow parameters
# to be set that one should not be able to set (e.g. the row ID).
@@ -31,8 +33,7 @@ module Deployments
tag: params[:tag],
sha: params[:sha],
user: current_user,
- on_stop: params[:on_stop],
- status: params[:status]
+ on_stop: params[:on_stop]
}
end
end
diff --git a/app/services/deployments/update_service.rb b/app/services/deployments/update_service.rb
index 97b233f16a7..b8f8740c9b9 100644
--- a/app/services/deployments/update_service.rb
+++ b/app/services/deployments/update_service.rb
@@ -10,22 +10,7 @@ module Deployments
end
def execute
- # A regular update() does not trigger the state machine transitions, which
- # we need to ensure merge requests are linked when changing the status to
- # success. To work around this we use this case statment, using the right
- # event methods to trigger the transition hooks.
- case params[:status]
- when 'running'
- deployment.run
- when 'success'
- deployment.succeed
- when 'failed'
- deployment.drop
- when 'canceled'
- deployment.cancel
- else
- false
- end
+ deployment.update_status(params[:status])
end
end
end
diff --git a/app/services/environments/reset_auto_stop_service.rb b/app/services/environments/reset_auto_stop_service.rb
new file mode 100644
index 00000000000..237629fda79
--- /dev/null
+++ b/app/services/environments/reset_auto_stop_service.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Environments
+ class ResetAutoStopService < ::BaseService
+ def execute(environment)
+ return error(_('Failed to cancel auto stop because you do not have permission to update the environment.')) unless can_update_environment?(environment)
+ return error(_('Failed to cancel auto stop because the environment is not set as auto stop.')) unless environment.auto_stop_at?
+
+ if environment.reset_auto_stop
+ success
+ else
+ error(_('Failed to cancel auto stop because failed to update the environment.'))
+ end
+ end
+
+ private
+
+ def can_update_environment?(environment)
+ can?(current_user, :update_environment, environment)
+ end
+ end
+end
diff --git a/app/services/error_tracking/list_issues_service.rb b/app/services/error_tracking/list_issues_service.rb
index 2e8c401b8ef..132e9dfa7bd 100644
--- a/app/services/error_tracking/list_issues_service.rb
+++ b/app/services/error_tracking/list_issues_service.rb
@@ -4,6 +4,7 @@ module ErrorTracking
class ListIssuesService < ErrorTracking::BaseService
DEFAULT_ISSUE_STATUS = 'unresolved'
DEFAULT_LIMIT = 20
+ DEFAULT_SORT = 'last_seen'
def external_url
project_error_tracking_setting&.sentry_external_url
@@ -12,11 +13,17 @@ module ErrorTracking
private
def fetch
- project_error_tracking_setting.list_sentry_issues(issue_status: issue_status, limit: limit)
+ 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
+ )
end
def parse_response(response)
- { issues: response[:issues] }
+ response.slice(:issues, :pagination)
end
def issue_status
@@ -26,5 +33,9 @@ module ErrorTracking
def limit
params[:limit] || DEFAULT_LIMIT
end
+
+ def sort
+ params[:sort] || DEFAULT_SORT
+ end
end
end
diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb
index 0801fd4d03f..d935d9e8cdc 100644
--- a/app/services/git/base_hooks_service.rb
+++ b/app/services/git/base_hooks_service.rb
@@ -85,12 +85,36 @@ module Git
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
+ def ci_variables_from_push_options
+ strong_memoize(:ci_variables_from_push_options) do
+ params[:push_options]&.deep_symbolize_keys&.dig(:ci, :variable)
+ end
+ end
+
+ def generate_vars_from_push_options
+ return [] unless ci_variables_from_push_options
+
+ ci_variables_from_push_options.map do |var_definition, _count|
+ key, value = var_definition.to_s.split("=", 2)
+
+ # Accept only valid format. We ignore the following formats
+ # 1. "=123". In this case, `key` will be an empty string
+ # 2. "FOO". In this case, `value` will be nil.
+ # However, the format "FOO=" will result in key beign `FOO` and value
+ # being an empty string. This is acceptable.
+ next if key.blank? || value.nil?
+
+ { "key" => key, "variable_type" => "env_var", "secret_value" => value }
+ end.compact
+ end
+
def push_data_params(commits:, with_changed_files: true)
{
oldrev: oldrev,
diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb
index 273a12f386a..bbb3c2ad050 100644
--- a/app/services/issuable/bulk_update_service.rb
+++ b/app/services/issuable/bulk_update_service.rb
@@ -4,19 +4,18 @@ module Issuable
class BulkUpdateService
include Gitlab::Allowable
- attr_accessor :current_user, :params
+ attr_accessor :parent, :current_user, :params
- def initialize(user = nil, params = {})
- @current_user, @params = user, params.dup
+ def initialize(parent, user = nil, params = {})
+ @parent, @current_user, @params = parent, user, params.dup
end
- # rubocop: disable CodeReuse/ActiveRecord
def execute(type)
model_class = type.classify.constantize
update_class = type.classify.pluralize.constantize::UpdateService
ids = params.delete(:issuable_ids).split(",")
- items = model_class.where(id: ids)
+ items = find_issuables(parent, model_class, ids)
permitted_attrs(type).each do |key|
params.delete(key) unless params[key].present?
@@ -37,7 +36,6 @@ module Issuable
success: !items.count.zero?
}
end
- # rubocop: enable CodeReuse/ActiveRecord
private
@@ -50,5 +48,15 @@ module Issuable
attrs.push(:assignee_id)
end
end
+
+ def find_issuables(parent, model_class, ids)
+ if parent.is_a?(Project)
+ model_class.id_in(ids).of_projects(parent)
+ elsif parent.is_a?(Group)
+ model_class.id_in(ids).of_projects(parent.all_projects)
+ end
+ end
end
end
+
+Issuable::BulkUpdateService.prepend_if_ee('EE::Issuable::BulkUpdateService')
diff --git a/app/services/issuable/clone/attributes_rewriter.rb b/app/services/issuable/clone/attributes_rewriter.rb
index 10c89c62bf1..1f5d83917cc 100644
--- a/app/services/issuable/clone/attributes_rewriter.rb
+++ b/app/services/issuable/clone/attributes_rewriter.rb
@@ -10,7 +10,13 @@ module Issuable
end
def execute
- new_entity.update(milestone: cloneable_milestone, labels: cloneable_labels)
+ update_attributes = { labels: cloneable_labels }
+
+ milestone = cloneable_milestone
+ update_attributes[:milestone] = milestone if milestone.present?
+
+ new_entity.update(update_attributes)
+
copy_resource_label_events
end
diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb
index a170a4dcae2..846b881e819 100644
--- a/app/services/issuable/common_system_notes_service.rb
+++ b/app/services/issuable/common_system_notes_service.rb
@@ -7,20 +7,24 @@ module Issuable
def execute(issuable, old_labels: [], is_update: true)
@issuable = issuable
- if is_update
- if issuable.previous_changes.include?('title')
- create_title_change_note(issuable.previous_changes['title'].first)
+ # We disable touch so that created system notes do not update
+ # the noteable's updated_at field
+ ActiveRecord::Base.no_touching do
+ if is_update
+ if issuable.previous_changes.include?('title')
+ create_title_change_note(issuable.previous_changes['title'].first)
+ end
+
+ handle_description_change_note
+
+ handle_time_tracking_note if issuable.is_a?(TimeTrackable)
+ create_discussion_lock_note if issuable.previous_changes.include?('discussion_locked')
end
- handle_description_change_note
-
- handle_time_tracking_note if issuable.is_a?(TimeTrackable)
- create_discussion_lock_note if issuable.previous_changes.include?('discussion_locked')
+ create_due_date_note if issuable.previous_changes.include?('due_date')
+ create_milestone_note if issuable.previous_changes.include?('milestone_id')
+ create_labels_note(old_labels) if old_labels && issuable.labels != old_labels
end
-
- create_due_date_note if issuable.previous_changes.include?('due_date')
- create_milestone_note if issuable.previous_changes.include?('milestone_id')
- create_labels_note(old_labels) if old_labels && issuable.labels != old_labels
end
private
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 8a79c5f889d..6cb84458d9b 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -163,10 +163,12 @@ class IssuableBaseService < BaseService
before_create(issuable)
- if issuable.save
- ActiveRecord::Base.no_touching do
- Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, is_update: false)
- end
+ issuable_saved = issuable.with_transaction_returning_status do
+ issuable.save && issuable.store_mentions!
+ end
+
+ if issuable_saved
+ Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, is_update: false)
after_create(issuable)
execute_hooks(issuable)
@@ -226,11 +228,12 @@ class IssuableBaseService < BaseService
update_project_counters = issuable.project && update_project_counter_caches?(issuable)
ensure_milestone_available(issuable)
- if issuable.with_transaction_returning_status { issuable.save(touch: should_touch) }
- # We do not touch as it will affect a update on updated_at field
- ActiveRecord::Base.no_touching do
- Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: old_associations[:labels])
- end
+ issuable_saved = issuable.with_transaction_returning_status do
+ issuable.save(touch: should_touch) && issuable.store_mentions!
+ end
+
+ if issuable_saved
+ Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: old_associations[:labels])
handle_changes(issuable, old_associations: old_associations)
@@ -264,10 +267,7 @@ class IssuableBaseService < BaseService
before_update(issuable, skip_spam_check: true)
if issuable.with_transaction_returning_status { issuable.save }
- # We do not touch as it will affect a update on updated_at field
- ActiveRecord::Base.no_touching do
- Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: nil)
- end
+ Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: nil)
handle_task_changes(issuable)
invalidate_cache_counts(issuable, users: issuable.assignees.to_a)
@@ -397,7 +397,7 @@ class IssuableBaseService < BaseService
end
def update_project_counter_caches?(issuable)
- issuable.state_changed?
+ issuable.state_id_changed?
end
def parent
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index 48ed5afbc2a..974f7e598ca 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -36,3 +36,5 @@ module Issues
end
end
end
+
+Issues::BaseService.prepend_if_ee('EE::Issues::BaseService')
diff --git a/app/services/issues/duplicate_service.rb b/app/services/issues/duplicate_service.rb
index 82c226f601e..c936d75e277 100644
--- a/app/services/issues/duplicate_service.rb
+++ b/app/services/issues/duplicate_service.rb
@@ -25,3 +25,5 @@ module Issues
end
end
end
+
+Issues::DuplicateService.prepend_if_ee('EE::Issues::DuplicateService')
diff --git a/app/services/issues/zoom_link_service.rb b/app/services/issues/zoom_link_service.rb
index 023d7080e88..9572cf50564 100644
--- a/app/services/issues/zoom_link_service.rb
+++ b/app/services/issues/zoom_link_service.rb
@@ -13,30 +13,29 @@ module Issues
if can_add_link? && (link = parse_link(link))
begin
add_zoom_meeting(link)
- success(_('Zoom meeting added'))
rescue ActiveRecord::RecordNotUnique
- error(_('Failed to add a Zoom meeting'))
+ error(message: _('Failed to add a Zoom meeting'))
end
else
- error(_('Failed to add a Zoom meeting'))
+ error(message: _('Failed to add a Zoom meeting'))
end
end
def remove_link
if can_remove_link?
remove_zoom_meeting
- success(_('Zoom meeting removed'))
+ success(message: _('Zoom meeting removed'))
else
- error(_('Failed to remove a Zoom meeting'))
+ error(message: _('Failed to remove a Zoom meeting'))
end
end
def can_add_link?
- can_update_issue? && !@added_meeting
+ can_change_link? && !@added_meeting
end
def can_remove_link?
- can_update_issue? && !!@added_meeting
+ can_change_link? && @issue.persisted? && !!@added_meeting
end
def parse_link(link)
@@ -56,14 +55,29 @@ module Issues
end
def add_zoom_meeting(link)
- ZoomMeeting.create(
+ zoom_meeting = new_zoom_meeting(link)
+ response =
+ if @issue.persisted?
+ # Save the meeting directly since we only want to update one meeting, not all
+ zoom_meeting.save
+ success(message: _('Zoom meeting added'))
+ else
+ success(message: _('Zoom meeting added'), payload: { zoom_meetings: [zoom_meeting] })
+ end
+
+ track_meeting_added_event
+ SystemNoteService.zoom_link_added(@issue, @project, current_user)
+
+ response
+ end
+
+ def new_zoom_meeting(link)
+ ZoomMeeting.new(
issue: @issue,
- project: @issue.project,
+ project: @project,
issue_status: :added,
url: link
)
- track_meeting_added_event
- SystemNoteService.zoom_link_added(@issue, @project, current_user)
end
def remove_zoom_meeting
@@ -72,16 +86,20 @@ module Issues
SystemNoteService.zoom_link_removed(@issue, @project, current_user)
end
- def success(message)
- ServiceResponse.success(message: message)
+ def success(message:, payload: nil)
+ ServiceResponse.success(message: message, payload: payload)
end
- def error(message)
+ def error(message:)
ServiceResponse.error(message: message)
end
- def can_update_issue?
- can?(current_user, :update_issue, project)
+ def can_change_link?
+ if @issue.persisted?
+ can?(current_user, :update_issue, @project)
+ else
+ can?(current_user, :create_issue, @project)
+ end
end
end
end
diff --git a/app/services/merge_requests/create_from_issue_service.rb b/app/services/merge_requests/create_from_issue_service.rb
index 200a34cae04..95fb99d3e7a 100644
--- a/app/services/merge_requests/create_from_issue_service.rb
+++ b/app/services/merge_requests/create_from_issue_service.rb
@@ -19,7 +19,7 @@ module MergeRequests
return error('Not allowed to create merge request') unless can_create_merge_request?
return error('Invalid issue iid') unless @issue_iid.present? && issue.present?
- result = CreateBranchService.new(target_project, current_user).execute(branch_name, ref)
+ result = ::Branches::CreateService.new(target_project, current_user).execute(branch_name, ref)
return result if result[:status] == :error
new_merge_request = create(merge_request)
diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb
index a45b4f1142e..4a109fe4e16 100644
--- a/app/services/merge_requests/merge_service.rb
+++ b/app/services/merge_requests/merge_service.rb
@@ -62,8 +62,6 @@ module MergeRequests
end
def updated_check!
- return unless Feature.enabled?(:validate_merge_sha, merge_request.target_project, default_enabled: false)
-
unless source_matches?
raise_error('Branch has been updated since the merge was requested. '\
'Please review the changes.')
@@ -101,7 +99,7 @@ module MergeRequests
log_info("Post merge finished on JID #{merge_jid} with state #{state}")
if delete_source_branch?
- DeleteBranchService.new(@merge_request.source_project, branch_deletion_user)
+ ::Branches::DeleteService.new(@merge_request.source_project, branch_deletion_user)
.execute(merge_request.source_branch)
end
end
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index bd3fcf85a62..396ddec6383 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -106,7 +106,7 @@ module MergeRequests
filter_merge_requests(merge_requests).each do |merge_request|
if branch_and_project_match?(merge_request) || @push.force_push?
merge_request.reload_diff(current_user)
- elsif merge_request.includes_any_commits?(push_commit_ids)
+ elsif merge_request.merge_request_diff.includes_any_commits?(push_commit_ids)
merge_request.reload_diff(current_user)
end
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 8a6a7119508..1dc5503d368 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -87,7 +87,7 @@ module MergeRequests
merge_request.update(merge_error: nil)
- if merge_request.head_pipeline && merge_request.head_pipeline.active?
+ if merge_request.head_pipeline_active?
AutoMergeService.new(project, current_user, { sha: last_diff_sha }).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS)
else
merge_request.merge_async(current_user.id, { sha: last_diff_sha })
diff --git a/app/services/metrics/dashboard/base_embed_service.rb b/app/services/metrics/dashboard/base_embed_service.rb
index 8bb5f4892cb..8aef9873ac1 100644
--- a/app/services/metrics/dashboard/base_embed_service.rb
+++ b/app/services/metrics/dashboard/base_embed_service.rb
@@ -13,7 +13,7 @@ module Metrics
def dashboard_path
params[:dashboard_path].presence ||
- ::Metrics::Dashboard::SystemDashboardService::SYSTEM_DASHBOARD_PATH
+ ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH
end
def group
diff --git a/app/services/metrics/dashboard/custom_metric_embed_service.rb b/app/services/metrics/dashboard/custom_metric_embed_service.rb
index 79a556b1695..9e616f4e379 100644
--- a/app/services/metrics/dashboard/custom_metric_embed_service.rb
+++ b/app/services/metrics/dashboard/custom_metric_embed_service.rb
@@ -40,7 +40,7 @@ module Metrics
# All custom metrics are displayed on the system dashboard.
# Nil is acceptable as we'll default to the system dashboard.
def valid_dashboard?(dashboard)
- dashboard.nil? || ::Metrics::Dashboard::SystemDashboardService.system_dashboard?(dashboard)
+ dashboard.nil? || ::Metrics::Dashboard::SystemDashboardService.matching_dashboard?(dashboard)
end
end
diff --git a/app/services/metrics/dashboard/grafana_metric_embed_service.rb b/app/services/metrics/dashboard/grafana_metric_embed_service.rb
index 60591e9a6f3..44b58ad9729 100644
--- a/app/services/metrics/dashboard/grafana_metric_embed_service.rb
+++ b/app/services/metrics/dashboard/grafana_metric_embed_service.rb
@@ -133,7 +133,7 @@ module Metrics
def uid_regex
base_url = @project.grafana_integration.grafana_url.chomp('/')
- %r{(#{Regexp.escape(base_url)}\/d\/(?<uid>\w+)\/)}x
+ %r{^(#{Regexp.escape(base_url)}\/d\/(?<uid>.+)\/)}x
end
end
diff --git a/app/services/metrics/dashboard/pod_dashboard_service.rb b/app/services/metrics/dashboard/pod_dashboard_service.rb
new file mode 100644
index 00000000000..16b87d2d587
--- /dev/null
+++ b/app/services/metrics/dashboard/pod_dashboard_service.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Metrics
+ module Dashboard
+ class PodDashboardService < ::Metrics::Dashboard::PredefinedDashboardService
+ DASHBOARD_PATH = 'config/prometheus/pod_metrics.yml'
+ DASHBOARD_NAME = 'Pod Health'
+ end
+ end
+end
diff --git a/app/services/metrics/dashboard/predefined_dashboard_service.rb b/app/services/metrics/dashboard/predefined_dashboard_service.rb
new file mode 100644
index 00000000000..1be1a000854
--- /dev/null
+++ b/app/services/metrics/dashboard/predefined_dashboard_service.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Metrics
+ module Dashboard
+ class PredefinedDashboardService < ::Metrics::Dashboard::BaseService
+ # These constants should be overridden in the inheriting class. For Ex:
+ # DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'
+ # DASHBOARD_NAME = 'Default'
+ DASHBOARD_PATH = nil
+ DASHBOARD_NAME = nil
+
+ SEQUENCE = [
+ STAGES::EndpointInserter,
+ STAGES::Sorter
+ ].freeze
+
+ class << self
+ def matching_dashboard?(filepath)
+ filepath == self::DASHBOARD_PATH
+ end
+ end
+
+ private
+
+ def cache_key
+ "metrics_dashboard_#{dashboard_path}"
+ end
+
+ def dashboard_path
+ self.class::DASHBOARD_PATH
+ end
+
+ # Returns the base metrics shipped with every GitLab service.
+ def get_raw_dashboard
+ yml = File.read(Rails.root.join(dashboard_path))
+
+ YAML.safe_load(yml)
+ end
+
+ def sequence
+ self.class::SEQUENCE
+ 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 f8dbb8a705c..bef65dbe1c2 100644
--- a/app/services/metrics/dashboard/system_dashboard_service.rb
+++ b/app/services/metrics/dashboard/system_dashboard_service.rb
@@ -1,12 +1,12 @@
# frozen_string_literal: true
# Fetches the system metrics dashboard and formats the output.
-# Use Gitlab::Metrics::Dashboard::Finder to retrive dashboards.
+# Use Gitlab::Metrics::Dashboard::Finder to retrieve dashboards.
module Metrics
module Dashboard
- class SystemDashboardService < ::Metrics::Dashboard::BaseService
- SYSTEM_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'
- SYSTEM_DASHBOARD_NAME = 'Default'
+ class SystemDashboardService < ::Metrics::Dashboard::PredefinedDashboardService
+ DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'
+ DASHBOARD_NAME = 'Default'
SEQUENCE = [
STAGES::CommonMetricsInserter,
@@ -18,37 +18,12 @@ module Metrics
class << self
def all_dashboard_paths(_project)
[{
- path: SYSTEM_DASHBOARD_PATH,
- display_name: SYSTEM_DASHBOARD_NAME,
+ path: DASHBOARD_PATH,
+ display_name: DASHBOARD_NAME,
default: true,
system_dashboard: true
}]
end
-
- def system_dashboard?(filepath)
- filepath == SYSTEM_DASHBOARD_PATH
- end
- end
-
- private
-
- def cache_key
- "metrics_dashboard_#{dashboard_path}"
- end
-
- def dashboard_path
- SYSTEM_DASHBOARD_PATH
- end
-
- # Returns the base metrics shipped with every GitLab service.
- def get_raw_dashboard
- yml = File.read(Rails.root.join(dashboard_path))
-
- YAML.safe_load(yml)
- end
-
- def sequence
- SEQUENCE
end
end
end
diff --git a/app/services/metrics/sample_metrics_service.rb b/app/services/metrics/sample_metrics_service.rb
new file mode 100644
index 00000000000..719bc6614e4
--- /dev/null
+++ b/app/services/metrics/sample_metrics_service.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Metrics
+ class SampleMetricsService
+ DIRECTORY = "sample_metrics"
+
+ attr_reader :identifier
+
+ def initialize(identifier)
+ @identifier = identifier
+ end
+
+ def query
+ return unless identifier && File.exist?(file_location)
+
+ YAML.load_file(File.expand_path(file_location, __dir__))
+ end
+
+ private
+
+ def file_location
+ sanitized_string = identifier.gsub(/[^0-9A-Za-z_]/, '')
+ File.join(Rails.root, DIRECTORY, "#{sanitized_string}.yml")
+ end
+ end
+end
diff --git a/app/services/notes/base_service.rb b/app/services/notes/base_service.rb
index b4d04c47cc0..87f7cb0e8ac 100644
--- a/app/services/notes/base_service.rb
+++ b/app/services/notes/base_service.rb
@@ -4,7 +4,7 @@ module Notes
class BaseService < ::BaseService
def clear_noteable_diffs_cache(note)
if note.is_a?(DiffNote) &&
- note.discussion_first_note? &&
+ note.start_of_discussion? &&
note.position.unfolded_diff?(project.repository)
note.noteable.diffs.clear_cache
end
diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb
index 541f3e0d23c..cf21818a886 100644
--- a/app/services/notes/build_service.rb
+++ b/app/services/notes/build_service.rb
@@ -11,7 +11,7 @@ module Notes
unless discussion && can?(current_user, :create_note, discussion.noteable)
note = Note.new
- note.errors.add(:base, 'Discussion to reply to cannot be found')
+ note.errors.add(:base, _('Discussion to reply to cannot be found'))
return note
end
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index 9e6cbfa06fe..accfdb5b863 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -2,6 +2,7 @@
module Notes
class CreateService < ::Notes::BaseService
+ # rubocop:disable Metrics/CyclomaticComplexity
def execute
merge_request_diff_head_sha = params.delete(:merge_request_diff_head_sha)
@@ -9,7 +10,9 @@ module Notes
# n+1: https://gitlab.com/gitlab-org/gitlab-foss/issues/37440
note_valid = Gitlab::GitalyClient.allow_n_plus_1_calls do
- note.valid?
+ # We may set errors manually in Notes::BuildService for this reason
+ # we also need to check for already existing errors.
+ note.errors.empty? && note.valid?
end
return note unless note_valid
@@ -33,7 +36,11 @@ module Notes
NewNoteWorker.perform_async(note.id)
end
- if !only_commands && note.save
+ note_saved = note.with_transaction_returning_status do
+ !only_commands && note.save && note.store_mentions!
+ end
+
+ if note_saved
if note.part_of_discussion? && note.discussion.can_convert_to_discussion?
note.discussion.convert_to_discussion!(save: true)
end
@@ -63,6 +70,7 @@ 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 573be8fbe8b..15c556498ec 100644
--- a/app/services/notes/update_service.rb
+++ b/app/services/notes/update_service.rb
@@ -7,7 +7,11 @@ module Notes
old_mentioned_users = note.mentioned_users(current_user).to_a
- note.update(params.merge(updated_by: current_user))
+ note.assign_attributes(params.merge(updated_by: current_user))
+
+ note.with_transaction_returning_status do
+ note.save && note.store_mentions!
+ end
only_commands = false
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 1709474a6c7..a75eaa99c23 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -58,6 +58,14 @@ class NotificationService
end
end
+ # Notify the owner of the personal access token, when it is about to expire
+ # And mark the token with about_to_expire_delivered
+ def access_token_about_to_expire(user)
+ return unless user.can?(:receive_notifications)
+
+ mailer.access_token_about_to_expire_email(user).deliver_later
+ end
+
# When create an issue we should send an email to:
#
# * issue assignee if their notification level is not Disabled
diff --git a/app/services/pages/delete_service.rb b/app/services/pages/delete_service.rb
new file mode 100644
index 00000000000..d4de6bb750d
--- /dev/null
+++ b/app/services/pages/delete_service.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Pages
+ class DeleteService < BaseService
+ def execute
+ project.remove_pages
+ project.pages_domains.destroy_all # rubocop: disable DestroyAll
+ 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 1b880a7aab1..b995df12e56 100644
--- a/app/services/projects/container_repository/cleanup_tags_service.rb
+++ b/app/services/projects/container_repository/cleanup_tags_service.rb
@@ -26,13 +26,13 @@ module Projects
def delete_tags(tags_to_delete, tags_by_digest)
deleted_digests = group_by_digest(tags_to_delete).select do |digest, tags|
- delete_tag_digest(digest, tags, tags_by_digest[digest])
+ delete_tag_digest(tags, tags_by_digest[digest])
end
deleted_digests.values.flatten
end
- def delete_tag_digest(digest, tags, other_tags)
+ def delete_tag_digest(tags, other_tags)
# Issue: https://gitlab.com/gitlab-org/gitlab-foss/issues/21405
# we have to remove all tags due
# to Docker Distribution bug unable
diff --git a/app/services/projects/container_repository/delete_tags_service.rb b/app/services/projects/container_repository/delete_tags_service.rb
index 48bd9394dc5..88ff3c2c9df 100644
--- a/app/services/projects/container_repository/delete_tags_service.rb
+++ b/app/services/projects/container_repository/delete_tags_service.rb
@@ -24,32 +24,36 @@ module Projects
dummy_manifest = container_repository.client.generate_empty_manifest(container_repository.path)
return error('could not generate manifest') if dummy_manifest.nil?
- # update the manifests of the tags with the new dummy image
- deleted_tags = []
- tag_digests = []
+ deleted_tags = replace_tag_manifests(container_repository, dummy_manifest, tag_names)
+
+ # 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)
+ success(deleted: deleted_tags.keys)
+ else
+ error('could not delete tags')
+ 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 = {}
tag_names.each do |name|
digest = container_repository.client.put_tag(container_repository.path, name, dummy_manifest)
next unless digest
- deleted_tags << name
- tag_digests << digest
+ deleted_tags[name] = digest
end
# make sure the digests are the same (it should always be)
- tag_digests.uniq!
+ digests = deleted_tags.values.uniq
# rubocop: disable CodeReuse/ActiveRecord
- Gitlab::Sentry.track_exception(ArgumentError.new('multiple tag digests')) if tag_digests.many?
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(ArgumentError.new('multiple tag digests')) if digests.many?
- # Deletes the dummy image
- # All created tag digests are the same since they all have the same dummy image.
- # a single delete is sufficient to remove all tags with it
- if tag_digests.any? && container_repository.delete_tag_by_digest(tag_digests.first)
- success(deleted: deleted_tags)
- else
- error('could not delete tags')
- end
+ deleted_tags
end
end
end
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index 90e703e7050..cbed794f92e 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -31,13 +31,6 @@ module Projects
Projects::UnlinkForkService.new(project, current_user).execute
- # The project is not necessarily a fork, so update the fork network originating
- # from this project
- if fork_network = project.root_of_fork_network
- fork_network.update(root_project: nil,
- deleted_root_project_name: project.full_name)
- end
-
attempt_destroy_transaction(project)
system_hook_service.execute_hooks_for(project, :destroy)
diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb
index 47ab7f9a8a0..e66a0ed181a 100644
--- a/app/services/projects/fork_service.rb
+++ b/app/services/projects/fork_service.rb
@@ -3,11 +3,16 @@
module Projects
class ForkService < BaseService
def execute(fork_to_project = nil)
- if fork_to_project
- link_existing_project(fork_to_project)
- else
- fork_new_project
- end
+ forked_project =
+ if fork_to_project
+ link_existing_project(fork_to_project)
+ else
+ fork_new_project
+ end
+
+ refresh_forks_count if forked_project&.saved?
+
+ forked_project
end
private
@@ -92,8 +97,7 @@ module Projects
def link_fork_network(fork_to_project)
return if fork_to_project.errors.any?
- fork_to_project.fork_network_member.save &&
- refresh_forks_count
+ fork_to_project.fork_network_member.save
end
def refresh_forks_count
diff --git a/app/services/projects/hashed_storage/base_repository_service.rb b/app/services/projects/hashed_storage/base_repository_service.rb
index 8b1bcaf17b7..09de8d9f0da 100644
--- a/app/services/projects/hashed_storage/base_repository_service.rb
+++ b/app/services/projects/hashed_storage/base_repository_service.rb
@@ -8,13 +8,12 @@ module Projects
class BaseRepositoryService < BaseService
include Gitlab::ShellAdapter
- attr_reader :old_disk_path, :new_disk_path, :old_wiki_disk_path, :old_storage_version, :logger, :move_wiki
+ attr_reader :old_disk_path, :new_disk_path, :old_storage_version, :logger, :move_wiki
def initialize(project:, old_disk_path:, logger: nil)
@project = project
@logger = logger || Gitlab::AppLogger
@old_disk_path = old_disk_path
- @old_wiki_disk_path = "#{old_disk_path}.wiki"
@move_wiki = has_wiki?
end
@@ -44,9 +43,21 @@ module Projects
gitlab_shell.mv_repository(project.repository_storage, from_name, to_name)
end
+ def move_repositories
+ result = move_repository(old_disk_path, new_disk_path)
+ project.reload_repository!
+
+ if move_wiki
+ result &&= move_repository(old_wiki_disk_path, new_wiki_disk_path)
+ project.clear_memoization(:wiki)
+ end
+
+ result
+ end
+
def rollback_folder_move
move_repository(new_disk_path, old_disk_path)
- move_repository("#{new_disk_path}.wiki", old_wiki_disk_path)
+ move_repository(new_wiki_disk_path, old_wiki_disk_path)
end
def try_to_set_repository_read_only!
@@ -58,6 +69,20 @@ module Projects
raise RepositoryInUseError, migration_error
end
end
+
+ def wiki_path_suffix
+ @wiki_path_suffix ||= Gitlab::GlRepository::WIKI.path_suffix
+ end
+
+ def old_wiki_disk_path
+ @old_wiki_disk_path ||= "#{old_disk_path}#{wiki_path_suffix}"
+ end
+
+ def new_wiki_disk_path
+ @new_wiki_disk_path ||= "#{new_disk_path}#{wiki_path_suffix}"
+ end
end
end
end
+
+Projects::HashedStorage::BaseRepositoryService.prepend_if_ee('EE::Projects::HashedStorage::BaseRepositoryService')
diff --git a/app/services/projects/hashed_storage/migrate_repository_service.rb b/app/services/projects/hashed_storage/migrate_repository_service.rb
index 0a0bd90cd20..fd62ac37d27 100644
--- a/app/services/projects/hashed_storage/migrate_repository_service.rb
+++ b/app/services/projects/hashed_storage/migrate_repository_service.rb
@@ -11,11 +11,7 @@ module Projects
@new_disk_path = project.disk_path
- result = move_repository(old_disk_path, new_disk_path)
-
- if move_wiki
- result &&= move_repository(old_wiki_disk_path, "#{new_disk_path}.wiki")
- end
+ result = move_repositories
if result
project.write_repository_config
diff --git a/app/services/projects/hashed_storage/rollback_repository_service.rb b/app/services/projects/hashed_storage/rollback_repository_service.rb
index a705112ebe3..d6646e3765e 100644
--- a/app/services/projects/hashed_storage/rollback_repository_service.rb
+++ b/app/services/projects/hashed_storage/rollback_repository_service.rb
@@ -11,11 +11,7 @@ module Projects
@new_disk_path = project.disk_path
- result = move_repository(old_disk_path, new_disk_path)
-
- if move_wiki
- result &&= move_repository(old_wiki_disk_path, "#{new_disk_path}.wiki")
- end
+ result = move_repositories
if result
project.write_repository_config
diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb
index 073c14040ce..cc12aacaf02 100644
--- a/app/services/projects/import_service.rb
+++ b/app/services/projects/import_service.rb
@@ -25,13 +25,13 @@ module Projects
success
rescue Gitlab::UrlBlocker::BlockedUrlError => e
- Gitlab::Sentry.track_acceptable_exception(e, extra: { project_path: project.full_path, importer: project.import_type })
+ Gitlab::ErrorTracking.track_exception(e, project_path: project.full_path, importer: project.import_type)
error(s_("ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}") % { project_safe_import_url: project.safe_import_url, project_full_path: project.full_path, message: e.message })
rescue => e
message = Projects::ImportErrorFilter.filter_message(e.message)
- Gitlab::Sentry.track_acceptable_exception(e, extra: { project_path: project.full_path, importer: project.import_type })
+ Gitlab::ErrorTracking.track_exception(e, project_path: project.full_path, importer: project.import_type)
error(s_("ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}") % { project_safe_import_url: project.safe_import_url, project_full_path: project.full_path, message: message })
end
diff --git a/app/services/projects/overwrite_project_service.rb b/app/services/projects/overwrite_project_service.rb
index 696e1b665b2..c5e38f166da 100644
--- a/app/services/projects/overwrite_project_service.rb
+++ b/app/services/projects/overwrite_project_service.rb
@@ -7,7 +7,9 @@ module Projects
Project.transaction do
move_before_destroy_relationships(source_project)
- destroy_old_project(source_project)
+ # Reset is required in order to get the proper
+ # uncached fork network method calls value.
+ destroy_old_project(source_project.reset)
rename_project(source_project.name, source_project.path)
@project
diff --git a/app/services/projects/unlink_fork_service.rb b/app/services/projects/unlink_fork_service.rb
index 1b8a920268f..e7e0141099e 100644
--- a/app/services/projects/unlink_fork_service.rb
+++ b/app/services/projects/unlink_fork_service.rb
@@ -2,34 +2,67 @@
module Projects
class UnlinkForkService < BaseService
- # rubocop: disable CodeReuse/ActiveRecord
+ # If a fork is given, it:
+ #
+ # - Saves LFS objects to the root project
+ # - Close existing MRs coming from it
+ # - Is removed from the fork network
+ #
+ # If a root of fork(s) is given, it does the same,
+ # but not updating LFS objects (there'll be no related root to cache it).
def execute
- return unless @project.forked?
+ fork_network = @project.fork_network
- if fork_source = @project.fork_source
- fork_source.lfs_objects.find_each do |lfs_object|
- lfs_object.projects << @project unless lfs_object.projects.include?(@project)
- end
+ return unless fork_network
- refresh_forks_count(fork_source)
- end
+ save_lfs_objects
- merge_requests = @project.fork_network
+ merge_requests = fork_network
.merge_requests
.opened
- .where.not(target_project: @project)
- .from_project(@project)
+ .from_and_to_forks(@project)
- merge_requests.each do |mr|
+ merge_requests.find_each do |mr|
::MergeRequests::CloseService.new(@project, @current_user).execute(mr)
end
- @project.fork_network_member.destroy
+ Project.transaction do
+ # Get out of the fork network as a member and
+ # remove references from all its direct forks.
+ @project.fork_network_member.destroy
+ @project.forked_to_members.update_all(forked_from_project_id: nil)
+
+ # The project is not necessarily a fork, so update the fork network originating
+ # from this project
+ if fork_network = @project.root_of_fork_network
+ fork_network.update(root_project: nil, deleted_root_project_name: @project.full_name)
+ end
+ end
+
+ # When the project getting out of the network is a node with parent
+ # and children, both the parent and the node needs a cache refresh.
+ [@project.forked_from_project, @project].compact.each do |project|
+ refresh_forks_count(project)
+ end
end
- # rubocop: enable CodeReuse/ActiveRecord
+
+ private
def refresh_forks_count(project)
Projects::ForksCountService.new(project).refresh_cache
end
+
+ def save_lfs_objects
+ return unless @project.forked?
+
+ lfs_storage_project = @project.lfs_storage_project
+
+ return unless lfs_storage_project
+ return if lfs_storage_project == @project # that project is being unlinked
+
+ lfs_storage_project.lfs_objects.find_each do |lfs_object|
+ lfs_object.projects << @project unless lfs_object.projects.include?(@project)
+ end
+ end
end
end
diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb
index 2dad1d05a2c..aedd7252f63 100644
--- a/app/services/projects/update_service.rb
+++ b/app/services/projects/update_service.rb
@@ -65,7 +65,7 @@ module Projects
)
project_changed_feature_keys = project.project_feature.previous_changes.keys
- if project.previous_changes.include?(:visibility_level) && project.private?
+ if project.visibility_level_previous_changes && project.private?
# don't enqueue immediately to prevent todos removal in case of a mistake
TodosDestroyer::ConfidentialIssueWorker.perform_in(Todo::WAIT_FOR_DELETE, nil, project.id)
TodosDestroyer::ProjectPrivateWorker.perform_in(Todo::WAIT_FOR_DELETE, project.id)
@@ -79,6 +79,11 @@ module Projects
system_hook_service.execute_hooks_for(project, :update)
end
+ if project.visibility_level_decreased? && project.unlink_forks_upon_visibility_decrease_enabled?
+ # It's a system-bounded operation, so no extra authorization check is required.
+ Projects::UnlinkForkService.new(project, current_user).execute
+ end
+
update_pages_config if changing_pages_related_config?
end
diff --git a/app/services/prometheus/proxy_variable_substitution_service.rb b/app/services/prometheus/proxy_variable_substitution_service.rb
new file mode 100644
index 00000000000..ca56292e9d6
--- /dev/null
+++ b/app/services/prometheus/proxy_variable_substitution_service.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Prometheus
+ class ProxyVariableSubstitutionService < BaseService
+ include Stepable
+
+ steps :add_params_to_result, :substitute_ruby_variables
+
+ def initialize(environment, params = {})
+ @environment, @params = environment, params.deep_dup
+ end
+
+ def execute
+ execute_steps
+ end
+
+ private
+
+ def add_params_to_result(result)
+ result[:params] = params
+
+ success(result)
+ end
+
+ def substitute_ruby_variables(result)
+ return success(result) unless query
+
+ # The % operator doesn't replace variables if the hash contains string
+ # keys.
+ result[:params][:query] = query % predefined_context.symbolize_keys
+
+ success(result)
+ rescue TypeError, ArgumentError => exception
+ log_error(exception.message)
+ Gitlab::ErrorTracking.track_exception(exception, extra: {
+ template_string: query,
+ variables: predefined_context
+ })
+
+ error(_('Malformed string'))
+ end
+
+ def predefined_context
+ @predefined_context ||= Gitlab::Prometheus::QueryVariables.call(@environment)
+ end
+
+ def query
+ params[:query]
+ end
+ end
+end
diff --git a/app/services/repair_ldap_blocked_user_service.rb b/app/services/repair_ldap_blocked_user_service.rb
deleted file mode 100644
index 6ed42054ac3..00000000000
--- a/app/services/repair_ldap_blocked_user_service.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-class RepairLdapBlockedUserService
- attr_accessor :user
-
- def initialize(user)
- @user = user
- end
-
- def execute
- user.block if ldap_hard_blocked?
- end
-
- private
-
- def ldap_hard_blocked?
- user.ldap_blocked? && !user.ldap_user?
- end
-end
diff --git a/app/services/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb
index 415a02ab337..7927ab265c5 100644
--- a/app/services/submit_usage_ping_service.rb
+++ b/app/services/submit_usage_ping_service.rb
@@ -38,7 +38,7 @@ class SubmitUsagePingService
def store_metrics(response)
return unless response['conv_index'].present?
- ConversationalDevelopmentIndex::Metric.create!(
+ DevOpsScore::Metric.create!(
response['conv_index'].slice(*METRICS)
)
end
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index 2299a02fea1..55f888d5664 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -174,6 +174,19 @@ class TodoService
mark_todos_as_done(todos, current_user)
end
+ def mark_all_todos_as_done_by_user(current_user)
+ todos = TodosFinder.new(current_user).execute
+ mark_todos_as_done(todos, current_user)
+ end
+
+ def mark_todo_as_done(todo, current_user)
+ return if todo.done?
+
+ todo.update(state: :done)
+
+ current_user.update_todos_count_cache
+ end
+
# When user marks some todos as pending
def mark_todos_as_pending(todos, current_user)
update_todos_state(todos, current_user, :pending)
diff --git a/app/services/update_snippet_service.rb b/app/services/update_snippet_service.rb
index a294812ef9e..ac7f8e9b1f5 100644
--- a/app/services/update_snippet_service.rb
+++ b/app/services/update_snippet_service.rb
@@ -25,8 +25,12 @@ class UpdateSnippetService < BaseService
snippet.assign_attributes(params)
spam_check(snippet, current_user)
- snippet.save.tap do |succeeded|
- Gitlab::UsageDataCounters::SnippetCounter.count(:update) if succeeded
+ snippet_saved = snippet.with_transaction_returning_status do
+ snippet.save && snippet.store_mentions!
+ end
+
+ if snippet_saved
+ Gitlab::UsageDataCounters::SnippetCounter.count(:update)
end
end
end
diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb
index 8c85ad9ffd8..ea4d11e728e 100644
--- a/app/services/users/build_service.rb
+++ b/app/services/users/build_service.rb
@@ -23,7 +23,7 @@ module Users
@reset_token = user.generate_reset_token if params[:reset_password]
if user_params[:force_random_password]
- random_password = Devise.friendly_token.first(Devise.password_length.min)
+ random_password = Devise.friendly_token.first(User.password_length.min)
user.password = user.password_confirmation = random_password
end
end
diff --git a/app/services/users/repair_ldap_blocked_service.rb b/app/services/users/repair_ldap_blocked_service.rb
new file mode 100644
index 00000000000..378145a65b3
--- /dev/null
+++ b/app/services/users/repair_ldap_blocked_service.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Users
+ class RepairLdapBlockedService
+ attr_accessor :user
+
+ def initialize(user)
+ @user = user
+ end
+
+ def execute
+ user.block if ldap_hard_blocked?
+ end
+
+ private
+
+ def ldap_hard_blocked?
+ user.ldap_blocked? && !user.ldap_user?
+ end
+ end
+end
diff --git a/app/services/validate_new_branch_service.rb b/app/services/validate_new_branch_service.rb
deleted file mode 100644
index 3f4a59e5cee..00000000000
--- a/app/services/validate_new_branch_service.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-require_relative 'base_service'
-
-class ValidateNewBranchService < BaseService
- def execute(branch_name, force: false)
- valid_branch = Gitlab::GitRefValidator.validate(branch_name)
-
- unless valid_branch
- return error('Branch name is invalid')
- end
-
- if project.repository.branch_exists?(branch_name) && !force
- return error('Branch already exists')
- end
-
- success
- rescue Gitlab::Git::PreReceiveError => ex
- error(ex.message)
- end
-end
diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb
index 8c294218708..87edac36e33 100644
--- a/app/services/web_hook_service.rb
+++ b/app/services/web_hook_service.rb
@@ -92,9 +92,6 @@ class WebHookService
end
def log_execution(trigger:, url:, request_data:, response:, execution_duration:, error_message: nil)
- # logging for ServiceHook's is not available
- return if hook.is_a?(ServiceHook)
-
WebHookLog.create(
web_hook: hook,
trigger: trigger,
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 4358365504a..6b95c0f40c5 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -23,6 +23,9 @@
= 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')
+
+ = render_if_exists 'admin/application_settings/personal_access_token_expiration_policy', form: f
+
.form-group
= f.label :user_oauth_applications, _('User OAuth applications'), class: 'label-bold'
.form-check
diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml
index 9806090c1a6..cb9f992bb1d 100644
--- a/app/views/admin/application_settings/_ci_cd.html.haml
+++ b/app/views/admin/application_settings/_ci_cd.html.haml
@@ -58,6 +58,6 @@
= f.text_field :default_ci_config_path, class: 'form-control', placeholder: '.gitlab-ci.yml'
%p.form-text.text-muted
= _("The default CI configuration path for new projects.").html_safe
- = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'custom-ci-config-path'), target: '_blank'
+ = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'custom-ci-configuration-path'), target: '_blank'
= f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml
index 7c1df78f30c..b9d9d86ca30 100644
--- a/app/views/admin/application_settings/_signup.html.haml
+++ b/app/views/admin/application_settings/_signup.html.haml
@@ -13,6 +13,12 @@
= f.label :send_user_confirmation_email, class: 'form-check-label' do
Send confirmation email on sign-up
.form-group
+ = f.label :minimum_password_length, _('Minimum password length (number of characters)'), class: 'label-bold'
+ = f.number_field :minimum_password_length, class: 'form-control', rows: 4, min: ApplicationSetting::DEFAULT_MINIMUM_PASSWORD_LENGTH, max: Devise.password_length.max
+ - password_policy_guidelines_link = link_to _('Password Policy Guidelines'), 'https://about.gitlab.com/handbook/security/#gitlab-password-policy-guidelines', target: '_blank', rel: 'noopener noreferrer nofollow'
+ .form-text.text-muted
+ = _("See GitLab's %{password_policy_guidelines}").html_safe % { password_policy_guidelines: password_policy_guidelines_link }
+ .form-group
= f.label :domain_whitelist, 'Whitelisted domains for sign-ups', class: 'label-bold'
= f.text_area :domain_whitelist_raw, placeholder: 'domain.com', class: 'form-control', rows: 8
.form-text.text-muted ONLY users with e-mail addresses that match these domain(s) will be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com
diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml
index be5f1f4f9a8..ae90ffd9efc 100644
--- a/app/views/admin/application_settings/_visibility_and_access.html.haml
+++ b/app/views/admin/application_settings/_visibility_and_access.html.haml
@@ -9,6 +9,7 @@
= f.label s_('ProjectCreationLevel|Default project creation protection'), class: 'label-bold'
= f.select :default_project_creation, options_for_select(Gitlab::Access.project_creation_options, @application_setting.default_project_creation), {}, class: 'form-control'
= render_if_exists 'admin/application_settings/default_project_deletion_protection_setting', form: f
+ = render_if_exists 'admin/application_settings/default_project_deletion_adjourned_period_setting', form: f
.form-group.visibility-level-setting
= f.label :default_project_visibility, class: 'label-bold'
= render('shared/visibility_radios', model_method: :default_project_visibility, form: f, selected_level: @application_setting.default_project_visibility, form_model: Project.new)
@@ -53,6 +54,7 @@
= select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control')
%span.form-text.text-muted#clone-protocol-help
= _('Allow only the selected protocols to be used for Git access.')
+
.form-group
= f.label :custom_http_clone_url_root, _('Custom Git clone URL for HTTP(S)'), class: 'label-bold'
= f.text_field :custom_http_clone_url_root, class: 'form-control', placeholder: 'https://git.example.com', :'aria-describedby' => 'custom_http_clone_url_root_help_block'
diff --git a/app/views/admin/application_settings/integrations.html.haml b/app/views/admin/application_settings/integrations.html.haml
index 0aa833e49a8..c6318c9bb2f 100644
--- a/app/views/admin/application_settings/integrations.html.haml
+++ b/app/views/admin/application_settings/integrations.html.haml
@@ -8,5 +8,5 @@
= render_if_exists 'admin/application_settings/slack'
= render 'admin/application_settings/third_party_offers'
= render 'admin/application_settings/snowplow'
-= render 'admin/application_settings/eks' if Feature.enabled?(:create_eks_clusters)
+= render 'admin/application_settings/eks'
diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml
index 962234d3aea..44d57beec0f 100644
--- a/app/views/admin/broadcast_messages/_form.html.haml
+++ b/app/views/admin/broadcast_messages/_form.html.haml
@@ -1,5 +1,5 @@
.broadcast-message-preview{ style: broadcast_message_style(@broadcast_message) }
- = icon('bullhorn')
+ = sprite_icon('bullhorn', size: 16, css_class:'vertical-align-text-top mr-2')
.js-broadcast-message-preview
- if @broadcast_message.message.present?
= render_broadcast_message(@broadcast_message)
@@ -40,6 +40,13 @@
= f.color_field :font, class: "form-control text-font-color"
.form-group.row
.col-sm-2.col-form-label
+ = f.label :target_path, _('Target Path')
+ .col-sm-10
+ = f.text_field :target_path, class: "form-control"
+ .form-text.text-muted
+ = _('Paths can contain wildcards, like */welcome')
+ .form-group.row
+ .col-sm-2.col-form-label
= f.label :starts_at, _("Starts at (UTC)")
.col-sm-10.datetime-controls
= f.datetime_select :starts_at, {}, class: 'form-control form-control-inline'
diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml
index eb4dfdf2858..4731421fd9e 100644
--- a/app/views/admin/broadcast_messages/index.html.haml
+++ b/app/views/admin/broadcast_messages/index.html.haml
@@ -19,6 +19,7 @@
%th Preview
%th Starts
%th Ends
+ %th Target Path
%th &nbsp;
%tbody
- @broadcast_messages.each do |message|
@@ -32,6 +33,8 @@
%td
= message.ends_at
%td
+ = message.target_path
+ %td
= link_to sprite_icon('pencil-square'), edit_admin_broadcast_message_path(message), title: 'Edit', class: 'btn'
= link_to sprite_icon('remove'), admin_broadcast_message_path(message), method: :delete, remote: true, title: 'Remove', class: 'js-remove-tr btn btn-danger'
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index e5a3c0df9bf..6b138445675 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -46,7 +46,8 @@
enabled: allow_signup?)
= feature_entry(_('LDAP'),
- enabled: Gitlab.config.ldap.enabled)
+ enabled: Gitlab.config.ldap.enabled,
+ doc_href: help_page_path('administration/auth/ldap'))
= feature_entry(_('Gravatar'),
href: admin_application_settings_path(anchor: 'js-account-settings'),
@@ -54,10 +55,12 @@
= feature_entry(_('OmniAuth'),
href: admin_application_settings_path(anchor: 'js-signin-settings'),
- enabled: Gitlab::Auth.omniauth_enabled?)
+ enabled: Gitlab::Auth.omniauth_enabled?,
+ doc_href: help_page_path('integration/omniauth'))
= feature_entry(_('Reply by email'),
- enabled: Gitlab::IncomingEmail.enabled?)
+ enabled: Gitlab::IncomingEmail.enabled?,
+ doc_href: help_page_path('administration/reply_by_email'))
= render_if_exists 'admin/dashboard/elastic_and_geo'
diff --git a/app/views/admin/groups/_group.html.haml b/app/views/admin/groups/_group.html.haml
index 395c469255e..3444e423235 100644
--- a/app/views/admin/groups/_group.html.haml
+++ b/app/views/admin/groups/_group.html.haml
@@ -1,7 +1,7 @@
- group = local_assigns.fetch(:group)
- css_class = 'no-description' if group.description.blank?
-%li.group-row{ class: css_class }
+%li.group-row.py-3{ class: css_class }
.controls
= link_to _('Edit'), admin_group_edit_path(group), id: "edit_#{dom_id(group)}", class: 'btn'
= link_to _('Delete'), [:admin, group], data: { confirm: _("Are you sure you want to remove %{group_name}?") % { group_name: group.name } }, method: :delete, class: 'btn btn-remove'
diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml
index f9cc118a252..160c3b4d06d 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -19,7 +19,8 @@
= group_icon(@group, class: "avatar s60")
%li
%span.light= _('Name:')
- %strong= @group.name
+ %strong
+ = link_to @group.name, group_path(@group)
%li
%span.light= _('Path:')
%strong
diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml
index 9c6c74ed965..9ce0fa8d401 100644
--- a/app/views/admin/hooks/edit.html.haml
+++ b/app/views/admin/hooks/edit.html.haml
@@ -1,21 +1,18 @@
-- add_to_breadcrumbs "System Hooks", admin_hooks_path
-- page_title 'Edit System Hook'
-%h3.page-title
- Edit System Hook
+- add_to_breadcrumbs @hook.pluralized_name, admin_hooks_path
+- page_title _('Edit System Hook')
-%p.light
- #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks')} can be
- used for binding events when GitLab creates a User or Project.
+.row.prepend-top-default
+ .col-lg-3
+ = render 'shared/web_hooks/title_and_docs', hook: @hook
-%hr
+ .col-lg-9.append-bottom-default
+ = form_for @hook, as: :hook, url: admin_hook_path do |f|
+ = render partial: 'form', locals: { form: f, hook: @hook }
+ .form-actions
+ %span>= f.submit _('Save changes'), class: 'btn btn-success append-right-8'
+ = render 'shared/web_hooks/test_button', hook: @hook
+ = link_to _('Delete'), admin_hook_path(@hook), method: :delete, class: 'btn btn-remove float-right', data: { confirm: _('Are you sure?') }
-= form_for @hook, as: :hook, url: admin_hook_path do |f|
- = render partial: 'form', locals: { form: f, hook: @hook }
- .form-actions
- = f.submit 'Save changes', class: 'btn btn-success'
- = render 'shared/web_hooks/test_button', triggers: SystemHook.triggers, hook: @hook
- = link_to 'Remove', admin_hook_path(@hook), method: :delete, class: 'btn btn-remove float-right', data: { confirm: 'Are you sure?' }
+ %hr
-%hr
-
-= render partial: 'admin/hook_logs/index', locals: { hook: @hook, hook_logs: @hook_logs }
+ = render partial: 'admin/hook_logs/index', locals: { hook: @hook, hook_logs: @hook_logs }
diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml
index b65bf07160a..eed3ec74d60 100644
--- a/app/views/admin/hooks/index.html.haml
+++ b/app/views/admin/hooks/index.html.haml
@@ -1,35 +1,14 @@
-- page_title 'System Hooks'
+- page_title @hook.pluralized_name
+
.row.prepend-top-default
.col-lg-4
- %h4.prepend-top-0
- = page_title
- %p
- #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks')} can be
- used for binding events when GitLab creates a User or Project.
+ = render 'shared/web_hooks/title_and_docs', hook: @hook
.col-lg-8.append-bottom-default
= form_for @hook, as: :hook, url: admin_hooks_path do |f|
= render partial: 'form', locals: { form: f, hook: @hook }
- = f.submit 'Add system hook', class: 'btn btn-success'
-
- %hr
+ = f.submit _('Add system hook'), class: 'btn btn-success'
- - if @hooks.any?
- .card
- .card-header
- System hooks (#{@hooks.count})
- %ul.content-list
- - @hooks.each do |hook|
- %li
- .controls
- = render 'shared/web_hooks/test_button', triggers: SystemHook.triggers, hook: hook, button_class: 'btn-sm'
- = link_to 'Edit', edit_admin_hook_path(hook), class: 'btn btn-sm'
- = link_to 'Remove', admin_hook_path(hook), data: { confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-remove btn-sm'
- .monospace= hook.url
- %div
- - SystemHook.triggers.each_value do |event|
- - if hook.public_send(event)
- %span.badge.badge-gray= event.to_s.titleize
- %span.badge.badge-gray SSL Verification: #{hook.enable_ssl_verification ? 'enabled' : 'disabled'}
+ = render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class
= render 'shared/plugins/index'
diff --git a/app/views/admin/projects/_archived.html.haml b/app/views/admin/projects/_archived.html.haml
new file mode 100644
index 00000000000..8b4d5806c47
--- /dev/null
+++ b/app/views/admin/projects/_archived.html.haml
@@ -0,0 +1,3 @@
+- if project.archived
+ %span.badge.badge-warning
+ = _('archived')
diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml
index 2f7ad35eb3e..f842ab2d009 100644
--- a/app/views/admin/projects/_projects.html.haml
+++ b/app/views/admin/projects/_projects.html.haml
@@ -14,8 +14,7 @@
.stats
%span.badge.badge-pill
= storage_counter(project.statistics&.storage_size)
- - if project.archived
- %span.badge.badge-warning archived
+ = render_if_exists 'admin/projects/archived', project: project
.title
= link_to(admin_project_path(project)) do
.dash-project-avatar
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index 2bf2b5fce8d..f8ef7a45f7f 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -10,7 +10,7 @@
%br
%div
- %span= _('Each Runner can be in one of the following states:')
+ %span= _('Each Runner can be in one of the following states and/or belong to one of the following types:')
%ul
%li
%span.badge.badge-success shared
@@ -120,7 +120,7 @@
.runners-content.content-list
.table-holder
.gl-responsive-table-row.table-row-header{ role: 'row' }
- .table-section.section-10{ role: 'rowheader' }= _('Type')
+ .table-section.section-10{ role: 'rowheader' }= _('Type/State')
.table-section.section-10{ role: 'rowheader' }= _('Runner token')
.table-section.section-20{ role: 'rowheader' }= _('Description')
.table-section.section-10{ role: 'rowheader' }= _('Version')
diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml
index 3d77a439d61..50fa48855c0 100644
--- a/app/views/admin/sessions/_new_base.html.haml
+++ b/app/views/admin/sessions/_new_base.html.haml
@@ -4,4 +4,4 @@
= password_field_tag :password, nil, class: 'form-control', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' }
.submit-container.move-submit-down
- = submit_tag _('Enter Admin Mode'), class: 'btn btn-success', data: { qa_selector: 'sign_in_button' }
+ = submit_tag _('Enter Admin Mode'), class: 'btn btn-success', data: { qa_selector: 'enter_admin_mode_button' }
diff --git a/app/views/admin/sessions/_signin_box.html.haml b/app/views/admin/sessions/_signin_box.html.haml
deleted file mode 100644
index 1d19915d3c5..00000000000
--- a/app/views/admin/sessions/_signin_box.html.haml
+++ /dev/null
@@ -1,11 +0,0 @@
-- if any_form_based_providers_enabled?
-
- - if password_authentication_enabled_for_web?
- .login-box.tab-pane{ id: 'login-pane', role: 'tabpanel' }
- .login-body
- = render 'admin/sessions/new_base'
-
-- elsif password_authentication_enabled_for_web?
- .login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' }
- .login-body
- = render 'admin/sessions/new_base'
diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml
index 73028e78ea5..a1d440f2cfd 100644
--- a/app/views/admin/sessions/new.html.haml
+++ b/app/views/admin/sessions/new.html.haml
@@ -7,9 +7,16 @@
#signin-container
= render 'admin/sessions/tabs_normal'
.tab-content
- - if password_authentication_enabled_for_web?
- = render 'admin/sessions/signin_box'
- - else
- -# Show a message if none of the mechanisms above are enabled
+ - if !current_user.require_password_creation_for_web?
+ .login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' }
+ .login-body
+ = render 'admin/sessions/new_base'
+
+ - if omniauth_enabled? && button_based_providers_enabled?
+ .clearfix
+ = render 'devise/shared/omniauth_box'
+
+ -# Show a message if none of the mechanisms above are enabled
+ - if current_user.require_password_creation_for_web? && !omniauth_enabled?
.prepend-top-default.center
= _('No authentication methods configured.')
diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml
index bb1e22cc610..e3ab2e4f9bd 100644
--- a/app/views/admin/users/_access_levels.html.haml
+++ b/app/views/admin/users/_access_levels.html.haml
@@ -43,7 +43,7 @@
= f.check_box :external do
External
%p.light
- External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects or groups.
+ External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets.
%row.hidden#warning_external_automatically_set.hidden
.badge.badge-warning.text-white
= _('Automatically marked as default internal user')
diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml
index 60ca7e4e267..793ddef2c58 100644
--- a/app/views/award_emoji/_awards_block.html.haml
+++ b/app/views/award_emoji/_awards_block.html.haml
@@ -17,3 +17,4 @@
%span{ class: "award-control-icon award-control-icon-positive" }= sprite_icon('smiley')
%span{ class: "award-control-icon award-control-icon-super-positive" }= sprite_icon('smile')
= icon('spinner spin', class: "award-control-icon award-control-icon-loading")
+ = yield
diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml
index ed9b3ab1940..4244556a24a 100644
--- a/app/views/ci/variables/_variable_row.html.haml
+++ b/app/views/ci/variables/_variable_row.html.haml
@@ -44,31 +44,21 @@
.ci-variable-body-item.ci-variable-protected-item.table-section.section-20.mr-0.border-top-0
.append-right-default
= s_("CiVariable|Protected")
- %button{ type: 'button',
- class: "js-project-feature-toggle project-feature-toggle #{'is-checked' if is_protected}",
- "aria-label": s_("CiVariable|Toggle protected") }
+ = render "shared/buttons/project_feature_toggle", is_checked: is_protected, label: s_("CiVariable|Toggle protected") do
%input{ type: "hidden",
class: 'js-ci-variable-input-protected js-project-feature-toggle-input',
name: protected_input_name,
value: is_protected,
data: { default: is_protected_default.to_s } }
- %span.toggle-icon
- = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
- = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
.ci-variable-body-item.ci-variable-masked-item.table-section.section-20.mr-0.border-top-0
.append-right-default
= s_("CiVariable|Masked")
- %button{ type: 'button',
- class: "js-project-feature-toggle project-feature-toggle qa-variable-masked #{'is-checked' if is_masked}",
- "aria-label": s_("CiVariable|Toggle masked") }
+ = render "shared/buttons/project_feature_toggle", is_checked: is_masked, label: s_("CiVariable|Toggle masked"), class_list: "js-project-feature-toggle project-feature-toggle qa-variable-masked" do
%input{ type: "hidden",
class: 'js-ci-variable-input-masked js-project-feature-toggle-input',
name: masked_input_name,
value: is_masked,
data: { default: is_masked_default.to_s } }
- %span.toggle-icon
- = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
- = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
= render_if_exists 'ci/variables/environment_scope', form_field: form_field, variable: variable
%button.js-row-remove-button.ci-variable-row-remove-button.table-section.section-5.border-top-0{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') }
= icon('minus-circle')
diff --git a/app/views/clusters/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml
index 493d7a00854..77f7c478ffa 100644
--- a/app/views/clusters/clusters/_advanced_settings.html.haml
+++ b/app/views/clusters/clusters/_advanced_settings.html.haml
@@ -8,10 +8,10 @@
- unless @cluster.provided_by_user?
.append-bottom-20
%label.append-bottom-10
- = s_('ClusterIntegration|Google Kubernetes Engine')
+ = @cluster.provider_label
%p
- - link_gke = link_to(s_('ClusterIntegration|Google Kubernetes Engine'), @cluster.gke_cluster_url, target: '_blank', rel: 'noopener noreferrer')
- = s_('ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}').html_safe % { link_gke: link_gke }
+ - 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|
@@ -28,9 +28,18 @@
.form-group
= field.submit _('Save changes'), class: 'btn btn-success qa-save-domain'
+ - if @cluster.managed?
+ .sub-section.form-group
+ %h4
+ = s_('ClusterIntegration|Clear cluster cache')
+ %p
+ = s_("ClusterIntegration|Clear the local cache of namespace and service accounts. This is necessary if your integration has become out of sync. The cache is repopulated during the next CI job that requires namespace and service accounts.")
+ = link_to(s_('ClusterIntegration|Clear cluster cache'), clusterable.clear_cluster_cache_path(@cluster), method: :delete, class: 'btn btn-primary')
+
.sub-section.form-group
%h4.text-danger
= s_('ClusterIntegration|Remove Kubernetes cluster integration')
%p
= s_("ClusterIntegration|Remove this Kubernetes cluster's configuration from this project. This will not delete your actual Kubernetes cluster.")
- = link_to(s_('ClusterIntegration|Remove integration'), clusterable.cluster_path(@cluster), method: :delete, class: 'btn btn-danger', data: { confirm: s_("ClusterIntegration|Are you sure you want to remove this Kubernetes cluster's integration? This will not delete your actual Kubernetes cluster.")})
+
+ #js-cluster-remove-actions{ data: { cluster_path: clusterable.cluster_path(@cluster), cluster_name: @cluster.name } }
diff --git a/app/views/clusters/clusters/_banner.html.haml b/app/views/clusters/clusters/_banner.html.haml
index 7d97aaccbcf..82057fd0463 100644
--- a/app/views/clusters/clusters/_banner.html.haml
+++ b/app/views/clusters/clusters/_banner.html.haml
@@ -6,17 +6,19 @@
%span.spinner.spinner-dark.spinner-sm{ 'aria-label': 'Loading' }
%span.prepend-left-4= s_('ClusterIntegration|Kubernetes cluster is being created...')
-.hidden.row.js-cluster-api-unreachable.bs-callout.bs-callout-warning{ role: 'alert' }
- .col-11
+.hidden.row.js-cluster-api-unreachable.gl-alert.gl-alert-warning{ role: 'alert' }
+ = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ %button.js-close-banner.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
+ = sprite_icon('close', size: 16, css_class: 'gl-icon')
+ .gl-alert-body
= s_('ClusterIntegration|Your cluster API is unreachable. Please ensure your API URL is correct.')
- .col-1.p-0
- %button.js-close-banner.close.cluster-application-banner-close.h-100.m-0= "×"
-.hidden.js-cluster-authentication-failure.row.js-cluster-api-unreachable.bs-callout.bs-callout-warning{ role: 'alert' }
- .col-11
+.hidden.js-cluster-authentication-failure.js-cluster-api-unreachable.gl-alert.gl-alert-warning{ role: 'alert' }
+ = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ %button.js-close-banner.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
+ = sprite_icon('close', size: 16, css_class: 'gl-icon')
+ .gl-alert-body
= s_('ClusterIntegration|There was a problem authenticating with your cluster. Please ensure your CA Certificate and Token are valid.')
- .col-1.p-0
- %button.js-close-banner.close.cluster-application-banner-close.h-100.m-0= "×"
.hidden.js-cluster-success.bs-callout.bs-callout-success{ role: 'alert' }
= s_("ClusterIntegration|Kubernetes cluster was successfully created.")
diff --git a/app/views/clusters/clusters/_form.html.haml b/app/views/clusters/clusters/_form.html.haml
index 3d0266a2d5b..f9085b781fb 100644
--- a/app/views/clusters/clusters/_form.html.haml
+++ b/app/views/clusters/clusters/_form.html.haml
@@ -3,14 +3,8 @@
.form-group
%h5= s_('ClusterIntegration|Integration status')
%label.append-bottom-0.js-cluster-enable-toggle-area
- %button{ type: 'button',
- class: "js-project-feature-toggle project-feature-toggle #{'is-checked' if @cluster.enabled?} #{'is-disabled' unless can?(current_user, :update_cluster, @cluster)}",
- "aria-label": s_("ClusterIntegration|Toggle Kubernetes cluster"),
- disabled: !can?(current_user, :update_cluster, @cluster) }
+ = render "shared/buttons/project_feature_toggle", is_checked: @cluster.enabled?, label: s_("ClusterIntegration|Toggle Kubernetes cluster"), disabled: !can?(current_user, :update_cluster, @cluster) do
= field.hidden_field :enabled, { class: 'js-project-feature-toggle-input'}
- %span.toggle-icon
- = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
- = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
.form-text.text-muted= s_('ClusterIntegration|Enable or disable GitLab\'s connection to your Kubernetes cluster.')
.form-group
diff --git a/app/views/clusters/clusters/_namespace.html.haml b/app/views/clusters/clusters/_namespace.html.haml
index 0c64819ad62..8a86fd90963 100644
--- a/app/views/clusters/clusters/_namespace.html.haml
+++ b/app/views/clusters/clusters/_namespace.html.haml
@@ -1,4 +1,4 @@
-- managed_namespace_help_text = s_('ClusterIntegration|Choose a prefix to be used for your namespaces. Defaults to your project path.')
+- managed_namespace_help_text = s_('ClusterIntegration|Set a prefix for your namespaces. If not set, defaults to your project path. If modified, existing environments will use their current namespaces until the cluster cache is cleared.')
- non_managed_namespace_help_text = s_('ClusterIntegration|The namespace associated with your project. This will be used for deploy boards, pod logs, and Web terminals.')
- managed_namespace_help_link = link_to _('More information'), help_page_path('user/project/clusters/index.md',
anchor: 'gitlab-managed-clusters'), target: '_blank'
diff --git a/app/views/clusters/clusters/aws/_new.html.haml b/app/views/clusters/clusters/aws/_new.html.haml
index 795b80bfb6f..d89e6965dac 100644
--- a/app/views/clusters/clusters/aws/_new.html.haml
+++ b/app/views/clusters/clusters/aws/_new.html.haml
@@ -5,19 +5,12 @@
- else
.js-create-eks-cluster-form-container{ data: { 'gitlab-managed-cluster-help-path' => help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'),
'create-role-path' => clusterable.authorize_aws_role_path,
- 'sign-out-path' => clusterable.revoke_aws_role_path,
'create-cluster-path' => clusterable.create_aws_clusters_path,
- 'get-roles-path' => clusterable.aws_api_proxy_path('roles'),
- 'get-regions-path' => clusterable.aws_api_proxy_path('regions'),
- 'get-key-pairs-path' => clusterable.aws_api_proxy_path('key_pairs'),
- 'get-vpcs-path' => clusterable.aws_api_proxy_path('vpcs'),
- 'get-subnets-path' => clusterable.aws_api_proxy_path('subnets'),
- 'get-security-groups-path' => clusterable.aws_api_proxy_path('security_groups'),
- 'get-instance-types-path' => clusterable.aws_api_proxy_path('instance_types'),
'account-id' => Gitlab::CurrentSettings.eks_account_id,
'external-id' => @aws_role.role_external_id,
+ 'role-arn' => @aws_role.role_arn,
+ 'instance-types' => @instance_types,
'kubernetes-integration-help-path' => help_page_path('user/project/clusters/index'),
'account-and-external-ids-help-path' => help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'eks-cluster'),
'create-role-arn-help-path' => help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'eks-cluster'),
- 'external-link-icon' => icon('external-link'),
- 'has-credentials' => @aws_role.role_arn.present?.to_s } }
+ 'external-link-icon' => icon('external-link') } }
diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml
index 95670a2ec87..ab01569b8fd 100644
--- a/app/views/clusters/clusters/gcp/_form.html.haml
+++ b/app/views/clusters/clusters/gcp/_form.html.haml
@@ -64,7 +64,7 @@
%p.form-text.text-muted
= s_('ClusterIntegration|Learn more about %{help_link_start_machine_type}machine types%{help_link_end} and %{help_link_start_pricing}pricing%{help_link_end}.').html_safe % { help_link_start_machine_type: help_link_start % { url: machine_type_link_url }, help_link_start_pricing: help_link_start % { url: pricing_link_url }, help_link_end: help_link_end }
- - if Feature.enabled?(:create_cloud_run_clusters, clusterable)
+ - 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)'),
label_class: 'label-bold' }
diff --git a/app/views/clusters/clusters/gcp/_gcp_not_configured.html.haml b/app/views/clusters/clusters/gcp/_gcp_not_configured.html.haml
index b57e45e9812..f1f26a0aab8 100644
--- a/app/views/clusters/clusters/gcp/_gcp_not_configured.html.haml
+++ b/app/views/clusters/clusters/gcp/_gcp_not_configured.html.haml
@@ -1,3 +1,3 @@
- documentation_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path("integration/google") }
- link_end = '<a/>'.html_safe
-= s_('Google authentication is not %{link_start}property configured%{link_end}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_start: documentation_link_start, link_end: link_end }
+= s_('Google authentication is not %{link_start}properly configured%{link_end}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_start: documentation_link_start, link_end: link_end }
diff --git a/app/views/clusters/clusters/gcp/_new.html.haml b/app/views/clusters/clusters/gcp/_new.html.haml
index 3d47f4bf2c3..6c3a230fb93 100644
--- a/app/views/clusters/clusters/gcp/_new.html.haml
+++ b/app/views/clusters/clusters/gcp/_new.html.haml
@@ -1,7 +1,5 @@
= render 'clusters/clusters/gcp/header'
- if @valid_gcp_token
= render 'clusters/clusters/gcp/form'
-- elsif @authorize_url
- = render 'clusters/clusters/gcp/signin_with_google_button'
- else
= render 'clusters/clusters/gcp/gcp_not_configured'
diff --git a/app/views/clusters/clusters/gcp/_signin_with_google_button.html.haml b/app/views/clusters/clusters/gcp/_signin_with_google_button.html.haml
deleted file mode 100644
index 65cfa6552b1..00000000000
--- a/app/views/clusters/clusters/gcp/_signin_with_google_button.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-.signin-with-google
- - create_account_link = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: 'https://accounts.google.com/SignUpWithoutGmail?service=cloudconsole&continue=https%3A%2F%2Fconsole.cloud.google.com%2Ffreetrial%3Futm_campaign%3D2018_cpanel%26utm_source%3Dgitlab%26utm_medium%3Dreferral' }
- = link_to(image_tag('auth_buttons/signin_with_google.png', width: '191px', alt: _('Sign in with Google')), @authorize_url)
- = s_('or %{link_start}create a new Google account%{link_end}').html_safe % { link_start: create_account_link, link_end: '</a>'.html_safe }
diff --git a/app/views/clusters/clusters/new.html.haml b/app/views/clusters/clusters/new.html.haml
index cb8cbe4e6f2..629585d82cd 100644
--- a/app/views/clusters/clusters/new.html.haml
+++ b/app/views/clusters/clusters/new.html.haml
@@ -1,6 +1,5 @@
- breadcrumb_title _('Kubernetes')
- page_title _('Kubernetes Cluster')
-- create_eks_enabled = Feature.enabled?(:create_eks_clusters)
- active_tab = local_assigns.fetch(:active_tab, 'create')
= javascript_include_tag 'https://apis.google.com/js/api.js'
@@ -14,21 +13,14 @@
%li.nav-item{ role: 'presentation' }
%a.nav-link{ href: '#create-cluster-pane', id: 'create-cluster-tab', class: active_when(active_tab == 'create'), data: { toggle: 'tab' }, role: 'tab' }
%span
- - if create_eks_enabled
- = create_new_cluster_label(provider: params[:provider])
- - else
- = create_new_cluster_label(provider: 'gcp')
+ = create_new_cluster_label(provider: params[:provider])
%li.nav-item{ role: 'presentation' }
%a.nav-link{ href: '#add-cluster-pane', id: 'add-cluster-tab', class: active_when(active_tab == 'add'), data: { toggle: 'tab' }, role: 'tab' }
%span Add existing cluster
.tab-content.gitlab-tab-content
- - if create_eks_enabled
- .tab-pane{ id: 'create-cluster-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' }
- = render new_cluster_partial(provider: params[:provider])
- - else
- .tab-pane{ id: 'create-cluster-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' }
- = render new_cluster_partial(provider: 'gcp')
+ .tab-pane{ id: 'create-cluster-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' }
+ = render new_cluster_partial(provider: params[:provider])
.tab-pane{ id: 'add-cluster-pane', class: active_when(active_tab == 'add'), role: 'tabpanel' }
= render 'clusters/clusters/user/header'
diff --git a/app/views/dashboard/_snippets_head.html.haml b/app/views/dashboard/_snippets_head.html.haml
index 34aca40d0d1..4958cdc3745 100644
--- a/app/views/dashboard/_snippets_head.html.haml
+++ b/app/views/dashboard/_snippets_head.html.haml
@@ -3,7 +3,8 @@
- if current_user && current_user.snippets.any? || @snippets.any?
.page-title-controls
- = link_to _("New snippet"), new_snippet_path, class: "btn btn-success", title: _("New snippet")
+ - if can?(current_user, :create_personal_snippet)
+ = link_to _("New snippet"), new_snippet_path, class: "btn btn-success", title: _("New snippet")
.top-area
%ul.nav-links.nav.nav-tabs
diff --git a/app/views/dashboard/projects/_blank_state_welcome.html.haml b/app/views/dashboard/projects/_blank_state_welcome.html.haml
index 913f0e8cfae..003e6f18b33 100644
--- a/app/views/dashboard/projects/_blank_state_welcome.html.haml
+++ b/app/views/dashboard/projects/_blank_state_welcome.html.haml
@@ -1,5 +1,3 @@
-- public_project_count = ProjectsFinder.new(current_user: current_user).execute.count
-
.blank-state-row
- if current_user.can_create_project?
= link_to new_project_path, class: "blank-state blank-state-link" do
@@ -30,19 +28,15 @@
%p.blank-state-text
Groups are the best way to manage projects and members.
- - if public_project_count > 0
- = link_to trending_explore_projects_path, class: "blank-state blank-state-link" do
- .blank-state-icon
- = custom_icon("globe", size: 50)
- .blank-state-body
- %h3.blank-state-title
- Explore public projects
- %p.blank-state-text
- There are
- = number_with_delimiter(public_project_count)
- public projects on this server.
- Public projects are an easy way to allow
- everyone to have read-only access.
+ = link_to trending_explore_projects_path, class: "blank-state blank-state-link" do
+ .blank-state-icon
+ = custom_icon("globe", size: 50)
+ .blank-state-body
+ %h3.blank-state-title
+ Explore public projects
+ %p.blank-state-text
+ Public projects are an easy way to allow
+ everyone to have read-only access.
= link_to "https://docs.gitlab.com/", class: "blank-state blank-state-link" do
.blank-state-icon
diff --git a/app/views/dashboard/snippets/index.html.haml b/app/views/dashboard/snippets/index.html.haml
index 2caa8e0cac4..44a9270971a 100644
--- a/app/views/dashboard/snippets/index.html.haml
+++ b/app/views/dashboard/snippets/index.html.haml
@@ -1,6 +1,7 @@
- @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)
= render 'dashboard/snippets_head'
- if current_user.snippets.exists?
@@ -9,4 +10,4 @@
- if current_user.snippets.exists?
= render partial: 'shared/snippets/list', locals: { link_project: true }
- else
- = render 'shared/empty_states/snippets', button_path: new_snippet_path
+ = render 'shared/empty_states/snippets', button_path: button_path
diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml
index 4c88660ccb9..618cfe57be4 100644
--- a/app/views/groups/settings/_permissions.html.haml
+++ b/app/views/groups/settings/_permissions.html.haml
@@ -23,6 +23,13 @@
%span.d-block= s_('GroupSettings|Disable email notifications')
%span.text-muted= s_('GroupSettings|This setting will override user notification preferences for all members of the group, subgroups, and projects.')
+ .form-group.append-bottom-default
+ .form-check
+ = f.check_box :mentions_disabled, checked: @group.mentions_disabled?, class: 'form-check-input'
+ = f.label :mentions_disabled, class: 'form-check-label' do
+ %span.d-block= s_('GroupSettings|Disable group mentions')
+ %span.text-muted= s_('GroupSettings|This setting will prevent group members from being notified if the group is mentioned.')
+
= render_if_exists 'groups/settings/ip_restriction', f: f, group: @group
= render_if_exists 'groups/settings/allowed_email_domain', f: f, group: @group
= render 'groups/settings/lfs', f: f
diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml
index a19c8911559..feebbccf46a 100644
--- a/app/views/import/gitlab_projects/new.html.haml
+++ b/app/views/import/gitlab_projects/new.html.haml
@@ -14,7 +14,6 @@
= _("To move or copy an entire GitLab project from another GitLab installation to this one, navigate to the original project's settings page, generate an export file, and upload it here.")
.row
.form-group.col-sm-12
- = hidden_field_tag :namespace_id, @namespace.id
= label_tag :file, _('GitLab project export'), class: 'label-bold'
.form-group
= file_field_tag :file, class: ''
diff --git a/app/views/instance_statistics/cohorts/_cohorts_table.html.haml b/app/views/instance_statistics/cohorts/_cohorts_table.html.haml
index 6a7c999bff3..d4defd3f849 100644
--- a/app/views/instance_statistics/cohorts/_cohorts_table.html.haml
+++ b/app/views/instance_statistics/cohorts/_cohorts_table.html.haml
@@ -1,25 +1,32 @@
+- number_of_data_columns = @cohorts[:months_included] - 1
.bs-callout.clearfix
%p
- User cohorts are shown for the last #{@cohorts[:months_included]}
- months. Only users with activity are counted in the cohort total; inactive
- users are counted separately.
+ = s_("Cohorts|User cohorts are shown for the last %{months_included} months. Only users with activity are counted in the 'New users' column; inactive users are counted separately.") % { months_included: @cohorts[:months_included] }
= link_to icon('question-circle'), help_page_path('user/instance_statistics/user_cohorts', anchor: 'cohorts'), title: 'About this feature', target: '_blank'
-.table-holder
+.table-holder.d-xl-table
%table.table
%thead
%tr
- %th Registration month
- %th Inactive users
- %th Cohort total
- - @cohorts[:months_included].times do |i|
- %th Month #{i}
+ %th.border-right.pt-4{ colspan: 3 }
+ %th.font-weight-bold.pt-4{ colspan: number_of_data_columns }
+ = s_("Cohorts|Returning users")
+ %tr
+ %th.border-top-0
+ = s_("Cohorts|Registration month")
+ %th.border-top-0
+ = s_("Cohorts|Inactive users")
+ %th.border-top-0.border-right
+ = s_("Cohorts|New users")
+ - number_of_data_columns.times do |i|
+ %th.border-top-0
+ = s_("Cohorts|Month %{month_index}") % { month_index: i + 1 }
%tbody
- @cohorts[:cohorts].each do |cohort|
%tr
%td= cohort[:registration_month]
%td= cohort[:inactive]
- %td= cohort[:total]
+ %td.border-right= cohort[:total]
- cohort[:activity_months].each do |activity_month|
%td
- next if cohort[:total] == '0'
diff --git a/app/views/instance_statistics/conversational_development_index/_callout.html.haml b/app/views/instance_statistics/conversational_development_index/_callout.html.haml
deleted file mode 100644
index a4256e23979..00000000000
--- a/app/views/instance_statistics/conversational_development_index/_callout.html.haml
+++ /dev/null
@@ -1,13 +0,0 @@
-.prepend-top-default
-.user-callout{ data: { uid: 'convdev_intro_callout_dismissed' } }
- .bordered-box.landing.content-block
- %button.btn.btn-default.close.js-close-callout{ type: 'button',
- 'aria-label' => _('Dismiss ConvDev introduction') }
- = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
- .user-callout-copy
- %h4
- = _('Introducing Your Conversational Development Index')
- %p
- = _('Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers.')
- .svg-container.convdev
- = custom_icon('convdev_overview')
diff --git a/app/views/instance_statistics/dev_ops_score/_callout.html.haml b/app/views/instance_statistics/dev_ops_score/_callout.html.haml
new file mode 100644
index 00000000000..64eb72c0d8d
--- /dev/null
+++ b/app/views/instance_statistics/dev_ops_score/_callout.html.haml
@@ -0,0 +1,13 @@
+.prepend-top-default
+.user-callout{ data: { uid: 'dev_ops_score_intro_callout_dismissed' } }
+ .bordered-box.landing.content-block
+ %button.btn.btn-default.close.js-close-callout{ type: 'button',
+ 'aria-label' => _('Dismiss DevOps Score introduction') }
+ = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
+ .user-callout-copy
+ %h4
+ = _('Introducing Your DevOps Score')
+ %p
+ = _('Your DevOps Score gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers.')
+ .svg-container.devops
+ = custom_icon('dev_ops_score_overview')
diff --git a/app/views/instance_statistics/conversational_development_index/_card.html.haml b/app/views/instance_statistics/dev_ops_score/_card.html.haml
index 76af55dcf7a..c63bd96a175 100644
--- a/app/views/instance_statistics/conversational_development_index/_card.html.haml
+++ b/app/views/instance_statistics/dev_ops_score/_card.html.haml
@@ -1,6 +1,6 @@
-.convdev-card-wrapper
- .convdev-card{ class: "convdev-card-#{score_level(card.percentage_score)}" }
- .convdev-card-title
+.devops-card-wrapper
+ .devops-card{ class: "devops-card-#{score_level(card.percentage_score)}" }
+ .devops-card-title
%h3
= card.title
.light-text
diff --git a/app/views/instance_statistics/conversational_development_index/_disabled.html.haml b/app/views/instance_statistics/dev_ops_score/_disabled.html.haml
index b854e15d36f..da27ea17b61 100644
--- a/app/views/instance_statistics/conversational_development_index/_disabled.html.haml
+++ b/app/views/instance_statistics/dev_ops_score/_disabled.html.haml
@@ -1,6 +1,6 @@
-.container.convdev-empty
+.container.devops-empty
.col-sm-12.justify-content-center.text-center
- = custom_icon('convdev_no_index')
+ = custom_icon('dev_ops_score_no_index')
%h4= _('Usage ping is not enabled')
- if !current_user.admin?
%p
diff --git a/app/views/instance_statistics/conversational_development_index/_no_data.html.haml b/app/views/instance_statistics/dev_ops_score/_no_data.html.haml
index 4e8f34cd574..54598244039 100644
--- a/app/views/instance_statistics/conversational_development_index/_no_data.html.haml
+++ b/app/views/instance_statistics/dev_ops_score/_no_data.html.haml
@@ -1,7 +1,7 @@
-.container.convdev-empty
+.container.devops-empty
.col-sm-12.justify-content-center.text-center
- = custom_icon('convdev_no_data')
+ = custom_icon('dev_ops_score_no_data')
%h4= _('Data is still calculating...')
%p
= _('In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index.')
- = link_to _('Learn more'), help_page_path('user/instance_statistics/convdev'), target: '_blank'
+ = link_to _('Learn more'), help_page_path('user/instance_statistics/dev_ops_score'), target: '_blank'
diff --git a/app/views/instance_statistics/conversational_development_index/index.html.haml b/app/views/instance_statistics/dev_ops_score/index.html.haml
index 49c8fdc9630..44c6e9664db 100644
--- a/app/views/instance_statistics/conversational_development_index/index.html.haml
+++ b/app/views/instance_statistics/dev_ops_score/index.html.haml
@@ -1,8 +1,8 @@
-- page_title _('ConvDev Index')
+- page_title _('DevOps Score')
- usage_ping_enabled = Gitlab::CurrentSettings.usage_ping_enabled
.container
- - if usage_ping_enabled && show_callout?('convdev_intro_callout_dismissed')
+ - if usage_ping_enabled && show_callout?('dev_ops_score_intro_callout_dismissed')
= render 'callout'
.prepend-top-default
@@ -11,23 +11,23 @@
- elsif @metric.blank?
= render 'no_data'
- else
- .convdev
- .convdev-header
- %h2.convdev-header-title{ class: "convdev-#{score_level(@metric.average_percentage_score)}-score" }
+ .devops
+ .devops-header
+ %h2.devops-header-title{ class: "devops-#{score_level(@metric.average_percentage_score)}-score" }
= number_to_percentage(@metric.average_percentage_score, precision: 1)
- .convdev-header-subtitle
+ .devops-header-subtitle
= _('index')
%br
= _('score')
- = link_to icon('question-circle', 'aria-hidden' => 'true'), help_page_path('user/instance_statistics/convdev')
+ = link_to icon('question-circle', 'aria-hidden' => 'true'), help_page_path('user/instance_statistics/dev_ops_score')
- .convdev-cards.board-card-container
+ .devops-cards.board-card-container
- @metric.cards.each do |card|
= render 'card', card: card
- .convdev-steps.d-none.d-lg-block.d-xl-block
+ .devops-steps.d-none.d-lg-block.d-xl-block
- @metric.idea_to_production_steps.each_with_index do |step, index|
- .convdev-step{ class: "convdev-#{score_level(step.percentage_score)}-score" }
+ .devops-step{ class: "devops-#{score_level(step.percentage_score)}-score" }
= custom_icon("i2p_step_#{index + 1}")
- %h4.convdev-step-title
+ %h4.devops-step-title
= step.title
diff --git a/app/views/layouts/_broadcast.html.haml b/app/views/layouts/_broadcast.html.haml
index e2dbdcbb939..ee3ca824342 100644
--- a/app/views/layouts/_broadcast.html.haml
+++ b/app/views/layouts/_broadcast.html.haml
@@ -1,2 +1,2 @@
-- BroadcastMessage.current&.each do |message|
+- current_broadcast_messages&.each do |message|
= broadcast_message(message)
diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml
index a0b030fa3b2..de1caeaa50f 100644
--- a/app/views/layouts/_flash.html.haml
+++ b/app/views/layouts/_flash.html.haml
@@ -1,8 +1,9 @@
+-# We currently only support `alert`, `notice`, `success`, 'toast'
.flash-container.flash-container-page.sticky
- -# We currently only support `alert`, `notice`, `success`
- flash.each do |key, value|
- -# Don't show a flash message if the message is nil
- - if value
+ - if key == 'toast' && value
+ .js-toast-message{ data: { message: value } }
+ - elsif value
%div{ class: "flash-#{key} mb-2" }
%span= value
%div{ class: "close-icon-wrapper js-close-icon" }
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index c38f96f302a..f4ab491a38e 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -1,7 +1,7 @@
!!! 5
%html{ lang: I18n.locale, class: page_class }
= render "layouts/head"
- %body{ class: "#{user_application_theme} #{@body_class} #{client_class_list}", data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}", find_file: find_file_path } }
+ %body{ class: "#{user_application_theme} #{@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/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml
index d15f0ae3228..88803f982e8 100644
--- a/app/views/layouts/header/_current_user_dropdown.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown.html.haml
@@ -20,7 +20,7 @@
= link_to s_("CurrentUser|Profile"), current_user, class: 'profile-link', data: { user: current_user.username }
- if current_user_menu?(:start_trial)
%li
- %a.profile-link{ href: trials_link_url }
+ %a.trial-link{ href: trials_link_url }
= s_("CurrentUser|Start a Gold trial")
= emoji_icon('rocket')
- if current_user_menu?(:settings)
diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml
index e28efb09be5..30109621515 100644
--- a/app/views/layouts/header/_new_dropdown.haml
+++ b/app/views/layouts/header/_new_dropdown.haml
@@ -38,4 +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
- %li= link_to _('New snippet'), new_snippet_path, class: 'qa-global-new-snippet-link'
+ - if current_user.can?(:create_personal_snippet)
+ %li= link_to _('New snippet'), new_snippet_path, class: 'qa-global-new-snippet-link'
diff --git a/app/views/layouts/nav/_breadcrumbs.html.haml b/app/views/layouts/nav/_breadcrumbs.html.haml
index f53bd2b5e4d..1b799477093 100644
--- a/app/views/layouts/nav/_breadcrumbs.html.haml
+++ b/app/views/layouts/nav/_breadcrumbs.html.haml
@@ -2,7 +2,7 @@
- hide_top_links = @hide_top_links || false
%nav.breadcrumbs{ role: "navigation", class: [container, @content_class] }
- .breadcrumbs-container
+ .breadcrumbs-container{ class: ("border-bottom-0" if @no_breadcrumb_border && mr_tabs_position_enabled?) }
- if defined?(@left_sidebar)
= button_tag class: 'toggle-mobile-nav', type: 'button' do
%span.sr-only= _("Open sidebar")
diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml
index d339751848b..9a839765286 100644
--- a/app/views/layouts/nav/_dashboard.html.haml
+++ b/app/views/layouts/nav/_dashboard.html.haml
@@ -48,7 +48,7 @@
%li.dropdown
= render_if_exists 'dashboard/nav_link_list'
- if can?(current_user, :read_instance_statistics)
- = nav_link(controller: [:conversational_development_index, :cohorts]) do
+ = nav_link(controller: [:dev_ops_score, :cohorts]) do
= link_to instance_statistics_root_path do
= _('Instance Statistics')
- if current_user.admin?
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index b33ef26f87d..71fef5df5bc 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -182,6 +182,8 @@
%strong.fly-out-top-item-name
= _('Deploy Keys')
+ = render_if_exists 'layouts/nav/sidebar/credentials_link'
+
= nav_link(controller: :services) do
= link_to admin_application_settings_services_path do
.nav-icon-container
@@ -264,8 +266,8 @@
= link_to network_admin_application_settings_path, title: _('Network'), data: { qa_selector: 'admin_settings_network_item' } do
%span
= _('Network')
- - if template_exists?('admin/application_settings/geo')
- = nav_link(path: 'application_settings#geo') do
+ - if template_exists?('admin/geo/settings/show')
+ = nav_link do
= link_to geo_admin_application_settings_path, title: _('Geo') do
%span
= _('Geo')
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index a6d2c894185..a027dca1b56 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -44,7 +44,7 @@
- if group_sidebar_link?(:contribution_analytics)
= nav_link(path: 'analytics#show') do
- = link_to group_analytics_path(@group), title: _('Contribution Analytics'), data: { placement: 'right' } do
+ = link_to group_analytics_path(@group), title: _('Contribution Analytics'), data: { placement: 'right', qa_selector: 'contribution_analytics_link' } do
%span
= _('Contribution Analytics')
diff --git a/app/views/layouts/nav/sidebar/_instance_statistics.html.haml b/app/views/layouts/nav/sidebar/_instance_statistics.html.haml
index 57180f27146..0a84e952442 100644
--- a/app/views/layouts/nav/sidebar/_instance_statistics.html.haml
+++ b/app/views/layouts/nav/sidebar/_instance_statistics.html.haml
@@ -6,17 +6,17 @@
= sprite_icon('chart', size: 24)
.sidebar-context-title= _('Instance Statistics')
%ul.sidebar-top-level-items
- = nav_link(controller: :conversational_development_index) do
- = link_to instance_statistics_conversational_development_index_index_path do
+ = nav_link(controller: :dev_ops_score) do
+ = link_to instance_statistics_dev_ops_score_index_path do
.nav-icon-container
= sprite_icon('comment')
%span.nav-item-name
- = _('ConvDev Index')
+ = _('DevOps Score')
%ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :conversational_development_index, html_options: { class: "fly-out-top-item" } ) do
- = link_to instance_statistics_conversational_development_index_index_path do
+ = nav_link(controller: :dev_ops_score, html_options: { class: "fly-out-top-item" } ) do
+ = link_to instance_statistics_dev_ops_score_index_path do
%strong.fly-out-top-item-name
- = _('ConvDev Index')
+ = _('DevOps Score')
- if Gitlab::CurrentSettings.usage_ping_enabled
= nav_link(controller: :cohorts) do
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 9b3ad05d0c0..1e2556aecc1 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -144,8 +144,16 @@
%strong.fly-out-top-item-name
= issue_tracker.title
+ - if (project_nav_tab? :labels) && !@project.issues_enabled?
+ = nav_link(controller: [:labels]) do
+ = link_to project_labels_path(@project), title: _('Labels'), class: 'shortcuts-labels qa-labels-items' do
+ .nav-icon-container
+ = sprite_icon('label')
+ %span.nav-item-name#js-onboarding-labels-link
+ = _('Labels')
+
- if project_nav_tab? :merge_requests
- = nav_link(controller: @project.issues_enabled? ? :merge_requests : [:merge_requests, :labels, :milestones]) do
+ = nav_link(controller: @project.issues_enabled? ? :merge_requests : [:merge_requests, :milestones]) do
= link_to project_merge_requests_path(@project), class: 'shortcuts-merge_requests', data: { qa_selector: 'merge_requests_link' } do
.nav-icon-container
= sprite_icon('git-merge')
diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml
index de487a94d40..e922b505be8 100644
--- a/app/views/layouts/notify.html.haml
+++ b/app/views/layouts/notify.html.haml
@@ -20,7 +20,7 @@
#{link_to _("View it on GitLab"), @target_url}.
%br
-# Don't link the host in the line below, one link in the email is easier to quickly click than two.
- = _("You're receiving this email because %{reason}.") % { reason: notification_reason_text(@reason) }
+ = notification_reason_text(@reason)
If you'd like to receive fewer emails, you can
- if @labels_url
adjust your #{link_to 'label subscriptions', @labels_url}.
diff --git a/app/views/layouts/notify.text.erb b/app/views/layouts/notify.text.erb
index 0ee30c2a6cf..49ad0b5abc5 100644
--- a/app/views/layouts/notify.text.erb
+++ b/app/views/layouts/notify.text.erb
@@ -11,7 +11,7 @@
<% end -%>
<% end -%>
-<%= "You're receiving this email because #{notification_reason_text(@reason)}." %>
+<%= notification_reason_text(@reason) %>
<%= render_if_exists 'layouts/mailer/additional_text' %>
<%= text_footer_message -%>
diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml
index dc5529b489b..c558358725c 100644
--- a/app/views/notify/_note_email.html.haml
+++ b/app/views/notify/_note_email.html.haml
@@ -11,7 +11,7 @@
- if discussion.nil?
commented
- else
- - if discussion.new_discussion?
+ - if note.start_of_discussion?
started a new
- else
commented on a
diff --git a/app/views/notify/_note_email.text.erb b/app/views/notify/_note_email.text.erb
index a25daad8458..8e2f7e6f76e 100644
--- a/app/views/notify/_note_email.text.erb
+++ b/app/views/notify/_note_email.text.erb
@@ -7,7 +7,7 @@
<% if discussion.nil? -%>
<%= 'commented' -%>:
<% else -%>
-<% if discussion.new_discussion? -%>
+<% if note.start_of_discussion? -%>
<%= 'started a new discussion' -%>
<% else -%>
<%= 'commented on a discussion' -%>
diff --git a/app/views/notify/access_token_about_to_expire_email.html.haml b/app/views/notify/access_token_about_to_expire_email.html.haml
new file mode 100644
index 00000000000..d1923e324f7
--- /dev/null
+++ b/app/views/notify/access_token_about_to_expire_email.html.haml
@@ -0,0 +1,7 @@
+%p
+ = _('Hi %{username}!') % { username: sanitize_name(@user.name) }
+%p
+ = _('One or more of your personal access tokens will expire in %{days_to_expire} days or less.') % { days_to_expire: @days_to_expire }
+%p
+ - pat_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url }
+ = _('You can create a new one or check them in your %{pat_link_start}Personal Access Tokens%{pat_link_end} settings').html_safe % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe }
diff --git a/app/views/notify/access_token_about_to_expire_email.text.erb b/app/views/notify/access_token_about_to_expire_email.text.erb
new file mode 100644
index 00000000000..5e6bd68d33f
--- /dev/null
+++ b/app/views/notify/access_token_about_to_expire_email.text.erb
@@ -0,0 +1,5 @@
+<%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %>
+
+<%= _('One or more of your personal access tokens will expire in %{days_to_expire} days or less.') % { days_to_expire: @days_to_expire} %>
+
+<%= _('You can create a new one or check them in your Personal Access Tokens settings %{pat_link}') % { pat_link: @target_url } %>
diff --git a/app/views/profiles/accounts/_providers.html.haml b/app/views/profiles/accounts/_providers.html.haml
index 068f9cc70f7..a87191d0fa4 100644
--- a/app/views/profiles/accounts/_providers.html.haml
+++ b/app/views/profiles/accounts/_providers.html.haml
@@ -16,6 +16,6 @@
%a.provider-btn
= s_('Profiles|Active')
- elsif link_allowed
- = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn not-active' do
+ = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn gl-text-blue-500' do
= s_('Profiles|Connect')
= render_if_exists 'profiles/accounts/group_saml_unlink_buttons', group_saml_identities: group_saml_identities
diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml
index f8351644df5..2de5cf2f506 100644
--- a/app/views/profiles/gpg_keys/_key.html.haml
+++ b/app/views/profiles/gpg_keys/_key.html.haml
@@ -5,7 +5,7 @@
- key.emails_with_verified_status.map do |email, verified|
= render partial: 'shared/email_with_badge', locals: { email: email, verified: verified }
- .description
+ %span.text-truncate
%code= key.fingerprint
- if key.subkeys.present?
.subkeys
diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml
index b9d73d89334..0e94e6563fd 100644
--- a/app/views/profiles/keys/_key.html.haml
+++ b/app/views/profiles/keys/_key.html.haml
@@ -10,7 +10,7 @@
.key-list-item-info
= link_to path_to_key(key, is_admin), class: "title" do
= key.title
- .description
+ %span.text-truncate
= key.fingerprint
.last-used-at
last used:
diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml
index 0ef01dec493..02f1a267044 100644
--- a/app/views/profiles/keys/_key_details.html.haml
+++ b/app/views/profiles/keys/_key_details.html.haml
@@ -17,11 +17,21 @@
.col-md-8
= form_errors(@key, type: 'key') unless @key.valid?
- %p
- %span.light= _('Fingerprint:')
- %code.key-fingerprint= @key.fingerprint
%pre.well-pre
= @key.key
+ .card
+ .card-header
+ = _('Fingerprints')
+ %ul.content-list
+ %li
+ %span.light= 'MD5:'
+ %code.key-fingerprint= @key.fingerprint
+ - if @key.fingerprint_sha256.present?
+ %li
+ %span.light= 'SHA256:'
+ %code.key-fingerprint= @key.fingerprint_sha256
+
+
.col-md-12
.float-right
- if @key.can_delete?
diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml
index 0e2b0430fec..af6fa6b1b61 100644
--- a/app/views/profiles/passwords/edit.html.haml
+++ b/app/views/profiles/passwords/edit.html.haml
@@ -32,4 +32,4 @@
.prepend-top-default.append-bottom-default
= f.submit _('Save password'), class: "btn btn-success append-right-10", data: { qa_selector: 'save_password_button' }
- unless @user.password_automatically_set?
- = link_to _('I forgot my password'), reset_profile_password_path, method: :put, class: "account-btn-link"
+ = link_to _('I forgot my password'), reset_profile_password_path, method: :put
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 5501e63e027..4a2d0a4f8ce 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -26,7 +26,7 @@
- else
%p
- help_link_start = '<a href="%{url}" target="_blank">' % { url: help_page_path('user/profile/account/two_factor_authentication') }
- - register_2fa_token = _('Install a soft token authenticator like %{free_otp_link} or Google Authenticator from your application repository and scan this QR code. More information is available in the %{help_link_start}documentation%{help_link_end}.') % { free_otp_link:'<a href="https://freeotp.github.io/">FreeOTP</a>', help_link_start:help_link_start, help_link_end:'</a>' }
+ - register_2fa_token = _('Install a soft token authenticator like %{free_otp_link} or Google Authenticator from your application repository and use that app to scan this QR code. More information is available in the %{help_link_start}documentation%{help_link_end}.') % { free_otp_link:'<a href="https://freeotp.github.io/">FreeOTP</a>', help_link_start:help_link_start, help_link_end:'</a>' }
= register_2fa_token.html_safe
.row.append-bottom-10
.col-md-4
diff --git a/app/views/projects/_archived_notice.html.haml b/app/views/projects/_archived_notice.html.haml
new file mode 100644
index 00000000000..522693ae24a
--- /dev/null
+++ b/app/views/projects/_archived_notice.html.haml
@@ -0,0 +1,5 @@
+- if project.archived?
+ .text-warning.center.prepend-top-20
+ %p
+ = icon("exclamation-triangle fw")
+ = _('Archived project! Repository and other project resources are read only')
diff --git a/app/views/projects/_remove.html.haml b/app/views/projects/_remove.html.haml
new file mode 100644
index 00000000000..6c84fbfeeb3
--- /dev/null
+++ b/app/views/projects/_remove.html.haml
@@ -0,0 +1,10 @@
+- return unless can?(current_user, :remove_project, project)
+
+.sub-section
+ %h4.danger-title= _('Remove project')
+ %p
+ %strong= _('Removing the project will delete its repository and all related resources including issues, merge requests etc.')
+ = form_tag(project_path(project), method: :delete) do
+ %p
+ %strong= _('Removed projects cannot be restored!')
+ = button_to _('Remove project'), '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_project_message(project) }
diff --git a/app/views/projects/_visibility_modal.html.haml b/app/views/projects/_visibility_modal.html.haml
new file mode 100644
index 00000000000..3ef93a40137
--- /dev/null
+++ b/app/views/projects/_visibility_modal.html.haml
@@ -0,0 +1,30 @@
+- strong_start = "<strong>".html_safe
+- strong_end = "</strong>".html_safe
+
+.modal.js-confirm-project-visiblity{ tabindex: -1 }
+ .modal-dialog
+ .modal-content
+ .modal-header
+ %h3.page-title= _('Reduce this project’s visibility?')
+ %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
+ %span{ "aria-hidden": true }= sprite_icon("close", size: 16)
+ .modal-body
+ %p
+ - if @project.group
+ = _("You're about to reduce the visibility of the project %{strong_start}%{project_name}%{strong_end} in %{strong_start}%{group_name}%{strong_end}.").html_safe % { project_name: @project.name, group_name: @project.group.name, strong_start: strong_start, strong_end: strong_end }
+ - else
+ = _("You're about to reduce the visibility of the project %{strong_start}%{project_name}%{strong_end}.").html_safe % { project_name: @project.name, strong_start: strong_start, strong_end: strong_end }
+ %p
+ = _('Once you confirm and press "Reduce project visibility":')
+ %ul
+ %li
+ = ("Current forks will keep their visibility level but their fork relationship with this project will be %{strong_start}removed%{strong_end}.").html_safe % { strong_start: strong_start, strong_end: strong_end }
+ %label{ for: "confirm_path_input" }
+ = ("To confirm, type %{phrase_code}").html_safe % { phrase_code: '<code class="js-confirm-danger-match">%{phrase_name}</code>'.html_safe % { phrase_name: @project.full_path } }
+ .form-group
+ = text_field_tag 'confirm_path_input', '', class: 'form-control js-confirm-danger-input qa-confirm-input'
+ .form-actions.clearfix
+ .pull-right
+ %button.btn.btn-default{ type: "button", "data-dismiss": "modal" }
+ = _('Cancel')
+ = submit_tag _('Reduce project visibility'), class: "btn btn-danger js-confirm-danger-submit qa-confirm-button", disabled: true
diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml
index 6a7cb1499c5..7abac2d14e4 100644
--- a/app/views/projects/artifacts/browse.html.haml
+++ b/app/views/projects/artifacts/browse.html.haml
@@ -15,7 +15,7 @@
%li.breadcrumb-item
= link_to truncate(title, length: 40), browse_project_job_artifacts_path(@project, @build, path)
- .tree-controls
+ .tree-controls<
= link_to download_project_job_artifacts_path(@project, @build),
rel: 'nofollow', download: '', class: 'btn btn-default download' do
= sprite_icon('download')
diff --git a/app/views/projects/blob/_breadcrumb.html.haml b/app/views/projects/blob/_breadcrumb.html.haml
index a4fb5f6ba88..e611df8df2a 100644
--- a/app/views/projects/blob/_breadcrumb.html.haml
+++ b/app/views/projects/blob/_breadcrumb.html.haml
@@ -17,21 +17,19 @@
- else
= link_to title, project_tree_path(@project, tree_join(@ref, path))
- .tree-controls
+ .tree-controls<
= render 'projects/find_file_link'
+ -# only show normal/blame view links for text files
+ - if blob.readable_text?
+ - if blame
+ = link_to 'Normal view', project_blob_path(@project, @id),
+ class: 'btn'
+ - else
+ = link_to 'Blame', project_blame_path(@project, @id),
+ class: 'btn js-blob-blame-link' unless blob.empty?
- .btn-group{ role: "group" }<
- -# only show normal/blame view links for text files
- - if blob.readable_text?
- - if blame
- = link_to 'Normal view', project_blob_path(@project, @id),
- class: 'btn'
- - else
- = link_to 'Blame', project_blame_path(@project, @id),
- class: 'btn js-blob-blame-link' unless blob.empty?
+ = link_to 'History', project_commits_path(@project, @id),
+ class: 'btn'
- = link_to 'History', project_commits_path(@project, @id),
- class: 'btn'
-
- = link_to 'Permalink', project_blob_path(@project,
- tree_join(@commit.sha, @path)), class: 'btn js-data-file-blob-permalink-url'
+ = link_to 'Permalink', project_blob_path(@project,
+ tree_join(@commit.sha, @path)), class: 'btn js-data-file-blob-permalink-url'
diff --git a/app/views/projects/blob/viewers/_openapi.html.haml b/app/views/projects/blob/viewers/_openapi.html.haml
new file mode 100644
index 00000000000..ce8030cf2d2
--- /dev/null
+++ b/app/views/projects/blob/viewers/_openapi.html.haml
@@ -0,0 +1 @@
+.file-content#js-openapi-viewer{ data: { endpoint: blob_raw_path } }
diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml
index bbe0a2c97fd..f1a7528065a 100644
--- a/app/views/projects/buttons/_dropdown.html.haml
+++ b/app/views/projects/buttons/_dropdown.html.haml
@@ -7,7 +7,7 @@
- show_menu = can_create_issue || can_create_project_snippet || can_push_code || create_mr_from_new_fork || merge_project
- if show_menu
- .project-action-button.dropdown.inline
+ .project-action-button.dropdown.inline<
%a.btn.dropdown-toggle.has-tooltip.qa-create-new-dropdown{ href: '#', title: _('Create new...'), 'data-toggle' => 'dropdown', 'data-container' => 'body', 'aria-label' => _('Create new...'), 'data-display' => 'static' }
= icon('plus')
= icon("caret-down")
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index e155e3758fb..3f1d44a488a 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -13,7 +13,7 @@
%ul.breadcrumb.repo-breadcrumb
= commits_breadcrumbs
- .tree-controls.d-none.d-sm-none.d-md-block
+ .tree-controls.d-none.d-sm-none.d-md-block<
- if @merge_request.present?
.control
= link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'btn'
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 328fdd0be10..1c18487f688 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -21,7 +21,9 @@
%input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' }
%template.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data_json(@project)
.js-project-permissions-form
- = f.submit _('Save changes'), class: "btn btn-success", data: { qa_selector: 'visibility_features_permissions_save_button' }
+ - if show_visibility_confirm_modal?(@project)
+ = render "visibility_modal"
+ = f.submit _('Save changes'), class: "btn btn-success #{('js-confirm-danger' if show_visibility_confirm_modal?(@project))}", data: { qa_selector: 'visibility_features_permissions_save_button', check_field_name: ("project[visibility_level]" if show_visibility_confirm_modal?(@project)), check_compare_value: @project.visibility_level }
%section.qa-merge-request-settings.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)] }
.settings-header
@@ -71,23 +73,7 @@
= render 'export', project: @project
- - if can? current_user, :archive_project, @project
- .sub-section
- %h4.warning-title
- - if @project.archived?
- = _('Unarchive project')
- - else
- = _('Archive project')
- - if @project.archived?
- %p= _("Unarchiving the project will restore people's ability to make changes to it. The repository can be committed to, and issues, comments and other entities can be created. <strong>Once active this project shows up in the search and on the dashboard.</strong>").html_safe
- = link_to _('Unarchive project'), unarchive_project_path(@project),
- data: { confirm: _("Are you sure that you want to unarchive this project?"), qa_selector: 'unarchive_project_link' },
- method: :post, class: "btn btn-success"
- - else
- %p= _("Archiving the project will make it entirely read-only. It is hidden from the dashboard and doesn't show up in searches. <strong>The repository cannot be committed to, and no issues, comments or other entities can be created.</strong>").html_safe
- = link_to _('Archive project'), archive_project_path(@project),
- data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link' },
- method: :post, class: "btn btn-warning"
+ = render_if_exists 'projects/settings/archive'
.sub-section.rename-repository
%h4.warning-title= _('Change path')
= render 'projects/errors'
@@ -133,14 +119,7 @@
%strong= _('Once removed, the fork relationship cannot be restored and you will no longer be able to send merge requests to the source.')
= button_to _('Remove fork relationship'), '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_fork_project_warning_message(@project) }
- - if can?(current_user, :remove_project, @project)
- .sub-section
- %h4.danger-title= _('Remove project')
- %p= _('Removing the project will delete its repository and all related resources including issues, merge requests etc.')
- = form_tag(project_path(@project), method: :delete) do
- %p
- %strong= _('Removed projects cannot be restored!')
- = button_to _('Remove project'), '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_project_message(@project) }
+ = render 'remove', project: @project
.save-project-loader.hide
.center
diff --git a/app/views/projects/environments/empty_logs.html.haml b/app/views/projects/environments/empty_logs.html.haml
deleted file mode 100644
index 602dc908b75..00000000000
--- a/app/views/projects/environments/empty_logs.html.haml
+++ /dev/null
@@ -1,14 +0,0 @@
-- page_title _('Pod logs')
-
-.row.empty-state
- .col-sm-12
- .svg-content
- = image_tag 'illustrations/operations_log_pods_empty.svg'
- .col-12
- .text-content
- %h4.text-center
- = s_('Environments|No deployed environments')
- %p.state-description.text-center
- = s_('Logs|To see the pod logs, deploy your code to an environment.')
- .text-center
- = link_to s_('Environments|Learn about environments'), help_page_path('ci/environments'), class: 'btn btn-success'
diff --git a/app/views/projects/environments/folder.html.haml b/app/views/projects/environments/folder.html.haml
index f85c57d9aa1..cd24c30e46f 100644
--- a/app/views/projects/environments/folder.html.haml
+++ b/app/views/projects/environments/folder.html.haml
@@ -1,3 +1,5 @@
-- page_title _("Environments")
+- add_to_breadcrumbs _("Environments"), project_environments_path(@project)
+- breadcrumb_title _("Folder/%{name}") % { name: @folder }
+- page_title _("Environments in %{name}") % { name: @folder }
#environments-folder-list-view{ data: { environments_data: environments_folder_list_view_data } }
diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml
index c4c39c227c6..62b1c140794 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -5,7 +5,7 @@
- content_for :page_specific_javascripts do
= stylesheet_link_tag 'page_bundles/xterm'
-- if can?(current_user, :stop_environment, @environment)
+- if @environment.available? && can?(current_user, :stop_environment, @environment)
#stop-environment-modal.modal.fade{ tabindex: -1 }
.modal-dialog
.modal-content
@@ -40,7 +40,7 @@
= render 'projects/environments/metrics_button', environment: @environment
- if can?(current_user, :update_environment, @environment)
= link_to _('Edit'), edit_project_environment_path(@project, @environment), class: 'btn'
- - if can?(current_user, :stop_environment, @environment)
+ - if @environment.available? && can?(current_user, :stop_environment, @environment)
= button_tag class: 'btn btn-danger', type: 'button', data: { toggle: 'modal',
target: '#stop-environment-modal' } do
= sprite_icon('stop')
diff --git a/app/views/projects/hook_logs/_index.html.haml b/app/views/projects/hook_logs/_index.html.haml
index 3e54c3ca9f8..ada986dd969 100644
--- a/app/views/projects/hook_logs/_index.html.haml
+++ b/app/views/projects/hook_logs/_index.html.haml
@@ -28,7 +28,7 @@
%td.light
= time_ago_with_tooltip(hook_log.created_at)
%td
- = link_to 'View details', project_hook_hook_log_path(project, hook, hook_log)
+ = link_to 'View details', hook_log.present.details_path
= paginate hook_logs, theme: 'gitlab'
diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml
index bd8ca5e7d70..a8796cd7b1c 100644
--- a/app/views/projects/hook_logs/show.html.haml
+++ b/app/views/projects/hook_logs/show.html.haml
@@ -3,7 +3,6 @@
%h4.prepend-top-0
Request details
.col-lg-9
-
- = link_to 'Resend Request', retry_project_hook_hook_log_path(@project, @hook, @hook_log), method: :post, class: "btn btn-default float-right prepend-left-10"
+ = link_to 'Resend Request', @hook_log.present.retry_path, method: :post, class: "btn btn-default float-right prepend-left-10"
= render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log }
diff --git a/app/views/projects/hooks/_index.html.haml b/app/views/projects/hooks/_index.html.haml
index 0ab7863b77c..70f2fa0e758 100644
--- a/app/views/projects/hooks/_index.html.haml
+++ b/app/views/projects/hooks/_index.html.haml
@@ -1,23 +1,10 @@
.row.prepend-top-default
.col-lg-4
- %h4.prepend-top-0
- = page_title
- %p
- #{link_to 'Webhooks', help_page_path('user/project/integrations/webhooks')} can be
- used for binding events when something is happening within the project.
+ = render 'shared/web_hooks/title_and_docs', hook: @hook
.col-lg-8.append-bottom-default
= form_for @hook, as: :hook, url: polymorphic_path([@project.namespace.becomes(Namespace), @project, :hooks]) do |f|
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
= f.submit 'Add webhook', class: 'btn btn-success'
- %hr
- %h5.prepend-top-default
- Webhooks (#{@hooks.count})
- - if @hooks.any?
- %ul.content-list
- - @hooks.each do |hook|
- = render 'project_hook', hook: hook
- - else
- %p.settings-message.text-center.append-bottom-0
- No webhooks found, add one in the form above.
+ = render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class
diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml
index 57311284e11..c1fdf619eb5 100644
--- a/app/views/projects/hooks/edit.html.haml
+++ b/app/views/projects/hooks/edit.html.haml
@@ -1,19 +1,17 @@
-- page_title 'Integrations'
+- add_to_breadcrumbs _('ProjectService|Integrations'), namespace_project_settings_integrations_path
+- page_title _('Edit Project Hook')
.row.prepend-top-default
.col-lg-3
- %h4.prepend-top-0
- = page_title
- %p
- #{link_to 'Webhooks', help_page_path('user/project/integrations/webhooks')} can be
- used for binding events when something is happening within the project.
+ = render 'shared/web_hooks/title_and_docs', hook: @hook
+
.col-lg-9.append-bottom-default
= form_for [@project.namespace.becomes(Namespace), @project, @hook], as: :hook, url: project_hook_path(@project, @hook) do |f|
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
- = f.submit 'Save changes', class: 'btn btn-success'
- = render 'shared/web_hooks/test_button', triggers: ProjectHook.triggers, hook: @hook
- = link_to 'Remove', project_hook_path(@project, @hook), method: :delete, class: 'btn btn-remove float-right', data: { confirm: 'Are you sure?' }
+ %span>= f.submit 'Save changes', class: 'btn btn-success append-right-8'
+ = render 'shared/web_hooks/test_button', hook: @hook
+ = link_to _('Delete'), project_hook_path(@project, @hook), method: :delete, class: 'btn btn-remove float-right', data: { confirm: _('Are you sure?') }
%hr
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index 8d3e54dc455..eb76326602f 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -28,7 +28,7 @@
%ul#create-merge-request-dropdown.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-right.gl-show-field-errors{ class: ("create-confidential-merge-request-dropdown-menu" if can_create_confidential_merge_request?), data: { dropdown: true } }
- if can_create_merge_request
%li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', text: create_mr_text } }
- .menu-item
+ .menu-item.text-nowrap
= icon('check', class: 'icon')
- if can_create_confidential_merge_request?
= _('Create confidential merge request and branch')
diff --git a/app/views/projects/merge_requests/_awards_block.html.haml b/app/views/projects/merge_requests/_awards_block.html.haml
new file mode 100644
index 00000000000..1eab28a2ff3
--- /dev/null
+++ b/app/views/projects/merge_requests/_awards_block.html.haml
@@ -0,0 +1,5 @@
+.content-block.content-block-small.emoji-list-container.js-noteable-awards
+ = render 'award_emoji/awards_block', awardable: @merge_request, inline: true do
+ - if mr_tabs_position_enabled?
+ .ml-auto.mt-auto.mb-auto
+ = render "projects/merge_requests/discussion_filter"
diff --git a/app/views/projects/merge_requests/_description.html.haml b/app/views/projects/merge_requests/_description.html.haml
new file mode 100644
index 00000000000..354a384b647
--- /dev/null
+++ b/app/views/projects/merge_requests/_description.html.haml
@@ -0,0 +1,9 @@
+%div
+ - if @merge_request.description.present?
+ .description.qa-description{ class: can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : '' }
+ .md
+ = markdown_field(@merge_request, :description)
+ %textarea.hidden.js-task-list-field
+ = @merge_request.description
+
+ = edited_time_ago_with_tooltip(@merge_request, placement: 'bottom')
diff --git a/app/views/projects/merge_requests/_discussion_filter.html.haml b/app/views/projects/merge_requests/_discussion_filter.html.haml
new file mode 100644
index 00000000000..96886661a8d
--- /dev/null
+++ b/app/views/projects/merge_requests/_discussion_filter.html.haml
@@ -0,0 +1,2 @@
+#js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@merge_request),
+ notes_filters: UserPreference.notes_filters.to_json } }
diff --git a/app/views/projects/merge_requests/_mr_box.html.haml b/app/views/projects/merge_requests/_mr_box.html.haml
index 4f09f47d795..ec78b040167 100644
--- a/app/views/projects/merge_requests/_mr_box.html.haml
+++ b/app/views/projects/merge_requests/_mr_box.html.haml
@@ -1,13 +1,6 @@
-.detail-page-description
- %h2.title.qa-title
+.detail-page-description{ class: ("py-2" if mr_tabs_position_enabled?) }
+ %h2.title.qa-title{ class: ("mb-0" if mr_tabs_position_enabled?) }
= markdown_field(@merge_request, :title)
- %div
- - if @merge_request.description.present?
- .description.qa-description{ class: can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : '' }
- .md
- = markdown_field(@merge_request, :description)
- %textarea.hidden.js-task-list-field
- = @merge_request.description
-
- = edited_time_ago_with_tooltip(@merge_request, placement: 'bottom')
+ - unless mr_tabs_position_enabled?
+ = render "projects/merge_requests/description"
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index 92e34b3ceda..d1e8dc3a834 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -1,16 +1,18 @@
+- @no_breadcrumb_border = true
- can_update_merge_request = can?(current_user, :update_merge_request, @merge_request)
- can_reopen_merge_request = can?(current_user, :reopen_merge_request, @merge_request)
+- state_human_name, state_icon_name = state_name_with_icon(@merge_request)
- if @merge_request.closed_without_fork?
.alert.alert-danger
The source project of this merge request has been removed.
-.detail-page-header
+.detail-page-header{ class: ("border-bottom-0 pt-0 pb-0" if mr_tabs_position_enabled?) }
.detail-page-header-body
.issuable-status-box.status-box{ class: status_box_class(@merge_request) }
- = sprite_icon(@merge_request.state_icon_name, size: 16, css_class: 'd-block d-sm-none')
+ = sprite_icon(state_icon_name, size: 16, css_class: 'd-block d-sm-none')
%span.d-none.d-sm-block
- = @merge_request.state_human_name
+ = state_human_name
.issuable-meta
- if @merge_request.discussion_locked?
diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml
new file mode 100644
index 00000000000..3fe6f0a6640
--- /dev/null
+++ b/app/views/projects/merge_requests/_widget.html.haml
@@ -0,0 +1,14 @@
+- if @merge_request.source_branch_exists?
+ = render "projects/merge_requests/how_to_merge"
+
+= javascript_tag nonce: true do
+ :plain
+ window.gl = window.gl || {};
+ window.gl.mrWidgetData = #{serialize_issuable(@merge_request, serializer: 'widget', issues_links: true)}
+
+ window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}';
+ window.gl.mrWidgetData.troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: 'troubleshooting')}';
+ window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.html', anchor: 'security-approvals-in-merge-requests-ultimate')}';
+ window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers')}';
+
+#js-vue-mr-widget.mr-widget
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index dee6bc8bae4..310cd355d22 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -14,56 +14,54 @@
.merge-request-details.issuable-details{ data: { id: @merge_request.project.id } }
= render "projects/merge_requests/mr_box"
- - if @merge_request.source_branch_exists?
- = render "projects/merge_requests/how_to_merge"
-
- = javascript_tag nonce: true do
- :plain
- window.gl = window.gl || {};
- window.gl.mrWidgetData = #{serialize_issuable(@merge_request, serializer: 'widget', issues_links: true)}
-
- window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}';
- window.gl.mrWidgetData.troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/index.md', anchor: 'troubleshooting')}';
- window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.html', anchor: 'security-approvals-in-merge-requests-ultimate')}';
-
- #js-vue-mr-widget.mr-widget
-
- .content-block.content-block-small.emoji-list-container.js-noteable-awards
- = render 'award_emoji/awards_block', awardable: @merge_request, inline: true
+ - unless mr_tabs_position_enabled?
+ = render "projects/merge_requests/widget"
+ = render "projects/merge_requests/awards_block"
.merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') }
.merge-request-tabs-container
%ul.merge-request-tabs.nav-tabs.nav.nav-links
- %li.notes-tab{ data: { qa_selector: 'notes_tab'} }
+ = render "projects/merge_requests/tabs/tab", class: "notes-tab", qa_selector: "notes_tab" do
= tab_link_for @merge_request, :show, force_link: @commit.present? do
- = _("Discussion")
+ - if mr_tabs_position_enabled?
+ = _("Overview")
+ - else
+ = _("Discussion")
%span.badge.badge-pill= @merge_request.related_notes.user.count
- if @merge_request.source_project
- %li.commits-tab
+ = render "projects/merge_requests/tabs/tab", name: "commits", class: "commits-tab" do
= tab_link_for @merge_request, :commits do
= _("Commits")
%span.badge.badge-pill= @commits_count
- if number_of_pipelines.nonzero?
- %li.pipelines-tab
+ = render "projects/merge_requests/tabs/tab", name: "pipelines", class: "pipelines-tab" do
= tab_link_for @merge_request, :pipelines do
= _("Pipelines")
%span.badge.badge-pill.js-pipelines-mr-count= number_of_pipelines
- %li.diffs-tab.qa-diffs-tab
+ = render "projects/merge_requests/tabs/tab", name: "diffs", class: "diffs-tab qa-diffs-tab", id: "diffs-tab" do
= tab_link_for @merge_request, :diffs do
= _("Changes")
%span.badge.badge-pill= @merge_request.diff_size
+ - if mr_tabs_position_enabled? && show_tabs_feature_highlight?
+ .js-tabs-feature-highlight{ data: { dismiss_endpoint: user_callouts_path, feature_id: UserCalloutsHelper::TABS_POSITION_HIGHLIGHT } }
.d-flex.flex-wrap.align-items-center.justify-content-lg-end
- #js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@merge_request),
- notes_filters: UserPreference.notes_filters.to_json } }
+ - unless mr_tabs_position_enabled?
+ = render "projects/merge_requests/discussion_filter"
#js-vue-discussion-counter
.tab-content#diff-notes-app
#js-diff-file-finder
- #notes.notes.tab-pane.voting_notes
+ = render "projects/merge_requests/tabs/pane", id: "notes", class: "notes voting_notes" do
.row
%section.col-md-12
%script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe
.issuable-discussion.js-vue-notes-event
+ - if mr_tabs_position_enabled?
+ - if @merge_request.description.present?
+ .detail-page-description
+ = render "projects/merge_requests/description"
+ = render "projects/merge_requests/widget"
+ = render "projects/merge_requests/awards_block"
#js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request).to_json,
noteable_data: serialize_issuable(@merge_request, serializer: 'noteable'),
noteable_type: 'MergeRequest',
@@ -71,13 +69,15 @@
help_page_path: suggest_changes_help_path,
current_user_data: @current_user_data} }
- #commits.commits.tab-pane
+ = render "projects/merge_requests/tabs/pane", name: "commits", id: "commits", class: "commits" do
-# This tab is always loaded via AJAX
- #pipelines.pipelines.tab-pane
+ = render "projects/merge_requests/tabs/pane", name: "pipelines", id: "pipelines", class: "pipelines" do
- if number_of_pipelines.nonzero?
= render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request)
- #js-diffs-app.diffs.tab-pane{ data: { "is-locked" => @merge_request.discussion_locked?,
+ = render "projects/merge_requests/tabs/pane", name: "diffs", id: "js-diffs-app", class: "diffs", data: { "is-locked": @merge_request.discussion_locked?,
endpoint: diffs_project_merge_request_path(@project, @merge_request, 'json', request.query_parameters),
+ endpoint_metadata: diffs_metadata_project_json_merge_request_path(@project, @merge_request, 'json', request.query_parameters),
+ endpoint_batch: diffs_batch_project_json_merge_request_path(@project, @merge_request, 'json', request.query_parameters),
help_page_path: suggest_changes_help_path,
current_user_data: @current_user_data,
project_path: project_path(@merge_request.project),
@@ -85,7 +85,7 @@
is_fluid_layout: fluid_layout.to_s,
dismiss_endpoint: user_callouts_path,
show_suggest_popover: show_suggest_popover?.to_s,
- show_whitespace_default: @show_whitespace_default.to_s } }
+ show_whitespace_default: @show_whitespace_default.to_s }
.mr-loading-status
= spinner
diff --git a/app/views/projects/merge_requests/tabs/_pane.html.haml b/app/views/projects/merge_requests/tabs/_pane.html.haml
new file mode 100644
index 00000000000..1a88d5f5134
--- /dev/null
+++ b/app/views/projects/merge_requests/tabs/_pane.html.haml
@@ -0,0 +1,7 @@
+- tab_name = local_assigns.fetch(:name, nil)
+- tab_id = local_assigns.fetch(:id, nil)
+- tab_class = local_assigns.fetch(:class, nil)
+- tab_data = local_assigns.fetch(:data, nil)
+
+.tab-pane{ id: tab_id, class: tab_class, style: ("display: block" if params[:tab] == tab_name), data: tab_data }
+ = yield
diff --git a/app/views/projects/merge_requests/tabs/_tab.html.haml b/app/views/projects/merge_requests/tabs/_tab.html.haml
new file mode 100644
index 00000000000..dcd8db90509
--- /dev/null
+++ b/app/views/projects/merge_requests/tabs/_tab.html.haml
@@ -0,0 +1,7 @@
+- tab_name = local_assigns.fetch(:name, nil)
+- tab_class = local_assigns.fetch(:class, nil)
+- qa_selector = local_assigns.fetch(:qa_selector, nil)
+- id = local_assigns.fetch(:id, nil)
+
+%li{ class: [tab_class, ("active" if params[:tab] == tab_name)], id: id, data: { qa_selector: qa_selector } }
+ = yield
diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml
index 4676c7399f1..6d196b06135 100644
--- a/app/views/projects/pages/_list.html.haml
+++ b/app/views/projects/pages/_list.html.haml
@@ -21,11 +21,11 @@
%span.badge.badge-danger
= s_('GitLabPages|Expired')
%div
- = link_to s_('GitLabPages|Edit'), edit_project_pages_domain_path(@project, domain), class: "btn btn-sm btn-grouped btn-success btn-inverted"
+ = link_to s_('GitLabPages|Edit'), project_pages_domain_path(@project, domain), class: "btn btn-sm btn-grouped btn-success btn-inverted"
= link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?')}, method: :delete, class: "btn btn-remove btn-sm btn-grouped"
- if verification_enabled && domain.unverified?
%li.list-group-item.bs-callout-warning
- - details_link_start = "<a href='#{edit_project_pages_domain_path(@project, domain)}'>".html_safe
+ - details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe
- details_link_end = '</a>'.html_safe
= s_('GitLabPages|%{domain} is not verified. To learn how to verify ownership, visit your %{link_start}domain details%{link_end}.').html_safe % { domain: domain.domain,
link_start: details_link_start,
diff --git a/app/views/projects/pages_domains/edit.html.haml b/app/views/projects/pages_domains/edit.html.haml
deleted file mode 100644
index a08be65d7e4..00000000000
--- a/app/views/projects/pages_domains/edit.html.haml
+++ /dev/null
@@ -1,21 +0,0 @@
-- add_to_breadcrumbs _("Pages"), project_pages_path(@project)
-- breadcrumb_title @domain.domain
-- page_title @domain.domain
-
-- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled?
-
-- if verification_enabled && @domain.unverified?
- = content_for :flash_message do
- .alert.alert-warning
- .container-fluid.container-limited
- = _("This domain is not verified. You will need to verify ownership before access is enabled.")
-
-%h3.page-title
- = _('Pages Domain')
-= render 'projects/pages_domains/helper_text'
-%div
- = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'fieldset-form' } do |f|
- = render 'form', { f: f }
- .form-actions.d-flex.justify-content-between
- = f.submit _('Save Changes'), class: "btn btn-success"
- = link_to _('Cancel'), project_pages_path(@project), class: 'btn btn-default btn-inverse'
diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml
index 8eec3d51835..a08be65d7e4 100644
--- a/app/views/projects/pages_domains/show.html.haml
+++ b/app/views/projects/pages_domains/show.html.haml
@@ -1,7 +1,6 @@
- add_to_breadcrumbs _("Pages"), project_pages_path(@project)
- breadcrumb_title @domain.domain
-- page_title "#{@domain.domain}", _('Pages Domains')
-- dns_record = "#{@domain.domain} CNAME #{@domain.project.pages_subdomain}.#{Settings.pages.host}."
+- page_title @domain.domain
- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled?
@@ -11,51 +10,12 @@
.container-fluid.container-limited
= _("This domain is not verified. You will need to verify ownership before access is enabled.")
-%h3.page-title.with-button
- = link_to _('Edit'), edit_project_pages_domain_path(@project, @domain), class: 'btn btn-success float-right'
- = _("Pages Domain")
-
-.table-holder
- %table.table
- %tr
- %td
- = _("Domain")
- %td
- = external_link(@domain.url, @domain.url)
- %tr
- %td
- = _("DNS")
- %td
- .input-group
- = text_field_tag :domain_dns, dns_record , class: "monospace js-select-on-focus form-control", readonly: true
- .input-group-append
- = clipboard_button(target: '#domain_dns', class: 'btn-default input-group-text d-none d-sm-block')
- %p.form-text.text-muted
- = _("To access this domain create a new DNS record")
-
- - if verification_enabled
- - verification_record = "#{@domain.verification_domain} TXT #{@domain.keyed_verification_code}"
- %tr
- %td
- = _("Verification status")
- %td
- = form_tag verify_project_pages_domain_path(@project, @domain) do
- .status-badge
- - text, status = @domain.unverified? ? [_('Unverified'), 'badge-danger'] : [_('Verified'), 'badge-success']
- .badge{ class: status }
- = text
- %button.btn.has-tooltip{ type: "submit", data: { container: 'body' }, title: _("Retry verification") }
- = sprite_icon('redo')
- .input-group
- = text_field_tag :domain_verification, verification_record, class: "monospace js-select-on-focus form-control", readonly: true
- .input-group-append
- = clipboard_button(target: '#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 }
-
- %tr
- %td
- = _("Certificate")
- %td
- = render 'lets_encrypt_callout', auto_ssl_available_and_enabled: false
+%h3.page-title
+ = _('Pages Domain')
+= render 'projects/pages_domains/helper_text'
+%div
+ = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'fieldset-form' } do |f|
+ = render 'form', { f: f }
+ .form-actions.d-flex.justify-content-between
+ = f.submit _('Save Changes'), class: "btn btn-success"
+ = link_to _('Cancel'), project_pages_path(@project), class: 'btn btn-default btn-inverse'
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index 4eec81c9125..ce6ae765de9 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -20,6 +20,11 @@
.well-segment.qa-pipeline-badges
.icon-container
= sprite_icon('flag')
+ - if @pipeline.child?
+ %span.js-pipeline-child.badge.badge-primary.has-tooltip{ title: s_("Pipelines|This is a child pipeline within the parent pipeline") }
+ = s_('Pipelines|Child pipeline')
+ = surround '(', ')' do
+ = link_to s_('Pipelines|parent'), pipeline_path(@pipeline.triggered_by_pipeline), class: 'text-white text-underline'
- if @pipeline.latest?
%span.js-pipeline-url-latest.badge.badge-success.has-tooltip{ title: _("Latest pipeline for the most recent commit on this branch") }
latest
diff --git a/app/views/projects/registry/settings/_index.haml b/app/views/projects/registry/settings/_index.haml
new file mode 100644
index 00000000000..e1eed93664e
--- /dev/null
+++ b/app/views/projects/registry/settings/_index.haml
@@ -0,0 +1,2 @@
+#js-registry-settings{ data: { registry_settings_endpoint: '',
+ help_page_path: help_page_path('user/project/operations/linking_to_an_external_dashboard') } }
diff --git a/app/views/projects/services/edit.html.haml b/app/views/projects/services/edit.html.haml
index 1e7903535c6..e3e8a312431 100644
--- a/app/views/projects/services/edit.html.haml
+++ b/app/views/projects/services/edit.html.haml
@@ -1,8 +1,10 @@
- breadcrumb_title @service.title
- page_title @service.title, s_("ProjectService|Services")
- add_to_breadcrumbs(s_("ProjectService|Settings"), edit_project_path(@project))
-- add_to_breadcrumbs(s_("ProjectService|Integrations"), namespace_project_settings_integrations_path)
+- add_to_breadcrumbs(s_("ProjectService|Integrations"), project_settings_integrations_path(@project))
= render 'deprecated_message' if @service.deprecation_message
= render 'form'
+- if @web_hook_logs
+ = render partial: 'projects/hook_logs/index', locals: { hook: @service.service_hook, hook_logs: @web_hook_logs, project: @project }
diff --git a/app/views/projects/settings/_archive.html.haml b/app/views/projects/settings/_archive.html.haml
new file mode 100644
index 00000000000..3307c3775ec
--- /dev/null
+++ b/app/views/projects/settings/_archive.html.haml
@@ -0,0 +1,18 @@
+- return unless can?(current_user, :archive_project, @project)
+
+.sub-section
+ %h4.warning-title
+ - if @project.archived?
+ = _('Unarchive project')
+ - else
+ = _('Archive project')
+ - if @project.archived?
+ %p= _("Unarchiving the project will restore people's ability to make changes to it. The repository can be committed to, and issues, comments, and other entities can be created. %{strong_start}Once active, this project shows up in the search and on the dashboard.%{strong_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
+ = link_to _('Unarchive project'), unarchive_project_path(@project),
+ data: { confirm: _("Are you sure that you want to unarchive this project?"), qa_selector: 'unarchive_project_link' },
+ method: :post, class: "btn btn-success"
+ - else
+ %p= _("Archiving the project will make it entirely read only. It is hidden from the dashboard and doesn't show up in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
+ = link_to _('Archive project'), archive_project_path(@project),
+ data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link' },
+ method: :post, class: "btn btn-warning"
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index ea815be23c1..a72179f40ad 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -55,7 +55,7 @@
= f.text_field :ci_config_path, class: 'form-control', placeholder: '.gitlab-ci.yml'
%p.form-text.text-muted
= _("The path to the CI configuration file. Defaults to <code>.gitlab-ci.yml</code>").html_safe
- = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'custom-ci-config-path'), target: '_blank'
+ = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'custom-ci-configuration-path'), target: '_blank'
%hr
.form-group
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index 862db23e856..38483f599b7 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -55,6 +55,18 @@
%button.btn.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
- = _("Triggers can force a specific branch or tag to get rebuilt with an API call. These tokens will impersonate their associated user including their access to projects and their project permissions.")
+ = _("Triggers can force a specific branch or tag to get rebuilt with an API call. These tokens will impersonate their associated user including their access to projects and their project permissions.")
.settings-content
= render 'projects/triggers/index'
+
+- if Feature.enabled?(:registry_retention_policies_settings, @project)
+ %section.settings.no-animate#js-registry-polcies{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _("Container Registry tag expiration policies")
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? _('Collapse') : _('Expand')
+ %p
+ = _("Expiration policies for the Container Registry are a perfect solution for keeping the Registry space down while still enjoying the full power of GitLab CI/CD.")
+ .settings-content
+ = render 'projects/registry/settings/index'
diff --git a/app/views/projects/settings/integrations/_project_hook.html.haml b/app/views/projects/settings/integrations/_project_hook.html.haml
deleted file mode 100644
index ef445f2e139..00000000000
--- a/app/views/projects/settings/integrations/_project_hook.html.haml
+++ /dev/null
@@ -1,16 +0,0 @@
-%li
- .row
- .col-md-8.col-lg-7
- %strong.light-header= hook.url
- %div
- - ProjectHook.triggers.each_value do |event|
- - if hook.public_send(event)
- %span.badge.badge-gray.deploy-project-label= event.to_s.titleize
- .col-md-4.col-lg-5.text-right-lg.prepend-top-5
- %span.append-right-10.inline
- #{_("SSL Verification")}: #{hook.enable_ssl_verification ? _('enabled') : _('disabled')}
- = link_to _('Edit'), edit_project_hook_path(@project, hook), class: 'btn btn-sm'
- = render 'shared/web_hooks/test_button', triggers: ProjectHook.triggers, hook: hook, button_class: 'btn-sm'
- = link_to project_hook_path(@project, hook), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn btn-transparent' do
- %span.sr-only= _("Remove")
- = icon('trash')
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index c5653c3dd5a..8f13806e8cd 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -18,11 +18,8 @@
- if can?(current_user, :download_code, @project) && @project.repository_languages.present?
= repository_languages_bar(@project.repository_languages)
- - if @project.archived?
- .text-warning.center.prepend-top-20
- %p
- = icon("exclamation-triangle fw")
- #{ _('Archived project! Repository and other project resources are read-only') }
+ = render "archived_notice", project: @project
+ = render_if_exists "projects/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 ea963510a68..29bad50579c 100644
--- a/app/views/projects/snippets/_actions.html.haml
+++ b/app/views/projects/snippets/_actions.html.haml
@@ -4,7 +4,7 @@
- if can?(current_user, :update_project_snippet, @snippet)
= link_to edit_project_snippet_path(@project, @snippet), class: "btn btn-grouped" do
= _('Edit')
- - if can?(current_user, :update_project_snippet, @snippet)
+ - if can?(current_user, :admin_project_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)
@@ -23,7 +23,7 @@
%li
= link_to new_project_snippet_path(@project), title: _("New snippet") do
= _('New snippet')
- - if can?(current_user, :update_project_snippet, @snippet)
+ - if can?(current_user, :admin_project_snippet, @snippet)
%li
= link_to project_snippet_path(@project, @snippet), method: :delete, data: { confirm: _("Are you sure?") }, title: _('Delete Snippet') do
= _('Delete')
diff --git a/app/views/projects/snippets/index.html.haml b/app/views/projects/snippets/index.html.haml
index 7682d01a5a1..0ce18d83d57 100644
--- a/app/views/projects/snippets/index.html.haml
+++ b/app/views/projects/snippets/index.html.haml
@@ -8,8 +8,7 @@
- if can?(current_user, :create_project_snippet, @project)
.nav-controls
- - if can?(current_user, :create_project_snippet, @project)
- = link_to _("New snippet"), new_project_snippet_path(@project), class: "btn btn-success", title: _("New snippet")
+ = link_to _("New snippet"), new_project_snippet_path(@project), class: "btn btn-success", title: _("New snippet")
= render 'shared/snippets/list'
- else
diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml
index f495b4eaf30..768e4422206 100644
--- a/app/views/projects/snippets/show.html.haml
+++ b/app/views/projects/snippets/show.html.haml
@@ -3,13 +3,16 @@
- breadcrumb_title @snippet.to_reference
- page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets")
-= render 'shared/snippets/header'
+- if Feature.enabled?(:snippets_vue)
+ #js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id} }
+- else
+ = render 'shared/snippets/header'
-.project-snippets
- %article.file-holder.snippet-file-content
- = render 'shared/snippets/blob'
+ .project-snippets
+ %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/stage/_stage.html.haml b/app/views/projects/stage/_stage.html.haml
index f93994bebe3..387c8fb3234 100644
--- a/app/views/projects/stage/_stage.html.haml
+++ b/app/views/projects/stage/_stage.html.haml
@@ -1,3 +1,5 @@
+- stage = stage.present(current_user: current_user)
+
%tr
%th{ colspan: 10 }
%strong
@@ -6,8 +8,8 @@
= ci_icon_for_status(stage.status)
&nbsp;
= stage.name.titleize
-= render stage.statuses.latest_ordered, stage: false, ref: false, pipeline_link: false, allow_retry: true
-= render stage.statuses.retried_ordered, stage: false, ref: false, pipeline_link: false, retried: true
+= render stage.latest_ordered_statuses, stage: false, ref: false, pipeline_link: false, allow_retry: true
+= render stage.retried_ordered_statuses, stage: false, ref: false, pipeline_link: false, retried: true
%tr
%td{ colspan: 10 }
&nbsp;
diff --git a/app/views/projects/tags/_tag.atom.builder b/app/views/projects/tags/_tag.atom.builder
index 60d4b21b9d1..e4b2428d267 100644
--- a/app/views/projects/tags/_tag.atom.builder
+++ b/app/views/projects/tags/_tag.atom.builder
@@ -7,7 +7,7 @@ if commit
xml.id tag_url
xml.link href: tag_url
xml.title truncate(tag.name, length: 80)
- xml.summary strip_gpg_signature(tag.message)
+ xml.summary strip_signature(tag.message)
xml.content markdown_field(release, :description), type: 'html'
xml.updated release.updated_at.xmlschema if release
xml.media :thumbnail, width: '40', height: '40', url: image_url(avatar_icon_for_email(commit.author_email))
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index c7bd0262c54..75805192a61 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -1,7 +1,7 @@
- commit = @repository.commit(tag.dereferenced_target)
- release = @releases.find { |release| release.tag == tag.name }
-%li.flex-row
- .row-main-content.str-truncated
+%li.flex-row.allow-wrap
+ .row-main-content
= icon('tag')
= link_to tag.name, project_tag_path(@project, tag.name), class: 'item-title ref-name prepend-left-4'
@@ -11,7 +11,7 @@
- if tag.message.present?
&nbsp;
- = strip_gpg_signature(tag.message)
+ = strip_signature(tag.message)
- if commit
.block-truncated
@@ -26,7 +26,7 @@
= _("Release")
= link_to release.name, project_releases_path(@project, anchor: release.tag), class: 'tag-release-link'
- if release.description.present?
- .description.md.prepend-top-default
+ .md.prepend-top-default
= markdown_field(release, :description)
.row-fixed-content.controls.flex-row
diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml
index 417cd7a8fee..8086d47479d 100644
--- a/app/views/projects/tags/show.html.haml
+++ b/app/views/projects/tags/show.html.haml
@@ -1,3 +1,7 @@
+- user = user_email = nil
+- if @tag.tagger
+ - user_email = @tag.tagger.email
+ - user = User.find_by_any_email(user_email)
- add_to_breadcrumbs s_('TagsPage|Tags'), project_tags_path(@project)
- breadcrumb_title @tag.name
- page_title @tag.name, s_('TagsPage|Tags')
@@ -11,6 +15,24 @@
- if protected_tag?(@project, @tag)
%span.badge.badge-success
= s_('TagsPage|protected')
+
+ - if user
+ = link_to user_path(user) do
+ %div
+ = user_avatar_without_link(user: user, size: 32, css_class: "mt-1 mb-1")
+
+ %div
+ %strong= user.name
+ %div= user.to_reference
+
+ - elsif user_email
+ = mail_to user_email do
+ %div
+ = user_avatar_without_link(user_email: user_email, size: 32, css_class: "mt-1 mb-1")
+
+ %div{ :class => "clearfix" }
+ %strong= user_email
+
- if @commit
= render 'projects/branches/commit', commit: @commit, project: @project
- else
@@ -33,7 +55,7 @@
- if @tag.message.present?
%pre.wrap
- = strip_gpg_signature(@tag.message)
+ = strip_signature(@tag.message)
.append-bottom-default.prepend-top-default
- if @release.description.present?
diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml
index fef019e1b69..3e3804ae204 100644
--- a/app/views/projects/tree/_readme.html.haml
+++ b/app/views/projects/tree/_readme.html.haml
@@ -1,9 +1,10 @@
- if readme.rich_viewer
%article.file-holder.readme-holder{ id: 'readme', class: [("limited-width-container" unless fluid_layout), ("js-show-on-root" if vue_file_list_enabled?)] }
- .js-file-title.file-title
- = blob_icon readme.mode, readme.name
- = link_to project_blob_path(@project, tree_join(@ref, readme.path)) do
- %strong
- = readme.name
+ .js-file-title.file-title-flex-parent
+ .file-header-content
+ = blob_icon readme.mode, readme.name
+ = link_to project_blob_path(@project, tree_join(@ref, readme.path)) do
+ %strong
+ = readme.name
= render 'projects/blob/viewer', viewer: readme.rich_viewer, viewer_url: project_blob_path(@project, tree_join(@ref, readme.path), viewer: :rich, format: :json)
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index 127734ddfd7..2d987744dfd 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -75,7 +75,7 @@
= link_to new_project_tag_path(@project) do
#{ _('New tag') }
-.tree-controls
+.tree-controls<
= 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) } }
@@ -85,20 +85,19 @@
= render 'projects/find_file_link'
- if can_create_mr_from_fork
- = succeed " " do
- - 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)
+ - else
+ = link_to '#modal-confirm-fork', class: 'btn btn-default qa-web-ide-button', data: { target: '#modal-confirm-fork', toggle: 'modal'} do
+ = _('Web IDE')
+ = render 'shared/confirm_fork_modal', fork_path: ide_fork_and_edit_path(@project, @ref, @path)
- if show_xcode_link?(@project)
- .project-action-button.project-xcode.inline
+ .project-action-button.project-xcode.inline<
= render "projects/buttons/xcode_link"
= render 'projects/buttons/download', project: @project, ref: @ref
diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml
index 84198489e41..255a62d0d06 100644
--- a/app/views/search/_category.html.haml
+++ b/app/views/search/_category.html.haml
@@ -27,7 +27,7 @@
= search_filter_link 'snippet_blobs', _("Snippet Contents"), search: { snippets: true, group_id: nil, project_id: nil }
= search_filter_link 'snippet_titles', _("Titles and Filenames"), search: { snippets: true, group_id: nil, project_id: nil }
- else
- = search_filter_link 'projects', _("Projects")
+ = search_filter_link 'projects', _("Projects"), data: { qa_selector: 'projects_tab' }
= search_filter_link 'issues', _("Issues")
= search_filter_link 'merge_requests', _("Merge requests")
= search_filter_link 'milestones', _("Milestones")
diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml
index 37f4efee9d2..0b114bf67ee 100644
--- a/app/views/search/results/_snippet_blob.html.haml
+++ b/app/views/search/results/_snippet_blob.html.haml
@@ -1,7 +1,7 @@
- snippet_blob = chunk_snippet(snippet_blob, @search_term)
- snippet = snippet_blob[:snippet_object]
- snippet_chunks = snippet_blob[:snippet_chunks]
-- snippet_path = reliable_snippet_path(snippet)
+- snippet_path = gitlab_snippet_path(snippet)
.search-result-row
%span
diff --git a/app/views/search/results/_snippet_title.html.haml b/app/views/search/results/_snippet_title.html.haml
index 7280146720e..81e746c55a3 100644
--- a/app/views/search/results/_snippet_title.html.haml
+++ b/app/views/search/results/_snippet_title.html.haml
@@ -1,6 +1,6 @@
.search-result-row
%h4.snippet-title.term
- = link_to reliable_snippet_path(snippet_title) do
+ = link_to gitlab_snippet_path(snippet_title) do
= truncate(snippet_title.title, length: 60)
= snippet_badge(snippet_title)
%span.cgray.monospace.tiny.float-right.term
diff --git a/app/views/shared/_personal_access_tokens_form.html.haml b/app/views/shared/_personal_access_tokens_form.html.haml
index ca0b473addf..16f8a692635 100644
--- a/app/views/shared/_personal_access_tokens_form.html.haml
+++ b/app/views/shared/_personal_access_tokens_form.html.haml
@@ -18,6 +18,9 @@
.form-group.col-md-6
= f.label :expires_at, _('Expires at'), class: 'label-bold'
.input-icon-wrapper
+
+ = render_if_exists 'personal_access_tokens/callout_max_personal_access_token_lifetime'
+
= f.text_field :expires_at, class: "datepicker form-control", placeholder: 'YYYY-MM-DD'
.form-group
diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml
index 627a1eb6eae..1bf52feab11 100644
--- a/app/views/shared/_service_settings.html.haml
+++ b/app/views/shared/_service_settings.html.haml
@@ -16,7 +16,7 @@
- if @service.configurable_events.present?
.form-group.row
- .col-sm-2.text-right Trigger
+ %label.col-form-label.col-sm-2= _('Trigger')
.col-sm-10
- @service.configurable_events.each do |event|
@@ -35,6 +35,22 @@
%p.text-muted
= @service.class.event_description(event)
+ - if @service.configurable_event_actions.present?
+ .form-group.row
+ %label.col-form-label.col-sm-2= _('Event Actions')
+
+ .col-sm-10
+ - @service.configurable_event_actions.each do |action|
+ .form-group
+ .form-check
+ = form.check_box service_event_action_field_name(action), class: 'form-check-input'
+ = form.label service_event_action_field_name(action), class: 'form-check-label' do
+ %strong
+ = event_action_description(action)
+
+ %p.text-muted
+ = event_action_description(action)
+
- @service.global_fields.each do |field|
- type = field[:type]
diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml
index 93fc839a371..7f62b983bfc 100644
--- a/app/views/shared/boards/_show.html.haml
+++ b/app/views/shared/boards/_show.html.haml
@@ -29,6 +29,7 @@
":board-id" => "boardId",
":key" => "list.id" }
= render "shared/boards/components/sidebar", group: group
+ = render_if_exists 'shared/boards/components/board_settings_sidebar'
- if @project
%board-add-issues-modal{ "new-issue-path" => new_project_issue_path(@project),
"milestone-path" => milestones_filter_dropdown_path,
diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml
index ffa24d1c041..eb9b7f6c48a 100644
--- a/app/views/shared/boards/components/_board.html.haml
+++ b/app/views/shared/boards/components/_board.html.haml
@@ -42,23 +42,27 @@
%button.board-delete.no-drag.p-0.border-0.has-tooltip.float-right{ type: "button", title: _("Delete list"), ":class": "{ 'd-none': !list.isExpanded }", "aria-label" => _("Delete list"), data: { placement: "bottom" }, "@click.stop" => "deleteBoard" }
= icon("trash")
- .issue-count-badge.no-drag.text-secondary{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', ":title": "counterTooltip", "v-tooltip": true, data: { placement: "top" } }
+ .issue-count-badge.pr-0.no-drag.text-secondary{ "v-if" => "showBoardListAndBoardInfo", ":title": "counterTooltip", "v-tooltip": true, data: { placement: "top" } }
%span.d-inline-flex
%span.issue-count-badge-count
%icon.mr-1{ name: "issues" }
- {{ list.issuesSize }}
+ %issue-count{ ":maxIssueCount" => "list.maxIssueCount",
+ ":issuesSize" => "list.issuesSize" }
= render_if_exists "shared/boards/components/list_weight"
- %button.issue-count-badge-add-button.no-drag.btn.btn-sm.btn-default.ml-1.has-tooltip{ type: "button",
- "@click" => "showNewIssueForm",
- "v-if" => "isNewIssueShown",
- ":class": "{ 'd-none': !list.isExpanded }",
- "aria-label" => _("New issue"),
- "title" => _("New issue"),
- data: { placement: "top", container: "body" } }
- = icon("plus")
+ %gl-button-group.board-list-button-group.pl-2{ "v-if" => "isNewIssueShown || isSettingsShown" }
+ %gl-button.issue-count-badge-add-button.no-drag{ type: "button",
+ "@click" => "showNewIssueForm",
+ "v-if" => "isNewIssueShown",
+ ":class": "{ 'd-none': !list.isExpanded, 'rounded-right': isNewIssueShown && !isSettingsShown }",
+ "aria-label" => _("New issue"),
+ "ref" => "newIssueBtn" }
+ = icon("plus")
+ %gl-tooltip{ ":target" => "() => $refs.newIssueBtn" }
+ = _("New Issue")
+ = render_if_exists 'shared/boards/components/list_settings'
- %board-list{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"',
+ %board-list{ "v-if" => "showBoardListAndBoardInfo",
":list" => "list",
":issues" => "list.issues",
":loading" => "list.loading",
diff --git a/app/views/shared/buttons/_project_feature_toggle.html.haml b/app/views/shared/buttons/_project_feature_toggle.html.haml
new file mode 100644
index 00000000000..0f630786455
--- /dev/null
+++ b/app/views/shared/buttons/_project_feature_toggle.html.haml
@@ -0,0 +1,16 @@
+- class_list ||= "js-project-feature-toggle project-feature-toggle"
+- data ||= nil
+- disabled ||= false
+- is_checked ||= false
+- label ||= nil
+
+%button{ type: 'button',
+ class: "#{class_list} #{'is-disabled' if disabled} #{'is-checked' if is_checked}",
+ "aria-label": label,
+ disabled: disabled,
+ data: data }
+ - if yield.present?
+ = yield
+ %span.toggle-icon
+ = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
+ = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
diff --git a/app/views/shared/empty_states/_snippets.html.haml b/app/views/shared/empty_states/_snippets.html.haml
index a1a16b9d067..889a470d6ec 100644
--- a/app/views/shared/empty_states/_snippets.html.haml
+++ b/app/views/shared/empty_states/_snippets.html.haml
@@ -11,7 +11,8 @@
%p
= s_('SnippetsEmptyState|They can be either public or private.')
.text-center
- = link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn btn-success', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link'
+ - 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')
- else
diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml
index 609b8dce21a..e47967ef622 100644
--- a/app/views/shared/groups/_group.html.haml
+++ b/app/views/shared/groups/_group.html.haml
@@ -1,7 +1,7 @@
- user = local_assigns.fetch(:user, current_user)
- access = user&.max_member_access_for_group(group.id)
-%li.group-row{ class: ('no-description' if group.description.blank?) }
+%li.group-row.py-3{ class: ('no-description' if group.description.blank?) }
.stats
%span
= icon('bookmark')
diff --git a/app/views/shared/icons/_convdev_no_data.svg b/app/views/shared/icons/_dev_ops_score_no_data.svg
index ed32b2333e7..ed32b2333e7 100644
--- a/app/views/shared/icons/_convdev_no_data.svg
+++ b/app/views/shared/icons/_dev_ops_score_no_data.svg
diff --git a/app/views/shared/icons/_convdev_no_index.svg b/app/views/shared/icons/_dev_ops_score_no_index.svg
index 95c00e81d10..95c00e81d10 100644
--- a/app/views/shared/icons/_convdev_no_index.svg
+++ b/app/views/shared/icons/_dev_ops_score_no_index.svg
diff --git a/app/views/shared/icons/_convdev_overview.svg b/app/views/shared/icons/_dev_ops_score_overview.svg
index 2f31113bad7..2f31113bad7 100644
--- a/app/views/shared/icons/_convdev_overview.svg
+++ b/app/views/shared/icons/_dev_ops_score_overview.svg
diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml
index 875cacd1f4f..2eb96a7bc9b 100644
--- a/app/views/shared/issuable/_close_reopen_button.html.haml
+++ b/app/views/shared/issuable/_close_reopen_button.html.haml
@@ -6,7 +6,7 @@
- if is_current_user
- if can_update
= link_to "Close #{display_issuable_type}", close_issuable_path(issuable), method: button_method,
- class: "d-none d-sm-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}"
+ class: "d-none d-sm-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}", data: { qa_selector: 'close_issue_button' }
- if can_reopen
= link_to "Reopen #{display_issuable_type}", reopen_issuable_path(issuable), method: button_method,
class: "d-none d-sm-none d-md-block btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}", data: { qa_selector: 'reopen_issue_button' }
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index d341520e4a2..5da86195243 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -6,7 +6,7 @@
.issues-filters{ class: ("w-100" if type == :boards_modal) }
.issues-details-filters.filtered-search-block.d-flex.flex-column.flex-lg-row{ class: block_css_class, "v-pre" => type == :boards_modal }
- .d-flex.flex-column.flex-md-row.flex-grow-1.mb-lg-0.mb-md-2.mb-sm-0
+ .d-flex.flex-column.flex-md-row.flex-grow-1.mb-lg-0.mb-md-2.mb-sm-0.w-100
- if type == :boards
= render "shared/boards/switcher", board: board
= form_tag page_filter_path, method: :get, class: 'filter-form js-filter-form w-100' do
@@ -162,8 +162,8 @@
%button.clear-search.hidden{ type: 'button' }
= icon('times')
.filter-dropdown-container.d-flex.flex-column.flex-md-row
- #js-board-labels-toggle
- if type == :boards
+ #js-board-labels-toggle
.js-board-config{ data: { can_admin_list: user_can_admin_list, has_scope: board.scoped? } }
- if user_can_admin_list
= render 'shared/issuable/board_create_list_dropdown', board: board
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 2170b88c7c3..2a853de12a4 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -30,7 +30,7 @@
= render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar
- milestone = issuable_sidebar[:milestone] || {}
- .block.milestone
+ .block.milestone{ data: { qa_selector: 'milestone_block' } }
.sidebar-collapsed-icon.has-tooltip{ title: sidebar_milestone_tooltip_label(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } }
= icon('clock-o', 'aria-hidden': 'true')
%span.milestone-title.collapse-truncated-title
@@ -45,7 +45,7 @@
= link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { track_label: "right_sidebar", track_property: "milestone", track_event: "click_edit_button", track_value: "" }
.value.hide-collapsed
- if milestone.present?
- = link_to milestone[:title], milestone[:web_url], class: "bold has-tooltip", title: sidebar_milestone_remaining_days(milestone), data: { container: "body", html: 'true', boundary: 'viewport', qa_selector: 'milestone_link' }
+ = link_to milestone[:title], milestone[:web_url], class: "bold has-tooltip", title: sidebar_milestone_remaining_days(milestone), data: { container: "body", html: 'true', boundary: 'viewport', qa_selector: 'milestone_link', qa_title: milestone[:title] }
- else
%span.no-value
= _('None')
@@ -107,10 +107,10 @@
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
= link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link qa-edit-link-labels float-right', data: { track_label: "right_sidebar", track_property: "labels", track_event: "click_edit_button", track_value: "" }
- .value.issuable-show-labels.dont-hide.hide-collapsed.qa-labels-block{ class: ("has-labels" if selected_labels.any?) }
+ .value.issuable-show-labels.dont-hide.hide-collapsed{ class: ("has-labels" if selected_labels.any?), data: { qa_selector: 'labels_block' } }
- if selected_labels.any?
- selected_labels.each do |label_hash|
- = render_label(label_from_hash(label_hash).present(issuable_subject: nil), link: sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label_hash[:title]))
+ = render_label(label_from_hash(label_hash).present(issuable_subject: nil), link: sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label_hash[:title]), dataset: { qa_selector: 'label', qa_label_name: label_hash[:title] })
- else
%span.no-value
= _('None')
diff --git a/app/views/shared/labels/_nav.html.haml b/app/views/shared/labels/_nav.html.haml
index e69246dd0eb..d613ea466fa 100644
--- a/app/views/shared/labels/_nav.html.haml
+++ b/app/views/shared/labels/_nav.html.haml
@@ -13,7 +13,7 @@
= form_tag labels_filter_path, method: :get do
= hidden_field_tag :subscribed, params[:subscribed]
.input-group
- = search_field_tag :search, params[:search], { placeholder: _('Filter'), id: 'label-search', class: 'form-control search-text-input input-short', spellcheck: false }
+ = search_field_tag :search, params[:search], { placeholder: _('Filter'), id: 'label-search', class: 'form-control search-text-input input-short', spellcheck: false, autofocus: true }
%span.input-group-append
%button.btn.btn-default{ type: "submit", "aria-label" => _('Submit search') }
= icon("search")
diff --git a/app/views/shared/members/_sort_dropdown.html.haml b/app/views/shared/members/_sort_dropdown.html.haml
index 5f3d49adff7..50a55565c3c 100644
--- a/app/views/shared/members/_sort_dropdown.html.haml
+++ b/app/views/shared/members/_sort_dropdown.html.haml
@@ -8,3 +8,13 @@
%li
= link_to filter_group_project_member_path(sort: value), class: ("is-active" if @sort == value) do
= title
+ %li.divider
+ %li{ data: { 'qa-selector': 'filter-members-with-inherited-permissions' } }
+ = link_to filter_group_project_member_path(with_inherited_permissions: nil), class: ("is-active" unless params[:with_inherited_permissions].present?) do
+ = _("Show all members")
+ %li{ data: { 'qa-selector': 'filter-members-with-inherited-permissions' } }
+ = link_to filter_group_project_member_path(with_inherited_permissions: 'exclude'), class: ("is-active" if params[:with_inherited_permissions] == 'exclude') do
+ = _("Show only direct members")
+ %li{ data: { 'qa-selector': 'filter-members-with-inherited-permissions' } }
+ = link_to filter_group_project_member_path(with_inherited_permissions: 'only'), class: ("is-active" if params[:with_inherited_permissions] == 'only') do
+ = _("Show only inherited members")
diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml
index b324f35c338..6e50b31fd71 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -43,8 +43,9 @@
.col-sm-4.milestone-progress
= milestone_progress_bar(milestone)
= link_to pluralize(milestone.total_issues_count(current_user), 'Issue'), issues_path
- &middot;
- = link_to pluralize(milestone.merge_requests_visible_to_user(current_user).size, 'Merge Request'), merge_requests_path
+ - if milestone.merge_requests_enabled?
+ &middot;
+ = link_to pluralize(milestone.merge_requests_visible_to_user(current_user).size, 'Merge Request'), merge_requests_path
.float-lg-right.light #{milestone.percent_complete(current_user)}% complete
.col-sm-2
.milestone-actions.d-flex.justify-content-sm-start.justify-content-md-end
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index b6656e6283c..fbbcc4f3e68 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -105,38 +105,39 @@
= render_if_exists 'shared/milestones/weight', milestone: milestone
- .block.merge-requests
- .sidebar-collapsed-icon.has-tooltip{ title: milestone_merge_requests_tooltip_text(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } }
- %strong
- = custom_icon('mr_bold')
- %span= milestone.merge_requests.count
- .title.hide-collapsed
- Merge requests
- %span.badge.badge-pill= milestone.merge_requests.count
- .value.hide-collapsed.bold
- - if !project || can?(current_user, :read_merge_request, project)
- %span.milestone-stat
- = link_to milestones_browse_issuables_path(milestone, type: :merge_requests) do
+ - if milestone.merge_requests_enabled?
+ .block.merge-requests
+ .sidebar-collapsed-icon.has-tooltip{ title: milestone_merge_requests_tooltip_text(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } }
+ %strong
+ = custom_icon('mr_bold')
+ %span= milestone.merge_requests.count
+ .title.hide-collapsed
+ Merge requests
+ %span.badge.badge-pill= milestone.merge_requests.count
+ .value.hide-collapsed.bold
+ - if !project || can?(current_user, :read_merge_request, project)
+ %span.milestone-stat
+ = link_to milestones_browse_issuables_path(milestone, type: :merge_requests) do
+ Open:
+ = milestone.merge_requests.opened.count
+ %span.milestone-stat
+ = link_to milestones_browse_issuables_path(milestone, type: :merge_requests, state: 'closed') do
+ Closed:
+ = milestone.merge_requests.closed.count
+ %span.milestone-stat
+ = link_to milestones_browse_issuables_path(milestone, type: :merge_requests, state: 'merged') do
+ Merged:
+ = milestone.merge_requests.merged.count
+ - else
+ %span.milestone-stat
Open:
= milestone.merge_requests.opened.count
- %span.milestone-stat
- = link_to milestones_browse_issuables_path(milestone, type: :merge_requests, state: 'closed') do
+ %span.milestone-stat
Closed:
= milestone.merge_requests.closed.count
- %span.milestone-stat
- = link_to milestones_browse_issuables_path(milestone, type: :merge_requests, state: 'merged') do
+ %span.milestone-stat
Merged:
= milestone.merge_requests.merged.count
- - else
- %span.milestone-stat
- Open:
- = milestone.merge_requests.opened.count
- %span.milestone-stat
- Closed:
- = milestone.merge_requests.closed.count
- %span.milestone-stat
- Merged:
- = milestone.merge_requests.merged.count
- if project
- recent_releases, total_count, more_count = recent_releases_with_counts(milestone)
diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml
index f718c5767d1..538ebe79641 100644
--- a/app/views/shared/milestones/_tabs.html.haml
+++ b/app/views/shared/milestones/_tabs.html.haml
@@ -6,10 +6,11 @@
= link_to '#tab-issues', class: 'nav-link active', data: { toggle: 'tab', show: '.tab-issues-buttons' } do
= _('Issues')
%span.badge.badge-pill= milestone.issues_visible_to_user(current_user).size
- %li.nav-item
- = link_to '#tab-merge-requests', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'merge_requests') } do
- = _('Merge Requests')
- %span.badge.badge-pill= milestone.merge_requests_visible_to_user(current_user).size
+ - if milestone.merge_requests_enabled?
+ %li.nav-item
+ = link_to '#tab-merge-requests', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'merge_requests') } do
+ = _('Merge Requests')
+ %span.badge.badge-pill= milestone.merge_requests_visible_to_user(current_user).size
%li.nav-item
= link_to '#tab-participants', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'participants') } do
= _('Participants')
@@ -26,9 +27,10 @@
.tab-content.milestone-content
.tab-pane.active#tab-issues{ data: { sort_endpoint: (sort_issues_project_milestone_path(@project, @milestone) if @project && current_user) } }
= render 'shared/milestones/issues_tab', issues: issues, show_project_name: show_project_name, show_full_project_name: show_full_project_name
- .tab-pane#tab-merge-requests
- -# loaded async
- = render "shared/milestones/tab_loading"
+ - if milestone.merge_requests_enabled?
+ .tab-pane#tab-merge-requests
+ -# loaded async
+ = render "shared/milestones/tab_loading"
.tab-pane#tab-participants
-# loaded async
= render "shared/milestones/tab_loading"
diff --git a/app/views/shared/notifications/_custom_notifications.html.haml b/app/views/shared/notifications/_custom_notifications.html.haml
index 1fef43c0c37..be574155436 100644
--- a/app/views/shared/notifications/_custom_notifications.html.haml
+++ b/app/views/shared/notifications/_custom_notifications.html.haml
@@ -18,7 +18,7 @@
.col-lg-4
%h4.prepend-top-0= _('Notification events')
%p
- - notification_link = link_to _('notification emails'), help_page_path('workflow/notifications'), target: '_blank'
+ - notification_link = link_to _('notification emails'), help_page_path('user/profile/notifications'), target: '_blank'
- paragraph = _('Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.') % { notification_link: notification_link.html_safe }
#{ paragraph.html_safe }
.col-lg-8
diff --git a/app/views/shared/projects/_archived.html.haml b/app/views/shared/projects/_archived.html.haml
new file mode 100644
index 00000000000..fad93d14390
--- /dev/null
+++ b/app/views/shared/projects/_archived.html.haml
@@ -0,0 +1,3 @@
+- if project.archived
+ %span.d-flex.badge.badge-warning
+ = _('archived')
diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml
index 59b4facdbe5..fab7ee9d763 100644
--- a/app/views/shared/projects/_list.html.haml
+++ b/app/views/shared/projects/_list.html.haml
@@ -35,6 +35,7 @@
.js-projects-list-holder{ data: { qa_selector: 'projects_list' } }
- if any_projects?(projects)
- load_pipeline_status(projects) if pipeline_status
+ - load_max_project_member_accesses(projects) # Prime cache used in shared/projects/project view rendered below
%ul.projects-list{ class: css_classes }
- projects.each_with_index do |project, i|
- css_class = (i >= projects_limit) || project.pending_delete? ? 'hide' : nil
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index 67dad9b7a75..45e95685677 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -26,7 +26,7 @@
= image_tag avatar_icon_for_user(project.creator, 48), class: "avatar s48", alt:''
- else
= project_icon(project, alt: '', class: 'avatar project-avatar s48', width: 48, height: 48)
- .project-details.d-sm-flex.flex-sm-fill.align-items-center
+ .project-details.d-sm-flex.flex-sm-fill.align-items-center{ data: { qa_selector: 'project', qa_project_name: project.name } }
.flex-wrapper
.d-flex.align-items-center.flex-wrap.project-title
%h2.d-flex.prepend-top-8
@@ -67,8 +67,7 @@
%span.icon-wrapper.pipeline-status
= render 'ci/status/icon', status: project.last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path
- - if project.archived
- %span.d-flex.icon-wrapper.badge.badge-warning archived
+ = render_if_exists 'shared/projects/archived', project: project
- if stars
= link_to project_starrers_path(project),
class: "d-flex align-items-center icon-wrapper stars has-tooltip",
diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml
index 67f177288f0..1243bdab6dd 100644
--- a/app/views/shared/snippets/_header.html.haml
+++ b/app/views/shared/snippets/_header.html.haml
@@ -17,11 +17,11 @@
= render "snippets/actions"
.snippet-header.limited-header-width
- %h2.snippet-title.prepend-top-0.mb-3.qa-snippet-title
+ %h2.snippet-title.prepend-top-0.mb-3{ data: { qa_selector: 'snippet_title' } }
= markdown_field(@snippet, :title)
- if @snippet.description.present?
- .description.qa-snippet-description
+ .description{ data: { qa_selector: 'snippet_description' } }
.md
= markdown_field(@snippet, :description)
%textarea.hidden.js-task-list-field
@@ -44,7 +44,7 @@
%li
%button.js-share-btn.btn.btn-transparent{ type: 'button' }
%strong.embed-toggle-list-item= _("Share")
- %input.js-snippet-url-area.snippet-embed-input.form-control{ type: "text", autocomplete: 'off', value: snippet_embed_tag(@snippet) }
+ = snippet_embed_input(@snippet)
.input-group-append
= clipboard_button(title: _('Copy'), class: 'js-clipboard-btn snippet-clipboard-btn btn btn-default', target: '.js-snippet-url-area')
.clearfix
diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml
index 5602ea37b5c..9e038854c59 100644
--- a/app/views/shared/snippets/_snippet.html.haml
+++ b/app/views/shared/snippets/_snippet.html.haml
@@ -1,11 +1,11 @@
- link_project = local_assigns.fetch(:link_project, false)
- notes_count = @noteable_meta_data[snippet.id].user_notes_count
-%li.snippet-row
+%li.snippet-row.py-3
= image_tag avatar_icon_for_user(snippet.author), class: "avatar s40 d-none d-sm-block", alt: ''
.title
- = link_to reliable_snippet_path(snippet) do
+ = link_to gitlab_snippet_path(snippet) do
= snippet.title
- if snippet.file_name.present?
%span.snippet-filename.d-none.d-sm-inline-block.ml-2
@@ -14,7 +14,7 @@
%ul.controls
%li
- = link_to reliable_snippet_path(snippet, anchor: 'notes'), class: ('no-comments' if notes_count.zero?) do
+ = link_to gitlab_snippet_path(snippet, anchor: 'notes'), class: ('no-comments' if notes_count.zero?) do
= icon('comments')
= notes_count
%li
diff --git a/app/views/shared/tokens/_scopes_list.html.haml b/app/views/shared/tokens/_scopes_list.html.haml
index 428861485b4..913392be510 100644
--- a/app/views/shared/tokens/_scopes_list.html.haml
+++ b/app/views/shared/tokens/_scopes_list.html.haml
@@ -9,5 +9,5 @@
%ul.scopes-list.append-bottom-0
- token.scopes.each do |scope|
%li
- %span.scope-name= scope
+ %span.bold= scope
= "(#{t(scope, scope: [:doorkeeper, :scopes])})"
diff --git a/app/views/shared/web_hooks/_hook.html.haml b/app/views/shared/web_hooks/_hook.html.haml
new file mode 100644
index 00000000000..34a62340966
--- /dev/null
+++ b/app/views/shared/web_hooks/_hook.html.haml
@@ -0,0 +1,16 @@
+%li
+ .row
+ .col-md-8.col-lg-7
+ %strong.light-header= hook.url
+ %div
+ - hook.class.triggers.each_value do |trigger|
+ - if hook.public_send(trigger)
+ %span.badge.badge-gray.deploy-project-label= trigger.to_s.titleize
+ %span.badge.badge-gray
+ = _('SSL Verification:')
+ = hook.enable_ssl_verification ? _('enabled') : _('disabled')
+
+ .col-md-4.col-lg-5.text-right-md.prepend-top-5
+ %span>= render 'shared/web_hooks/test_button', hook: hook, button_class: 'btn-sm append-right-8'
+ %span>= link_to _('Edit'), edit_hook_path(hook), class: 'btn btn-sm append-right-8'
+ = link_to _('Delete'), destroy_hook_path(hook), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn btn-sm'
diff --git a/app/views/shared/web_hooks/_index.html.haml b/app/views/shared/web_hooks/_index.html.haml
new file mode 100644
index 00000000000..b22d51a101a
--- /dev/null
+++ b/app/views/shared/web_hooks/_index.html.haml
@@ -0,0 +1,14 @@
+%hr
+.card
+ .card-header
+ %h5
+ = hook_class.underscore.humanize.titleize.pluralize
+ (#{hooks.count})
+
+ - if hooks.any?
+ %ul.content-list
+ - hooks.each do |hook|
+ = render 'shared/web_hooks/hook', hook: hook
+ - else
+ %p.text-center.prepend-top-default.append-bottom-default
+ = _('No webhooks found, add one in the form above.')
diff --git a/app/views/shared/web_hooks/_test_button.html.haml b/app/views/shared/web_hooks/_test_button.html.haml
index 5ece8b1d4c7..fc24e425ab6 100644
--- a/app/views/shared/web_hooks/_test_button.html.haml
+++ b/app/views/shared/web_hooks/_test_button.html.haml
@@ -1,10 +1,10 @@
-- triggers = local_assigns.fetch(:triggers)
- button_class = local_assigns.fetch(:button_class, '')
- hook = local_assigns.fetch(:hook)
+- triggers = hook.class.triggers
-.hook-test-button.dropdown.inline
+.hook-test-button.dropdown.inline>
%button.btn{ 'data-toggle' => 'dropdown', class: button_class }
- Test
+ = _('Test')
= icon('caret-down')
%ul.dropdown-menu.dropdown-menu-right{ role: 'menu' }
- triggers.each_value do |event|
diff --git a/app/views/shared/web_hooks/_title_and_docs.html.haml b/app/views/shared/web_hooks/_title_and_docs.html.haml
new file mode 100644
index 00000000000..359f5f34f5b
--- /dev/null
+++ b/app/views/shared/web_hooks/_title_and_docs.html.haml
@@ -0,0 +1,5 @@
+%h4.prepend-top-0
+ = page_title
+%p
+ - link = link_to(hook.pluralized_name, help_page_path(hook.help_path))
+ = _('%{link} can be used for binding events when something is happening within the project.').html_safe % { link: link }
diff --git a/app/views/snippets/_actions.html.haml b/app/views/snippets/_actions.html.haml
index 9952f373156..5ee12a2f22a 100644
--- a/app/views/snippets/_actions.html.haml
+++ b/app/views/snippets/_actions.html.haml
@@ -5,10 +5,11 @@
= link_to edit_snippet_path(@snippet), class: "btn btn-grouped" do
= _("Edit")
- if can?(current_user, :admin_personal_snippet, @snippet)
- = link_to snippet_path(@snippet), method: :delete, data: { confirm: _("Are you sure?") }, class: "btn btn-grouped btn-inverted btn-remove", title: _('Delete Snippet') do
+ = 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")
- = link_to new_snippet_path, class: "btn btn-grouped btn-success btn-inverted", title: _("New snippet") do
- = _("New snippet")
+ - if can?(current_user, :create_personal_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)
= link_to _('Submit as spam'), mark_as_spam_snippet_path(@snippet), method: :post, class: 'btn btn-grouped btn-spam', title: _('Submit as spam')
.d-block.d-sm-none.dropdown
@@ -17,12 +18,13 @@
= icon('caret-down')
.dropdown-menu.dropdown-menu-full-width
%ul
- %li
- = link_to new_snippet_path, title: _("New snippet") do
- = _("New snippet")
+ - if can?(current_user, :create_personal_snippet)
+ %li
+ = link_to new_snippet_path, title: _("New snippet") do
+ = _("New snippet")
- if can?(current_user, :admin_personal_snippet, @snippet)
%li
- = link_to snippet_path(@snippet), method: :delete, data: { confirm: _("Are you sure?") }, title: _('Delete Snippet') do
+ = 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)
%li
diff --git a/app/views/snippets/_snippets.html.haml b/app/views/snippets/_snippets.html.haml
index dab247da251..69b19c0def9 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
+- primary_button_link = new_snippet_path if can?(current_user, :create_personal_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 c312226dd6c..cb59b11ca2b 100644
--- a/app/views/snippets/_snippets_scope_menu.html.haml
+++ b/app/views/snippets/_snippets_scope_menu.html.haml
@@ -9,7 +9,7 @@
- if include_private
= subject.snippets.count
- else
- = subject.snippets.public_and_internal.count
+ = subject.snippets.public_and_internal_only.count
- if include_private
%li{ class: active_when(params[:scope] == "are_private") }
diff --git a/app/views/snippets/edit.html.haml b/app/views/snippets/edit.html.haml
index ebc6c0a2605..f5ffb037152 100644
--- a/app/views/snippets/edit.html.haml
+++ b/app/views/snippets/edit.html.haml
@@ -3,4 +3,4 @@
%h3.page-title
= _("Edit Snippet")
%hr
-= render 'shared/snippets/form', url: snippet_path(@snippet)
+= render 'shared/snippets/form', url: gitlab_snippet_path(@snippet)
diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml
index 36b4e00e8d5..080c0ab6ece 100644
--- a/app/views/snippets/show.html.haml
+++ b/app/views/snippets/show.html.haml
@@ -4,13 +4,16 @@
- breadcrumb_title @snippet.to_reference
- page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets")
-= render 'shared/snippets/header'
+- if Feature.enabled?(:snippets_vue)
+ #js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id} }
+- else
+ = render 'shared/snippets/header'
-.personal-snippets
- %article.file-holder.snippet-file-content
- = render 'shared/snippets/blob'
+ .personal-snippets
+ %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/_profile_basic_info.html.haml b/app/views/users/_profile_basic_info.html.haml
new file mode 100644
index 00000000000..af0a766bab0
--- /dev/null
+++ b/app/views/users/_profile_basic_info.html.haml
@@ -0,0 +1,6 @@
+%p
+ %span.middle-dot-divider
+ @#{@user.username}
+ - if can?(current_user, :read_user_profile, @user)
+ %span.middle-dot-divider
+ = s_('Member since %{date}') % { date: @user.created_at.to_date.to_s(:long) }
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index e1c75d5d0f4..e10dad8aa8d 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -1,7 +1,7 @@
- @hide_top_links = true
- @hide_breadcrumbs = true
- @no_container = true
-- page_title @user.name
+- page_title @user.blocked? ? s_('UserProfile|Blocked user') : @user.name
- page_description @user.bio
- header_title @user.name, user_path(@user)
@@ -36,50 +36,48 @@
= link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
= image_tag avatar_icon_for_user(@user, 90), class: "avatar s90", alt: ''
- .user-info
- .cover-title
- = @user.name
-
- - if @user.status
- .cover-status
- = emoji_icon(@user.status.emoji)
- = markdown_field(@user.status, :message)
-
- .cover-desc.member-date.cgray
- %p
- %span.middle-dot-divider
- @#{@user.username}
- - if can?(current_user, :read_user_profile, @user)
- %span.middle-dot-divider
- = s_('Member since %{date}') % { date: @user.created_at.to_date.to_s(:long) }
-
- .cover-desc.cgray
- - unless @user.public_email.blank?
- .profile-link-holder.middle-dot-divider
- = link_to @user.public_email, "mailto:#{@user.public_email}", class: 'text-link'
- - unless @user.skype.blank?
- .profile-link-holder.middle-dot-divider
- = link_to "skype:#{@user.skype}", title: "Skype" do
- = icon('skype')
- - unless @user.linkedin.blank?
- .profile-link-holder.middle-dot-divider
- = link_to linkedin_url(@user), title: "LinkedIn", target: '_blank', rel: 'noopener noreferrer nofollow' do
- = icon('linkedin-square')
- - unless @user.twitter.blank?
- .profile-link-holder.middle-dot-divider
- = link_to twitter_url(@user), title: "Twitter", target: '_blank', rel: 'noopener noreferrer nofollow' do
- = icon('twitter-square')
- - unless @user.website_url.blank?
- .profile-link-holder.middle-dot-divider
- = link_to @user.short_website_url, @user.full_website_url, class: 'text-link', target: '_blank', rel: 'me noopener noreferrer nofollow'
- - unless @user.location.blank?
- .profile-link-holder.middle-dot-divider
- = sprite_icon('location', size: 16, css_class: 'vertical-align-sub')
- = @user.location
- - unless @user.organization.blank?
- .profile-link-holder.middle-dot-divider
- = sprite_icon('work', size: 16, css_class: 'vertical-align-sub')
- = @user.organization
+ - if @user.blocked?
+ .user-info
+ .cover-title
+ = s_('UserProfile|Blocked user')
+ = render "users/profile_basic_info"
+ - else
+ .user-info
+ .cover-title
+ = @user.name
+
+ - if @user.status
+ .cover-status
+ = emoji_icon(@user.status.emoji)
+ = markdown_field(@user.status, :message)
+ = render "users/profile_basic_info"
+ .cover-desc.cgray
+ - unless @user.public_email.blank?
+ .profile-link-holder.middle-dot-divider
+ = link_to @user.public_email, "mailto:#{@user.public_email}", class: 'text-link'
+ - unless @user.skype.blank?
+ .profile-link-holder.middle-dot-divider
+ = link_to "skype:#{@user.skype}", title: "Skype" do
+ = icon('skype')
+ - unless @user.linkedin.blank?
+ .profile-link-holder.middle-dot-divider
+ = link_to linkedin_url(@user), title: "LinkedIn", target: '_blank', rel: 'noopener noreferrer nofollow' do
+ = icon('linkedin-square')
+ - unless @user.twitter.blank?
+ .profile-link-holder.middle-dot-divider
+ = link_to twitter_url(@user), title: "Twitter", target: '_blank', rel: 'noopener noreferrer nofollow' do
+ = icon('twitter-square')
+ - unless @user.website_url.blank?
+ .profile-link-holder.middle-dot-divider
+ = link_to @user.short_website_url, @user.full_website_url, class: 'text-link', target: '_blank', rel: 'me noopener noreferrer nofollow'
+ - unless @user.location.blank?
+ .profile-link-holder.middle-dot-divider
+ = sprite_icon('location', size: 16, css_class: 'vertical-align-sub')
+ = @user.location
+ - unless @user.organization.blank?
+ .profile-link-holder.middle-dot-divider
+ = sprite_icon('work', size: 16, css_class: 'vertical-align-sub')
+ = @user.organization
- if @user.bio.present?
.cover-desc.cgray
@@ -165,4 +163,8 @@
.col-12.text-center
.text-content
%h4
- = s_('UserProfile|This user has a private profile')
+ - if @user.blocked?
+ = s_('UserProfile|This user is blocked')
+ - else
+ = s_('UserProfile|This user has a private profile')
+
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 66b5214cfcb..02acf360afc 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -16,6 +16,7 @@
- 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
@@ -38,6 +39,9 @@
- 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
@@ -48,6 +52,8 @@
- 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
diff --git a/app/workers/clusters/applications/activate_service_worker.rb b/app/workers/clusters/applications/activate_service_worker.rb
new file mode 100644
index 00000000000..4f285d55162
--- /dev/null
+++ b/app/workers/clusters/applications/activate_service_worker.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class ActivateServiceWorker
+ include ApplicationWorker
+ include ClusterQueue
+
+ def perform(cluster_id, service_name)
+ cluster = Clusters::Cluster.find_by_id(cluster_id)
+ return unless cluster
+
+ cluster.all_projects.find_each do |project|
+ project.find_or_initialize_service(service_name).update!(active: true)
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/clusters/applications/deactivate_service_worker.rb b/app/workers/clusters/applications/deactivate_service_worker.rb
new file mode 100644
index 00000000000..2c560cc998c
--- /dev/null
+++ b/app/workers/clusters/applications/deactivate_service_worker.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class DeactivateServiceWorker
+ include ApplicationWorker
+ include ClusterQueue
+
+ def perform(cluster_id, service_name)
+ cluster = Clusters::Cluster.find_by_id(cluster_id)
+ raise cluster_missing_error(service_name) unless cluster
+
+ service = "#{service_name}_service".to_sym
+ cluster.all_projects.with_service(service).find_each do |project|
+ project.public_send(service).update!(active: false) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+
+ def cluster_missing_error(service)
+ ActiveRecord::RecordNotFound.new("Can't deactivate #{service} services, host cluster not found! Some inconsistent records may be left in database.")
+ end
+ end
+ end
+end
diff --git a/app/workers/clusters/cleanup/app_worker.rb b/app/workers/clusters/cleanup/app_worker.rb
index 1eedf510ba1..8b2fddd3164 100644
--- a/app/workers/clusters/cleanup/app_worker.rb
+++ b/app/workers/clusters/cleanup/app_worker.rb
@@ -3,13 +3,16 @@
module Clusters
module Cleanup
class AppWorker
- include ApplicationWorker
- include ClusterQueue
- include ClusterApplications
+ include ClusterCleanupMethods
- # TODO: Merge with https://gitlab.com/gitlab-org/gitlab/merge_requests/16954
- # We're splitting the above MR in smaller chunks to facilitate reviews
- def perform
+ def perform(cluster_id, execution_count = 0)
+ Clusters::Cluster.with_persisted_applications.find_by_id(cluster_id).try do |cluster|
+ break unless cluster.cleanup_uninstalling_applications?
+
+ break exceeded_execution_limit(cluster) if exceeded_execution_limit?(execution_count)
+
+ ::Clusters::Cleanup::AppService.new(cluster, execution_count).execute
+ end
end
end
end
diff --git a/app/workers/clusters/cleanup/project_namespace_worker.rb b/app/workers/clusters/cleanup/project_namespace_worker.rb
index 09f2abf5d8a..8a7fbf0fde7 100644
--- a/app/workers/clusters/cleanup/project_namespace_worker.rb
+++ b/app/workers/clusters/cleanup/project_namespace_worker.rb
@@ -3,13 +3,16 @@
module Clusters
module Cleanup
class ProjectNamespaceWorker
- include ApplicationWorker
- include ClusterQueue
- include ClusterApplications
+ include ClusterCleanupMethods
- # TODO: Merge with https://gitlab.com/gitlab-org/gitlab/merge_requests/16954
- # We're splitting the above MR in smaller chunks to facilitate reviews
- def perform
+ def perform(cluster_id, execution_count = 0)
+ Clusters::Cluster.find_by_id(cluster_id).try do |cluster|
+ break unless cluster.cleanup_removing_project_namespaces?
+
+ break exceeded_execution_limit(cluster) if exceeded_execution_limit?(execution_count)
+
+ Clusters::Cleanup::ProjectNamespaceService.new(cluster, execution_count).execute
+ end
end
end
end
diff --git a/app/workers/clusters/cleanup/service_account_worker.rb b/app/workers/clusters/cleanup/service_account_worker.rb
index fab6318a807..95de56d8ebe 100644
--- a/app/workers/clusters/cleanup/service_account_worker.rb
+++ b/app/workers/clusters/cleanup/service_account_worker.rb
@@ -3,13 +3,14 @@
module Clusters
module Cleanup
class ServiceAccountWorker
- include ApplicationWorker
- include ClusterQueue
- include ClusterApplications
+ include ClusterCleanupMethods
- # TODO: Merge with https://gitlab.com/gitlab-org/gitlab/merge_requests/16954
- # We're splitting the above MR in smaller chunks to facilitate reviews
- def perform
+ def perform(cluster_id)
+ Clusters::Cluster.find_by_id(cluster_id).try do |cluster|
+ break unless cluster.cleanup_removing_service_account?
+
+ Clusters::Cleanup::ServiceAccountService.new(cluster).execute
+ end
end
end
end
diff --git a/app/workers/concerns/cluster_cleanup_methods.rb b/app/workers/concerns/cluster_cleanup_methods.rb
new file mode 100644
index 00000000000..04fa4d69666
--- /dev/null
+++ b/app/workers/concerns/cluster_cleanup_methods.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+# Concern for setting Sidekiq settings for the various GitLab ObjectStorage workers.
+module ClusterCleanupMethods
+ extend ActiveSupport::Concern
+
+ include ApplicationWorker
+ include ClusterQueue
+
+ DEFAULT_EXECUTION_LIMIT = 10
+ ExceededExecutionLimitError = Class.new(StandardError)
+
+ included do
+ worker_has_external_dependencies!
+
+ sidekiq_options retry: 3
+
+ sidekiq_retries_exhausted do |msg, error|
+ cluster_id = msg['args'][0]
+
+ cluster = Clusters::Cluster.find_by_id(cluster_id)
+
+ cluster.make_cleanup_errored!("#{self.class.name} retried too many times") if cluster
+
+ logger = Gitlab::Kubernetes::Logger.build
+
+ logger.error({
+ exception: error,
+ cluster_id: cluster_id,
+ class_name: msg['class'],
+ event: :sidekiq_retries_exhausted,
+ message: msg['error_message']
+ })
+ end
+ end
+
+ private
+
+ # Override this method to customize the execution_limit
+ def execution_limit
+ DEFAULT_EXECUTION_LIMIT
+ end
+
+ def exceeded_execution_limit?(execution_count)
+ execution_count >= execution_limit
+ end
+
+ def logger
+ @logger ||= Gitlab::Kubernetes::Logger.build
+ end
+
+ def exceeded_execution_limit(cluster)
+ log_exceeded_execution_limit_error(cluster)
+
+ cluster.make_cleanup_errored!("#{self.class.name} exceeded the execution limit")
+ end
+
+ def cluster_applications_and_status(cluster)
+ cluster.persisted_applications
+ .map { |application| "#{application.name}:#{application.status_name}" }
+ .join(",")
+ end
+
+ def log_exceeded_execution_limit_error(cluster)
+ logger.error({
+ exception: ExceededExecutionLimitError.name,
+ cluster_id: cluster.id,
+ class_name: self.class.name,
+ cleanup_status: cluster.cleanup_status_name,
+ applications: cluster_applications_and_status(cluster),
+ event: :failed_to_remove_cluster_and_resources,
+ message: "exceeded execution limit of #{execution_limit} tries"
+ })
+ end
+end
diff --git a/app/workers/delete_merged_branches_worker.rb b/app/workers/delete_merged_branches_worker.rb
index 44b3db30d0d..f3d86233c1b 100644
--- a/app/workers/delete_merged_branches_worker.rb
+++ b/app/workers/delete_merged_branches_worker.rb
@@ -15,7 +15,7 @@ class DeleteMergedBranchesWorker
user = User.find(user_id)
begin
- DeleteMergedBranchesService.new(project, user).execute
+ ::Branches::DeleteMergedService.new(project, user).execute
rescue Gitlab::Access::AccessDeniedError
return
end
diff --git a/app/workers/delete_stored_files_worker.rb b/app/workers/delete_stored_files_worker.rb
index 8a693a64055..e1e2f66f573 100644
--- a/app/workers/delete_stored_files_worker.rb
+++ b/app/workers/delete_stored_files_worker.rb
@@ -15,7 +15,7 @@ class DeleteStoredFilesWorker
unless klass
message = "Unknown class '#{class_name}'"
logger.error(message)
- Gitlab::Sentry.track_exception(RuntimeError.new(message))
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(RuntimeError.new(message))
return
end
diff --git a/app/workers/hashed_storage/project_migrate_worker.rb b/app/workers/hashed_storage/project_migrate_worker.rb
index 8c0ec97638f..0174467923d 100644
--- a/app/workers/hashed_storage/project_migrate_worker.rb
+++ b/app/workers/hashed_storage/project_migrate_worker.rb
@@ -14,7 +14,7 @@ module HashedStorage
try_obtain_lease do
project = Project.without_deleted.find_by(id: project_id)
- break unless project
+ break unless project && project.storage_upgradable?
old_disk_path ||= Storage::LegacyProject.new(project).disk_path
diff --git a/app/workers/pages_domain_removal_cron_worker.rb b/app/workers/pages_domain_removal_cron_worker.rb
index b1506831056..07ecde55922 100644
--- a/app/workers/pages_domain_removal_cron_worker.rb
+++ b/app/workers/pages_domain_removal_cron_worker.rb
@@ -11,7 +11,7 @@ class PagesDomainRemovalCronWorker
PagesDomain.for_removal.find_each do |domain|
domain.destroy!
rescue => e
- Raven.capture_exception(e)
+ Gitlab::ErrorTracking.track_exception(e)
end
end
end
diff --git a/app/workers/personal_access_tokens/expiring_worker.rb b/app/workers/personal_access_tokens/expiring_worker.rb
new file mode 100644
index 00000000000..f28109c4583
--- /dev/null
+++ b/app/workers/personal_access_tokens/expiring_worker.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module PersonalAccessTokens
+ class ExpiringWorker
+ include ApplicationWorker
+ include CronjobQueue
+
+ feature_category :authentication_and_authorization
+
+ def perform(*args)
+ notification_service = NotificationService.new
+ 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)
+
+ 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)
+ end
+ end
+ end
+end
diff --git a/app/workers/pipeline_process_worker.rb b/app/workers/pipeline_process_worker.rb
index 2a36ab992e9..200f3619332 100644
--- a/app/workers/pipeline_process_worker.rb
+++ b/app/workers/pipeline_process_worker.rb
@@ -11,7 +11,9 @@ class PipelineProcessWorker
# rubocop: disable CodeReuse/ActiveRecord
def perform(pipeline_id, build_ids = nil)
Ci::Pipeline.find_by(id: pipeline_id).try do |pipeline|
- pipeline.process!(build_ids)
+ Ci::ProcessPipelineService
+ .new(pipeline)
+ .execute(build_ids)
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb
index 8b4d66ae493..36af51d859e 100644
--- a/app/workers/process_commit_worker.rb
+++ b/app/workers/process_commit_worker.rb
@@ -55,16 +55,15 @@ class ProcessCommitWorker
end
end
- # rubocop: disable CodeReuse/ActiveRecord
def update_issue_metrics(commit, author)
mentioned_issues = commit.all_references(author).issues
return if mentioned_issues.empty?
- Issue::Metrics.where(issue_id: mentioned_issues.map(&:id), first_mentioned_in_commit_at: nil)
+ Issue::Metrics.for_issues(mentioned_issues)
+ .with_first_mention_not_earlier_than(commit.committed_date)
.update_all(first_mentioned_in_commit_at: commit.committed_date)
end
- # rubocop: enable CodeReuse/ActiveRecord
def build_commit(project, hash)
date_suffix = '_date'
diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb
index 853f774875a..f8f8a2fe7ae 100644
--- a/app/workers/run_pipeline_schedule_worker.rb
+++ b/app/workers/run_pipeline_schedule_worker.rb
@@ -38,10 +38,10 @@ class RunPipelineScheduleWorker
Rails.logger.error "Failed to create a scheduled pipeline. " \
"schedule_id: #{schedule.id} message: #{error.message}"
- Gitlab::Sentry
- .track_exception(error,
+ Gitlab::ErrorTracking
+ .track_and_raise_for_dev_exception(error,
issue_url: 'https://gitlab.com/gitlab-org/gitlab-foss/issues/41231',
- extra: { schedule_id: schedule.id })
+ schedule_id: schedule.id)
end
# rubocop:enable Gitlab/RailsLogger
diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb
index b116965d105..d08cea9e494 100644
--- a/app/workers/stuck_ci_jobs_worker.rb
+++ b/app/workers/stuck_ci_jobs_worker.rb
@@ -80,12 +80,12 @@ class StuckCiJobsWorker
end
def track_exception_for_build(ex, build)
- Gitlab::Sentry.track_acceptable_exception(ex, extra: {
+ Gitlab::ErrorTracking.track_exception(ex,
build_id: build.id,
build_name: build.name,
build_stage: build.stage,
pipeline_id: build.pipeline_id,
project_id: build.project_id
- })
+ )
end
end