summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorMike Lewis <mlewis@gitlab.com>2019-06-07 20:13:17 +0000
committerMike Lewis <mlewis@gitlab.com>2019-06-07 20:13:17 +0000
commit99df0218f82b851b017bd0eea1b8351dc89df6ed (patch)
treeb01f884fbd1418dd5465fc1741f1620061ae8c5c /app
parent3eea6906747d10bea501426febaf15d2c209e06a (diff)
parente07b2b277f79bc25cdce22ca2defba1ba80791aa (diff)
downloadgitlab-ce-99df0218f82b851b017bd0eea1b8351dc89df6ed.tar.gz
Merge branch 'master' into 'docs/fix-example-dot-net'
# Conflicts: # doc/user/project/clusters/serverless/index.md
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/favicon-yellow.pngbin1667 -> 1481 bytes
-rw-r--r--app/assets/images/select2-spinner.gifbin0 -> 1849 bytes
-rw-r--r--app/assets/images/select2.pngbin0 -> 613 bytes
-rw-r--r--app/assets/images/select2x2.pngbin0 -> 845 bytes
-rw-r--r--app/assets/javascripts/api.js26
-rw-r--r--app/assets/javascripts/avatar_picker.js (renamed from app/assets/javascripts/group_avatar.js)9
-rw-r--r--app/assets/javascripts/awards_handler.js6
-rw-r--r--app/assets/javascripts/batch_comments/mixins/resolved_status.js15
-rw-r--r--app/assets/javascripts/behaviors/copy_to_clipboard.js5
-rw-r--r--app/assets/javascripts/behaviors/markdown/copy_as_gfm.js10
-rw-r--r--app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js3
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_gfm.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js19
-rw-r--r--app/assets/javascripts/behaviors/preview_markdown.js4
-rw-r--r--app/assets/javascripts/behaviors/quick_submit.js5
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js4
-rw-r--r--app/assets/javascripts/blob/balsamiq_viewer.js3
-rw-r--r--app/assets/javascripts/blob/blob_file_dropzone.js5
-rw-r--r--app/assets/javascripts/blob/sketch/index.js5
-rw-r--r--app/assets/javascripts/blob/template_selectors/dockerfile_selector.js3
-rw-r--r--app/assets/javascripts/blob/viewer/index.js14
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js2
-rw-r--r--app/assets/javascripts/boards/boards_util.js7
-rw-r--r--app/assets/javascripts/boards/components/board.js5
-rw-r--r--app/assets/javascripts/boards/components/board_blank_state.vue13
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_delete.js3
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue13
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue10
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js33
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.vue91
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner_scoped_label.vue45
-rw-r--r--app/assets/javascripts/boards/components/issue_due_date.vue8
-rw-r--r--app/assets/javascripts/boards/components/issue_time_estimate.vue2
-rw-r--r--app/assets/javascripts/boards/components/modal/empty_state.vue4
-rw-r--r--app/assets/javascripts/boards/components/modal/header.vue6
-rw-r--r--app/assets/javascripts/boards/components/modal/index.vue15
-rw-r--r--app/assets/javascripts/boards/components/modal/list.vue4
-rw-r--r--app/assets/javascripts/boards/components/new_list_dropdown.js9
-rw-r--r--app/assets/javascripts/boards/components/sidebar/remove_issue.vue2
-rw-r--r--app/assets/javascripts/boards/filtered_search_boards.js4
-rw-r--r--app/assets/javascripts/boards/index.js64
-rw-r--r--app/assets/javascripts/boards/mixins/issue_card_inner.js5
-rw-r--r--app/assets/javascripts/boards/models/assignee.js (renamed from app/assets/javascripts/vue_shared/models/assignee.js)0
-rw-r--r--app/assets/javascripts/boards/models/issue.js30
-rw-r--r--app/assets/javascripts/boards/models/label.js11
-rw-r--r--app/assets/javascripts/boards/models/list.js28
-rw-r--r--app/assets/javascripts/boards/models/milestone.js11
-rw-r--r--app/assets/javascripts/boards/services/board_service.js3
-rw-r--r--app/assets/javascripts/boards/stores/actions.js65
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js62
-rw-r--r--app/assets/javascripts/boards/stores/boards_store_ee.js5
-rw-r--r--app/assets/javascripts/boards/stores/index.js14
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js21
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js91
-rw-r--r--app/assets/javascripts/boards/stores/state.js3
-rw-r--r--app/assets/javascripts/branches/branches_delete_modal.js2
-rw-r--r--app/assets/javascripts/breakpoints.js3
-rw-r--r--app/assets/javascripts/ci_variable_list/ajax_variable_list.js11
-rw-r--r--app/assets/javascripts/ci_variable_list/ci_variable_list.js40
-rw-r--r--app/assets/javascripts/ci_variable_list/native_form_variable_list.js1
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js141
-rw-r--r--app/assets/javascripts/clusters/components/application_row.vue309
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue276
-rw-r--r--app/assets/javascripts/clusters/components/knative_domain_editor.vue150
-rw-r--r--app/assets/javascripts/clusters/components/uninstall_application_button.vue33
-rw-r--r--app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue74
-rw-r--r--app/assets/javascripts/clusters/constants.js28
-rw-r--r--app/assets/javascripts/clusters/mixins/track_uninstall_button_click.js5
-rw-r--r--app/assets/javascripts/clusters/services/application_state_machine.js174
-rw-r--r--app/assets/javascripts/clusters/services/clusters_service.js11
-rw-r--r--app/assets/javascripts/clusters/stores/clusters_store.js123
-rw-r--r--app/assets/javascripts/commit/image_file.js44
-rw-r--r--app/assets/javascripts/commons/bootstrap.js60
-rw-r--r--app/assets/javascripts/commons/jquery.js4
-rw-r--r--app/assets/javascripts/commons/polyfills.js31
-rw-r--r--app/assets/javascripts/compare_autocomplete.js2
-rw-r--r--app/assets/javascripts/contextual_sidebar.js58
-rw-r--r--app/assets/javascripts/create_item_dropdown.js2
-rw-r--r--app/assets/javascripts/create_label.js9
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js6
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js3
-rw-r--r--app/assets/javascripts/diff_notes/components/comment_resolve_btn.js9
-rw-r--r--app/assets/javascripts/diff_notes/components/diff_note_avatars.js3
-rw-r--r--app/assets/javascripts/diff_notes/components/jump_to_discussion.js5
-rw-r--r--app/assets/javascripts/diff_notes/components/resolve_btn.js12
-rw-r--r--app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js5
-rw-r--r--app/assets/javascripts/diff_notes/services/resolve.js4
-rw-r--r--app/assets/javascripts/diffs/components/app.vue74
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue5
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue22
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue39
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue46
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue157
-rw-r--r--app/assets/javascripts/diffs/components/diff_gutter_avatars.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_gutter_content.vue6
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue31
-rw-r--r--app/assets/javascripts/diffs/components/diff_table_cell.vue24
-rw-r--r--app/assets/javascripts/diffs/components/edit_button.vue27
-rw-r--r--app/assets/javascripts/diffs/components/image_diff_overlay.vue9
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_comment_row.vue2
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_table_row.vue15
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_view.vue9
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue4
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_table_row.vue4
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_view.vue52
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue59
-rw-r--r--app/assets/javascripts/diffs/constants.js15
-rw-r--r--app/assets/javascripts/diffs/index.js2
-rw-r--r--app/assets/javascripts/diffs/mixins/draft_comments.js10
-rw-r--r--app/assets/javascripts/diffs/mixins/image_diff.js13
-rw-r--r--app/assets/javascripts/diffs/store/actions.js164
-rw-r--r--app/assets/javascripts/diffs/store/getters.js7
-rw-r--r--app/assets/javascripts/diffs/store/modules/diff_state.js8
-rw-r--r--app/assets/javascripts/diffs/store/mutation_types.js10
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js58
-rw-r--r--app/assets/javascripts/diffs/store/utils.js47
-rw-r--r--app/assets/javascripts/diffs/workers/tree_worker.js5
-rw-r--r--app/assets/javascripts/dirty_submit/dirty_submit_form.js13
-rw-r--r--app/assets/javascripts/dropzone_input.js15
-rw-r--r--app/assets/javascripts/due_date_select.js4
-rw-r--r--app/assets/javascripts/emoji/no_emoji_validator.js44
-rw-r--r--app/assets/javascripts/environments/components/confirm_rollback_modal.vue108
-rw-r--r--app/assets/javascripts/environments/components/container.vue20
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue57
-rw-r--r--app/assets/javascripts/environments/components/environment_rollback.vue42
-rw-r--r--app/assets/javascripts/environments/components/environment_terminal_button.vue2
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue17
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue79
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_bundle.js3
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_view.vue11
-rw-r--r--app/assets/javascripts/environments/index.js3
-rw-r--r--app/assets/javascripts/environments/mixins/canary_callout_mixin.js5
-rw-r--r--app/assets/javascripts/environments/mixins/container_mixin.js29
-rw-r--r--app/assets/javascripts/environments/mixins/environment_item_mixin.js13
-rw-r--r--app/assets/javascripts/environments/mixins/environments_app_mixin.js32
-rw-r--r--app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js29
-rw-r--r--app/assets/javascripts/environments/mixins/environments_mixin.js40
-rw-r--r--app/assets/javascripts/environments/mixins/environments_table_mixin.js10
-rw-r--r--app/assets/javascripts/environments/stores/environments_store.js25
-rw-r--r--app/assets/javascripts/environments/stores/helpers.js8
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue26
-rw-r--r--app/assets/javascripts/error_tracking/store/actions.js17
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/app.vue129
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue91
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue82
-rw-r--r--app/assets/javascripts/error_tracking_settings/index.js27
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/actions.js91
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/getters.js44
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/index.js16
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/mutation_types.js11
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/mutations.js61
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/state.js12
-rw-r--r--app/assets/javascripts/error_tracking_settings/utils.js18
-rw-r--r--app/assets/javascripts/event_tracking/notes.js1
-rw-r--r--app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js30
-rw-r--r--app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js8
-rw-r--r--app/assets/javascripts/filtered_search/available_dropdown_mappings.js164
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_ajax_filter.js68
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_emoji.js3
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_non_user.js3
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_user.js91
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js108
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js30
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_token_keys.js21
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js153
-rw-r--r--app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js21
-rw-r--r--app/assets/javascripts/filtered_search/recent_searches_storage_keys.js4
-rw-r--r--app/assets/javascripts/filtered_search/services/recent_searches_service_error.js4
-rw-r--r--app/assets/javascripts/filtered_search/visual_token_value.js117
-rw-r--r--app/assets/javascripts/fly_out_nav.js3
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue57
-rw-r--r--app/assets/javascripts/frequent_items/store/actions.js2
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js16
-rw-r--r--app/assets/javascripts/gl_dropdown.js40
-rw-r--r--app/assets/javascripts/gl_field_error.js3
-rw-r--r--app/assets/javascripts/gl_field_errors.js2
-rw-r--r--app/assets/javascripts/gl_form.js6
-rw-r--r--app/assets/javascripts/gpg_badges.js2
-rw-r--r--app/assets/javascripts/groups/components/app.vue2
-rw-r--r--app/assets/javascripts/groups/transfer_dropdown.js3
-rw-r--r--app/assets/javascripts/groups_select.js3
-rw-r--r--app/assets/javascripts/helpers/monitor_helper.js17
-rw-r--r--app/assets/javascripts/ide/components/activity_bar.vue2
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/actions.vue33
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/message_field.vue1
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue43
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue7
-rw-r--r--app/assets/javascripts/ide/components/file_row_extra.vue5
-rw-r--r--app/assets/javascripts/ide/components/ide.vue40
-rw-r--r--app/assets/javascripts/ide/components/ide_tree_list.vue19
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/index.vue25
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue59
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/upload.vue2
-rw-r--r--app/assets/javascripts/ide/components/pipelines/list.vue14
-rw-r--r--app/assets/javascripts/ide/components/preview/clientside.vue2
-rw-r--r--app/assets/javascripts/ide/components/repo_commit_section.vue4
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue10
-rw-r--r--app/assets/javascripts/ide/constants.js7
-rw-r--r--app/assets/javascripts/ide/ide_router.js13
-rw-r--r--app/assets/javascripts/ide/lib/editor_options.js2
-rw-r--r--app/assets/javascripts/ide/lib/files.js119
-rw-r--r--app/assets/javascripts/ide/lib/keymap.json8
-rw-r--r--app/assets/javascripts/ide/services/index.js3
-rw-r--r--app/assets/javascripts/ide/stores/actions.js133
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js12
-rw-r--r--app/assets/javascripts/ide/stores/actions/merge_request.js33
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js118
-rw-r--r--app/assets/javascripts/ide/stores/actions/tree.js64
-rw-r--r--app/assets/javascripts/ide/stores/getters.js20
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js66
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/constants.js10
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/getters.js19
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/mutation_types.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/mutations.js14
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/state.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/file_templates/actions.js25
-rw-r--r--app/assets/javascripts/ide/stores/modules/file_templates/getters.js5
-rw-r--r--app/assets/javascripts/ide/stores/modules/file_templates/mutations.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/getters.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/mutations.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/state.js1
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js22
-rw-r--r--app/assets/javascripts/ide/stores/mutations/branch.js6
-rw-r--r--app/assets/javascripts/ide/stores/mutations/merge_request.js3
-rw-r--r--app/assets/javascripts/ide/stores/mutations/project.js5
-rw-r--r--app/assets/javascripts/ide/stores/mutations/tree.js14
-rw-r--r--app/assets/javascripts/ide/stores/utils.js47
-rw-r--r--app/assets/javascripts/ide/stores/workers/files_decorator_worker.js100
-rw-r--r--app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js2
-rw-r--r--app/assets/javascripts/image_diff/image_diff.js4
-rw-r--r--app/assets/javascripts/image_diff/view_types.js2
-rw-r--r--app/assets/javascripts/import_projects/components/import_projects_table.vue2
-rw-r--r--app/assets/javascripts/import_projects/components/provider_repo_table_row.vue2
-rw-r--r--app/assets/javascripts/import_projects/index.js3
-rw-r--r--app/assets/javascripts/import_projects/store/getters.js6
-rw-r--r--app/assets/javascripts/import_projects/store/index.js15
-rw-r--r--app/assets/javascripts/integrations/integration_settings_form.js9
-rw-r--r--app/assets/javascripts/issuable_bulk_update_actions.js6
-rw-r--r--app/assets/javascripts/issuable_form.js2
-rw-r--r--app/assets/javascripts/issuable_index.js10
-rw-r--r--app/assets/javascripts/issue.js29
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue22
-rw-r--r--app/assets/javascripts/issue_show/components/description.vue4
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description.vue1
-rw-r--r--app/assets/javascripts/issue_show/components/fields/title.vue2
-rw-r--r--app/assets/javascripts/issue_show/components/form.vue47
-rw-r--r--app/assets/javascripts/issue_show/components/title.vue3
-rw-r--r--app/assets/javascripts/issue_show/index.js7
-rw-r--r--app/assets/javascripts/issue_show/utils/parse_data.js15
-rw-r--r--app/assets/javascripts/issue_status_select.js3
-rw-r--r--app/assets/javascripts/jobs/components/commit_block.vue2
-rw-r--r--app/assets/javascripts/jobs/components/empty_state.vue2
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue43
-rw-r--r--app/assets/javascripts/jobs/components/job_container_item.vue8
-rw-r--r--app/assets/javascripts/jobs/components/job_log_controllers.vue6
-rw-r--r--app/assets/javascripts/jobs/components/sidebar.vue3
-rw-r--r--app/assets/javascripts/jobs/components/stages_dropdown.vue56
-rw-r--r--app/assets/javascripts/jobs/components/trigger_block.vue2
-rw-r--r--app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue30
-rw-r--r--app/assets/javascripts/jobs/index.js1
-rw-r--r--app/assets/javascripts/jobs/store/actions.js26
-rw-r--r--app/assets/javascripts/jobs/store/getters.js14
-rw-r--r--app/assets/javascripts/jobs/store/mutation_types.js4
-rw-r--r--app/assets/javascripts/jobs/store/mutations.js19
-rw-r--r--app/assets/javascripts/jobs/store/state.js1
-rw-r--r--app/assets/javascripts/label_manager.js22
-rw-r--r--app/assets/javascripts/labels_select.js143
-rw-r--r--app/assets/javascripts/lib/graphql.js31
-rw-r--r--app/assets/javascripts/lib/utils/accessor.js2
-rw-r--r--app/assets/javascripts/lib/utils/autosave.js32
-rw-r--r--app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js9
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js54
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js47
-rw-r--r--app/assets/javascripts/lib/utils/highlight.js44
-rw-r--r--app/assets/javascripts/lib/utils/http_status.js1
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js34
-rw-r--r--app/assets/javascripts/lib/utils/simple_poll.js4
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js6
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js40
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js38
-rw-r--r--app/assets/javascripts/lib/utils/webpack.js10
-rw-r--r--app/assets/javascripts/locale/index.js2
-rw-r--r--app/assets/javascripts/main.js23
-rw-r--r--app/assets/javascripts/members.js27
-rw-r--r--app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js2
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_store.js15
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js3
-rw-r--r--app/assets/javascripts/merge_request_tabs.js7
-rw-r--r--app/assets/javascripts/milestone.js3
-rw-r--r--app/assets/javascripts/milestone_select.js15
-rw-r--r--app/assets/javascripts/mini_pipeline_graph_dropdown.js3
-rw-r--r--app/assets/javascripts/mirrors/mirror_repos.js2
-rw-r--r--app/assets/javascripts/mirrors/ssh_mirror.js118
-rw-r--r--app/assets/javascripts/monitoring/components/charts/area.vue185
-rw-r--r--app/assets/javascripts/monitoring/components/charts/single_stat.vue37
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue258
-rw-r--r--app/assets/javascripts/monitoring/constants.js29
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle.js7
-rw-r--r--app/assets/javascripts/monitoring/services/monitoring_service.js75
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js117
-rw-r--r--app/assets/javascripts/monitoring/stores/index.js21
-rw-r--r--app/assets/javascripts/monitoring/stores/monitoring_store.js75
-rw-r--r--app/assets/javascripts/monitoring/stores/mutation_types.js12
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js45
-rw-r--r--app/assets/javascripts/monitoring/stores/state.js12
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js83
-rw-r--r--app/assets/javascripts/monitoring/utils.js33
-rw-r--r--app/assets/javascripts/mr_notes/index.js69
-rw-r--r--app/assets/javascripts/mr_notes/init_notes.js70
-rw-r--r--app/assets/javascripts/mr_notes/stores/getters.js2
-rw-r--r--app/assets/javascripts/mr_popover/components/mr_popover.vue110
-rw-r--r--app/assets/javascripts/mr_popover/constants.js12
-rw-r--r--app/assets/javascripts/mr_popover/index.js67
-rw-r--r--app/assets/javascripts/mr_popover/queries/merge_request.graphql14
-rw-r--r--app/assets/javascripts/namespace_select.js3
-rw-r--r--app/assets/javascripts/namespaces/leave_by_url.js22
-rw-r--r--app/assets/javascripts/notebook/cells/output/index.vue29
-rw-r--r--app/assets/javascripts/notes.js83
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue18
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue10
-rw-r--r--app/assets/javascripts/notes/components/discussion_actions.vue58
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue14
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter.vue28
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter_note.vue52
-rw-r--r--app/assets/javascripts/notes/components/discussion_locked_widget.vue20
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes.vue155
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue31
-rw-r--r--app/assets/javascripts/notes/components/note_actions/reply_button.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_awards_list.vue6
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue17
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue176
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue50
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue273
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue37
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue10
-rw-r--r--app/assets/javascripts/notes/constants.js7
-rw-r--r--app/assets/javascripts/notes/discussion_filters.js13
-rw-r--r--app/assets/javascripts/notes/index.js14
-rw-r--r--app/assets/javascripts/notes/mixins/autosave.js3
-rw-r--r--app/assets/javascripts/notes/mixins/diff_line_note_form.js10
-rw-r--r--app/assets/javascripts/notes/mixins/draft.js8
-rw-r--r--app/assets/javascripts/notes/mixins/get_discussion.js7
-rw-r--r--app/assets/javascripts/notes/mixins/issuable_state.js15
-rw-r--r--app/assets/javascripts/notes/mixins/note_form.js24
-rw-r--r--app/assets/javascripts/notes/mixins/resolvable.js4
-rw-r--r--app/assets/javascripts/notes/stores/actions.js54
-rw-r--r--app/assets/javascripts/notes/stores/getters.js5
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js4
-rw-r--r--app/assets/javascripts/notes/stores/utils.js14
-rw-r--r--app/assets/javascripts/notifications_dropdown.js4
-rw-r--r--app/assets/javascripts/operation_settings/components/external_dashboard.vue67
-rw-r--r--app/assets/javascripts/operation_settings/index.js23
-rw-r--r--app/assets/javascripts/operation_settings/store/actions.js38
-rw-r--r--app/assets/javascripts/operation_settings/store/index.js16
-rw-r--r--app/assets/javascripts/operation_settings/store/mutation_types.js3
-rw-r--r--app/assets/javascripts/operation_settings/store/mutations.js7
-rw-r--r--app/assets/javascripts/operation_settings/store/state.js5
-rw-r--r--app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js2
-rw-r--r--app/assets/javascripts/pages/admin/clusters/destroy/index.js5
-rw-r--r--app/assets/javascripts/pages/admin/clusters/edit/index.js5
-rw-r--r--app/assets/javascripts/pages/admin/clusters/index.js21
-rw-r--r--app/assets/javascripts/pages/admin/clusters/index/index.js6
-rw-r--r--app/assets/javascripts/pages/admin/clusters/show/index.js5
-rw-r--r--app/assets/javascripts/pages/admin/groups/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/groups/new/index.js4
-rw-r--r--app/assets/javascripts/pages/dashboard/merge_requests/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/clusters/index/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/details/index.js5
-rw-r--r--app/assets/javascripts/pages/groups/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/issues/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/labels/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/labels/new/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/merge_requests/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js1
-rw-r--r--app/assets/javascripts/pages/groups/shared/group_details.js31
-rw-r--r--app/assets/javascripts/pages/groups/shared/group_tabs.js (renamed from app/assets/javascripts/pages/groups/show/group_tabs.js)0
-rw-r--r--app/assets/javascripts/pages/groups/show/index.js29
-rw-r--r--app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue8
-rw-r--r--app/assets/javascripts/pages/profiles/keys/index.js2
-rw-r--r--app/assets/javascripts/pages/profiles/show/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/clusters/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/edit/index.js21
-rw-r--r--app/assets/javascripts/pages/projects/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/issues/edit/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/issues/new/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js2
-rw-r--r--app/assets/javascripts/pages/projects/labels/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/labels/new/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js2
-rw-r--r--app/assets/javascripts/pages/projects/pages_domains/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pages_domains/form.js43
-rw-r--r--app/assets/javascripts/pages/projects/pages_domains/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue2
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js77
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js8
-rw-r--r--app/assets/javascripts/pages/projects/project.js12
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js1
-rw-r--r--app/assets/javascripts/pages/projects/settings/operations/show/index.js9
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue5
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/constants.js10
-rw-r--r--app/assets/javascripts/pages/projects/show/index.js10
-rw-r--r--app/assets/javascripts/pages/projects/tree/show/index.js8
-rw-r--r--app/assets/javascripts/pages/search/show/search.js7
-rw-r--r--app/assets/javascripts/pages/sessions/new/index.js2
-rw-r--r--app/assets/javascripts/pages/sessions/new/length_validator.js32
-rw-r--r--app/assets/javascripts/pages/users/activity_calendar.js63
-rw-r--r--app/assets/javascripts/pages/users/user_tabs.js1
-rw-r--r--app/assets/javascripts/pdf/index.vue6
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue34
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue2
-rw-r--r--app/assets/javascripts/performance_bar/components/request_selector.vue7
-rw-r--r--app/assets/javascripts/persistent_user_callout.js8
-rw-r--r--app/assets/javascripts/pipelines/components/graph/action_component.vue14
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue7
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_name_component.vue5
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue30
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue97
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_triggerer.vue35
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_url.vue28
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_table.vue53
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_table_row.vue12
-rw-r--r--app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js16
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines.js6
-rw-r--r--app/assets/javascripts/pipelines/mixins/stage_column_mixin.js7
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js18
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_mediator.js16
-rw-r--r--app/assets/javascripts/pipelines/services/pipeline_service.js4
-rw-r--r--app/assets/javascripts/pipelines/stores/pipeline_store.js1
-rw-r--r--app/assets/javascripts/profile/account/index.js2
-rw-r--r--app/assets/javascripts/profile/profile.js12
-rw-r--r--app/assets/javascripts/project_label_subscription.js4
-rw-r--r--app/assets/javascripts/project_select.js5
-rw-r--r--app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue2
-rw-r--r--app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js2
-rw-r--r--app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js6
-rw-r--r--app/assets/javascripts/projects/project_new.js47
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js4
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js3
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_edit.js3
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js4
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_create.js3
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_edit.js7
-rw-r--r--app/assets/javascripts/raven/index.js7
-rw-r--r--app/assets/javascripts/raven/raven_config.js11
-rw-r--r--app/assets/javascripts/registry/stores/mutations.js4
-rw-r--r--app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue118
-rw-r--r--app/assets/javascripts/related_merge_requests/index.js24
-rw-r--r--app/assets/javascripts/related_merge_requests/store/actions.js37
-rw-r--r--app/assets/javascripts/related_merge_requests/store/index.js14
-rw-r--r--app/assets/javascripts/related_merge_requests/store/mutation_types.js4
-rw-r--r--app/assets/javascripts/related_merge_requests/store/mutations.js19
-rw-r--r--app/assets/javascripts/related_merge_requests/store/state.js7
-rw-r--r--app/assets/javascripts/releases/components/release_block.vue4
-rw-r--r--app/assets/javascripts/releases/store/actions.js2
-rw-r--r--app/assets/javascripts/reports/components/issue_status_icon.vue7
-rw-r--r--app/assets/javascripts/reports/components/issues_list.vue22
-rw-r--r--app/assets/javascripts/reports/components/report_item.vue17
-rw-r--r--app/assets/javascripts/reports/components/report_section.vue50
-rw-r--r--app/assets/javascripts/reports/constants.js6
-rw-r--r--app/assets/javascripts/reports/store/state.js5
-rw-r--r--app/assets/javascripts/reports/store/utils.js6
-rw-r--r--app/assets/javascripts/repository/components/app.vue3
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue61
-rw-r--r--app/assets/javascripts/repository/components/table/header.vue9
-rw-r--r--app/assets/javascripts/repository/components/table/index.vue145
-rw-r--r--app/assets/javascripts/repository/components/table/parent_row.vue37
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue77
-rw-r--r--app/assets/javascripts/repository/fragmentTypes.json1
-rw-r--r--app/assets/javascripts/repository/graphql.js43
-rw-r--r--app/assets/javascripts/repository/index.js59
-rw-r--r--app/assets/javascripts/repository/mixins/get_ref.js14
-rw-r--r--app/assets/javascripts/repository/pages/index.vue18
-rw-r--r--app/assets/javascripts/repository/pages/tree.vue20
-rw-r--r--app/assets/javascripts/repository/queries/getFiles.graphql57
-rw-r--r--app/assets/javascripts/repository/queries/getProjectPath.graphql3
-rw-r--r--app/assets/javascripts/repository/queries/getProjectShortPath.graphql3
-rw-r--r--app/assets/javascripts/repository/queries/getRef.graphql3
-rw-r--r--app/assets/javascripts/repository/router.js29
-rw-r--r--app/assets/javascripts/repository/utils/icon.js99
-rw-r--r--app/assets/javascripts/repository/utils/title.js9
-rw-r--r--app/assets/javascripts/right_sidebar.js11
-rw-r--r--app/assets/javascripts/search_autocomplete.js11
-rw-r--r--app/assets/javascripts/serverless/components/area.vue146
-rw-r--r--app/assets/javascripts/serverless/components/function_details.vue54
-rw-r--r--app/assets/javascripts/serverless/components/function_row.vue5
-rw-r--r--app/assets/javascripts/serverless/components/functions.vue66
-rw-r--r--app/assets/javascripts/serverless/components/missing_prometheus.vue63
-rw-r--r--app/assets/javascripts/serverless/components/url.vue2
-rw-r--r--app/assets/javascripts/serverless/constants.js7
-rw-r--r--app/assets/javascripts/serverless/serverless_bundle.js126
-rw-r--r--app/assets/javascripts/serverless/services/get_functions_service.js11
-rw-r--r--app/assets/javascripts/serverless/store/actions.js128
-rw-r--r--app/assets/javascripts/serverless/store/getters.js10
-rw-r--r--app/assets/javascripts/serverless/store/index.js18
-rw-r--r--app/assets/javascripts/serverless/store/mutation_types.js11
-rw-r--r--app/assets/javascripts/serverless/store/mutations.js49
-rw-r--r--app/assets/javascripts/serverless/store/state.js14
-rw-r--r--app/assets/javascripts/serverless/stores/serverless_details_store.js11
-rw-r--r--app/assets/javascripts/serverless/stores/serverless_store.js29
-rw-r--r--app/assets/javascripts/serverless/utils.js23
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue12
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.vue39
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/todo.vue5
-rw-r--r--app/assets/javascripts/sidebar/lib/sidebar_move_issue.js7
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js3
-rw-r--r--app/assets/javascripts/snippet/snippet_embed.js6
-rw-r--r--app/assets/javascripts/star.js2
-rw-r--r--app/assets/javascripts/subscription_select.js3
-rw-r--r--app/assets/javascripts/templates/issuable_template_selector.js3
-rw-r--r--app/assets/javascripts/terminal/terminal.js4
-rw-r--r--app/assets/javascripts/test_utils/index.js4
-rw-r--r--app/assets/javascripts/u2f/error.js12
-rw-r--r--app/assets/javascripts/usage_ping_consent.js3
-rw-r--r--app/assets/javascripts/users_select.js65
-rw-r--r--app/assets/javascripts/validators/input_validator.js34
-rw-r--r--app/assets/javascripts/visual_review_toolbar/index.js2
-rw-r--r--app/assets/javascripts/visual_review_toolbar/styles/toolbar.css149
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment.vue45
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue46
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue62
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue112
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue38
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue111
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js13
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue74
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js20
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_pipeline_link.vue32
-rw-r--r--app/assets/javascripts/vue_shared/components/clipboard_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.vue72
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue89
-rw-r--r--app/assets/javascripts/vue_shared/components/empty_component.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue20
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue20
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_warning.vue45
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue141
-rw-r--r--app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js20
-rw-r--r--app/assets/javascripts/vue_shared/components/loading_button.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue42
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue20
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue32
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue40
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue27
-rw-r--r--app/assets/javascripts/vue_shared/components/modal_copy_button.vue121
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/pikaday.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue74
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue103
-rw-r--r--app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue40
-rw-r--r--app/assets/javascripts/vue_shared/components/select2_select.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue19
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue56
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue47
-rw-r--r--app/assets/javascripts/vue_shared/components/svg_gradient.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/table_pagination.vue39
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue25
-rw-r--r--app/assets/javascripts/vue_shared/directives/tooltip.js1
-rw-r--r--app/assets/javascripts/vue_shared/mixins/is_ee.js10
-rw-r--r--app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js217
-rw-r--r--app/assets/javascripts/vue_shared/models/label.js13
-rw-r--r--app/assets/stylesheets/application.scss42
-rw-r--r--app/assets/stylesheets/bootstrap_migration.scss14
-rw-r--r--app/assets/stylesheets/components/avatar.scss202
-rw-r--r--app/assets/stylesheets/components/dashboard_skeleton.scss77
-rw-r--r--app/assets/stylesheets/components/popover.scss63
-rw-r--r--app/assets/stylesheets/components/project_list_item.scss24
-rw-r--r--app/assets/stylesheets/components/related_items_list.scss416
-rw-r--r--app/assets/stylesheets/components/toast.scss53
-rw-r--r--app/assets/stylesheets/errors.scss2
-rw-r--r--app/assets/stylesheets/framework.scss3
-rw-r--r--app/assets/stylesheets/framework/animations.scss52
-rw-r--r--app/assets/stylesheets/framework/asciidoctor.scss2
-rw-r--r--app/assets/stylesheets/framework/avatar.scss160
-rw-r--r--app/assets/stylesheets/framework/awards.scss16
-rw-r--r--app/assets/stylesheets/framework/blank.scss28
-rw-r--r--app/assets/stylesheets/framework/blocks.scss29
-rw-r--r--app/assets/stylesheets/framework/buttons.scss46
-rw-r--r--app/assets/stylesheets/framework/callout.scss4
-rw-r--r--app/assets/stylesheets/framework/ci_variable_list.scss2
-rw-r--r--app/assets/stylesheets/framework/common.scss139
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss133
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss8
-rw-r--r--app/assets/stylesheets/framework/emojis.scss2
-rw-r--r--app/assets/stylesheets/framework/files.scss39
-rw-r--r--app/assets/stylesheets/framework/filters.scss24
-rw-r--r--app/assets/stylesheets/framework/flash.scss29
-rw-r--r--app/assets/stylesheets/framework/forms.scss125
-rw-r--r--app/assets/stylesheets/framework/gfm.scss2
-rw-r--r--app/assets/stylesheets/framework/header.scss80
-rw-r--r--app/assets/stylesheets/framework/highlight.scss3
-rw-r--r--app/assets/stylesheets/framework/icons.scss5
-rw-r--r--app/assets/stylesheets/framework/lists.scss20
-rw-r--r--app/assets/stylesheets/framework/logo.scss35
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss123
-rw-r--r--app/assets/stylesheets/framework/mixins.scss85
-rw-r--r--app/assets/stylesheets/framework/modal.scss16
-rw-r--r--app/assets/stylesheets/framework/notes.scss2
-rw-r--r--app/assets/stylesheets/framework/page_title.scss4
-rw-r--r--app/assets/stylesheets/framework/panels.scss1
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss79
-rw-r--r--app/assets/stylesheets/framework/selects.scss12
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss52
-rw-r--r--app/assets/stylesheets/framework/snippets.scss4
-rw-r--r--app/assets/stylesheets/framework/sortable.scss92
-rw-r--r--app/assets/stylesheets/framework/spinner.scss51
-rw-r--r--app/assets/stylesheets/framework/system_messages.scss3
-rw-r--r--app/assets/stylesheets/framework/tables.scss1
-rw-r--r--app/assets/stylesheets/framework/terms.scss1
-rw-r--r--app/assets/stylesheets/framework/timeline.scss5
-rw-r--r--app/assets/stylesheets/framework/toggle.scss14
-rw-r--r--app/assets/stylesheets/framework/typography.scss142
-rw-r--r--app/assets/stylesheets/framework/variables.scss181
-rw-r--r--app/assets/stylesheets/framework/variables_overrides.scss11
-rw-r--r--app/assets/stylesheets/framework/vue_transitions.scss6
-rw-r--r--app/assets/stylesheets/framework/wells.scss4
-rw-r--r--app/assets/stylesheets/highlight/common.scss2
-rw-r--r--app/assets/stylesheets/highlight/embedded.scss2
-rw-r--r--app/assets/stylesheets/highlight/white_base.scss74
-rw-r--r--app/assets/stylesheets/mailers/highlighted_diff_email.scss67
-rw-r--r--app/assets/stylesheets/notify.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/_ide_mixins.scss14
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss10
-rw-r--r--app/assets/stylesheets/page_bundles/xterm.scss6
-rw-r--r--app/assets/stylesheets/pages/boards.scss215
-rw-r--r--app/assets/stylesheets/pages/builds.scss59
-rw-r--r--app/assets/stylesheets/pages/clusters.scss2
-rw-r--r--app/assets/stylesheets/pages/commits.scss56
-rw-r--r--app/assets/stylesheets/pages/cycle_analytics.scss1
-rw-r--r--app/assets/stylesheets/pages/detail_page.scss1
-rw-r--r--app/assets/stylesheets/pages/diff.scss420
-rw-r--r--app/assets/stylesheets/pages/environments.scss336
-rw-r--r--app/assets/stylesheets/pages/events.scss10
-rw-r--r--app/assets/stylesheets/pages/graph.scss2
-rw-r--r--app/assets/stylesheets/pages/groups.scss52
-rw-r--r--app/assets/stylesheets/pages/help.scss10
-rw-r--r--app/assets/stylesheets/pages/import.scss14
-rw-r--r--app/assets/stylesheets/pages/issuable.scss71
-rw-r--r--app/assets/stylesheets/pages/issues.scss18
-rw-r--r--app/assets/stylesheets/pages/labels.scss78
-rw-r--r--app/assets/stylesheets/pages/login.scss19
-rw-r--r--app/assets/stylesheets/pages/members.scss100
-rw-r--r--app/assets/stylesheets/pages/merge_conflicts.scss96
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss176
-rw-r--r--app/assets/stylesheets/pages/milestone.scss5
-rw-r--r--app/assets/stylesheets/pages/monitor.scss5
-rw-r--r--app/assets/stylesheets/pages/note_form.scss49
-rw-r--r--app/assets/stylesheets/pages/notes.scss158
-rw-r--r--app/assets/stylesheets/pages/notifications.scss28
-rw-r--r--app/assets/stylesheets/pages/pipeline_schedules.scss7
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss58
-rw-r--r--app/assets/stylesheets/pages/profile.scss65
-rw-r--r--app/assets/stylesheets/pages/projects.scss101
-rw-r--r--app/assets/stylesheets/pages/prometheus.scss270
-rw-r--r--app/assets/stylesheets/pages/reports.scss5
-rw-r--r--app/assets/stylesheets/pages/search.scss15
-rw-r--r--app/assets/stylesheets/pages/settings.scss63
-rw-r--r--app/assets/stylesheets/pages/stat_graph.scss10
-rw-r--r--app/assets/stylesheets/pages/status.scss9
-rw-r--r--app/assets/stylesheets/pages/todos.scss79
-rw-r--r--app/assets/stylesheets/pages/tree.scss20
-rw-r--r--app/assets/stylesheets/pages/ui_dev_kit.scss2
-rw-r--r--app/assets/stylesheets/pages/wiki.scss6
-rw-r--r--app/assets/stylesheets/performance_bar.scss8
-rw-r--r--app/assets/stylesheets/print.scss18
-rw-r--r--app/assets/stylesheets/utilities.scss17
-rw-r--r--app/assets/stylesheets/vendors/atwho.scss92
-rw-r--r--app/controllers/abuse_reports_controller.rb6
-rw-r--r--app/controllers/acme_challenges_controller.rb17
-rw-r--r--app/controllers/admin/appearances_controller.rb11
-rw-r--r--app/controllers/admin/application_controller.rb7
-rw-r--r--app/controllers/admin/application_settings_controller.rb29
-rw-r--r--app/controllers/admin/applications_controller.rb4
-rw-r--r--app/controllers/admin/broadcast_messages_controller.rb4
-rw-r--r--app/controllers/admin/clusters/applications_controller.rb11
-rw-r--r--app/controllers/admin/clusters_controller.rb13
-rw-r--r--app/controllers/admin/deploy_keys_controller.rb2
-rw-r--r--app/controllers/admin/groups_controller.rb11
-rw-r--r--app/controllers/admin/hooks_controller.rb4
-rw-r--r--app/controllers/admin/identities_controller.rb8
-rw-r--r--app/controllers/admin/impersonation_tokens_controller.rb8
-rw-r--r--app/controllers/admin/keys_controller.rb4
-rw-r--r--app/controllers/admin/labels_controller.rb6
-rw-r--r--app/controllers/admin/logs_controller.rb3
-rw-r--r--app/controllers/admin/projects_controller.rb17
-rw-r--r--app/controllers/admin/runners_controller.rb16
-rw-r--r--app/controllers/admin/spam_logs_controller.rb6
-rw-r--r--app/controllers/admin/users_controller.rb42
-rw-r--r--app/controllers/application_controller.rb42
-rw-r--r--app/controllers/autocomplete_controller.rb9
-rw-r--r--app/controllers/clusters/applications_controller.rb42
-rw-r--r--app/controllers/clusters/clusters_controller.rb12
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor.rb13
-rw-r--r--app/controllers/concerns/boards_actions.rb38
-rw-r--r--app/controllers/concerns/continue_params.rb2
-rw-r--r--app/controllers/concerns/creates_commit.rb25
-rw-r--r--app/controllers/concerns/enforces_admin_authentication.rb19
-rw-r--r--app/controllers/concerns/enforces_two_factor_authentication.rb2
-rw-r--r--app/controllers/concerns/import_url_params.rb19
-rw-r--r--app/controllers/concerns/issuable_actions.rb9
-rw-r--r--app/controllers/concerns/issuable_collections.rb17
-rw-r--r--app/controllers/concerns/lfs_request.rb6
-rw-r--r--app/controllers/concerns/membership_actions.rb35
-rw-r--r--app/controllers/concerns/milestone_actions.rb10
-rw-r--r--app/controllers/concerns/notes_actions.rb2
-rw-r--r--app/controllers/concerns/preview_markdown.rb2
-rw-r--r--app/controllers/concerns/project_unauthorized.rb19
-rw-r--r--app/controllers/concerns/renders_notes.rb2
-rw-r--r--app/controllers/concerns/routable_actions.rb18
-rw-r--r--app/controllers/concerns/spammable_actions.rb6
-rw-r--r--app/controllers/concerns/uploads_actions.rb4
-rw-r--r--app/controllers/confirmations_controller.rb2
-rw-r--r--app/controllers/dashboard/projects_controller.rb22
-rw-r--r--app/controllers/dashboard/todos_controller.rb4
-rw-r--r--app/controllers/dashboard_controller.rb5
-rw-r--r--app/controllers/explore/projects_controller.rb6
-rw-r--r--app/controllers/google_api/authorizations_controller.rb32
-rw-r--r--app/controllers/graphql_controller.rb61
-rw-r--r--app/controllers/groups/boards_controller.rb39
-rw-r--r--app/controllers/groups/group_members_controller.rb1
-rw-r--r--app/controllers/groups/runners_controller.rb10
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb20
-rw-r--r--app/controllers/groups/variables_controller.rb2
-rw-r--r--app/controllers/groups_controller.rb43
-rw-r--r--app/controllers/help_controller.rb35
-rw-r--r--app/controllers/import/bitbucket_controller.rb2
-rw-r--r--app/controllers/import/bitbucket_server_controller.rb16
-rw-r--r--app/controllers/import/fogbugz_controller.rb6
-rw-r--r--app/controllers/import/gitea_controller.rb2
-rw-r--r--app/controllers/import/gitlab_controller.rb2
-rw-r--r--app/controllers/import/gitlab_projects_controller.rb4
-rw-r--r--app/controllers/import/google_code_controller.rb12
-rw-r--r--app/controllers/import/phabricator_controller.rb35
-rw-r--r--app/controllers/invites_controller.rb8
-rw-r--r--app/controllers/jwt_controller.rb8
-rw-r--r--app/controllers/ldap/omniauth_callbacks_controller.rb2
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb21
-rw-r--r--app/controllers/passwords_controller.rb4
-rw-r--r--app/controllers/profiles/accounts_controller.rb4
-rw-r--r--app/controllers/profiles/active_sessions_controller.rb11
-rw-r--r--app/controllers/profiles/chat_names_controller.rb10
-rw-r--r--app/controllers/profiles/groups_controller.rb24
-rw-r--r--app/controllers/profiles/notifications_controller.rb4
-rw-r--r--app/controllers/profiles/passwords_controller.rb12
-rw-r--r--app/controllers/profiles/personal_access_tokens_controller.rb8
-rw-r--r--app/controllers/profiles/preferences_controller.rb10
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb25
-rw-r--r--app/controllers/profiles/u2f_registrations_controller.rb2
-rw-r--r--app/controllers/profiles_controller.rb7
-rw-r--r--app/controllers/projects/application_controller.rb11
-rw-r--r--app/controllers/projects/autocomplete_sources_controller.rb2
-rw-r--r--app/controllers/projects/blob_controller.rb65
-rw-r--r--app/controllers/projects/boards_controller.rb39
-rw-r--r--app/controllers/projects/branches_controller.rb18
-rw-r--r--app/controllers/projects/clusters/applications_controller.rb4
-rw-r--r--app/controllers/projects/clusters_controller.rb8
-rw-r--r--app/controllers/projects/commit_controller.rb6
-rw-r--r--app/controllers/projects/commits_controller.rb1
-rw-r--r--app/controllers/projects/deploy_keys_controller.rb2
-rw-r--r--app/controllers/projects/environments/prometheus_api_controller.rb60
-rw-r--r--app/controllers/projects/environments_controller.rb53
-rw-r--r--app/controllers/projects/git_http_client_controller.rb15
-rw-r--r--app/controllers/projects/git_http_controller.rb18
-rw-r--r--app/controllers/projects/graphs_controller.rb8
-rw-r--r--app/controllers/projects/group_links_controller.rb7
-rw-r--r--app/controllers/projects/hooks_controller.rb2
-rw-r--r--app/controllers/projects/imports_controller.rb13
-rw-r--r--app/controllers/projects/issues_controller.rb25
-rw-r--r--app/controllers/projects/jobs_controller.rb4
-rw-r--r--app/controllers/projects/labels_controller.rb2
-rw-r--r--app/controllers/projects/lfs_api_controller.rb4
-rw-r--r--app/controllers/projects/merge_requests/application_controller.rb12
-rw-r--r--app/controllers/projects/merge_requests/conflicts_controller.rb8
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb4
-rw-r--r--app/controllers/projects/merge_requests_controller.rb82
-rw-r--r--app/controllers/projects/mirrors_controller.rb5
-rw-r--r--app/controllers/projects/pages_domains_controller.rb4
-rw-r--r--app/controllers/projects/pipeline_schedules_controller.rb10
-rw-r--r--app/controllers/projects/pipelines_controller.rb16
-rw-r--r--app/controllers/projects/refs_controller.rb2
-rw-r--r--app/controllers/projects/repositories_controller.rb2
-rw-r--r--app/controllers/projects/runners_controller.rb10
-rw-r--r--app/controllers/projects/serverless/functions_controller.rb35
-rw-r--r--app/controllers/projects/services_controller.rb10
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb11
-rw-r--r--app/controllers/projects/settings/operations_controller.rb47
-rw-r--r--app/controllers/projects/stages_controller.rb25
-rw-r--r--app/controllers/projects/tags/releases_controller.rb10
-rw-r--r--app/controllers/projects/tree_controller.rb4
-rw-r--r--app/controllers/projects/triggers_controller.rb14
-rw-r--r--app/controllers/projects/variables_controller.rb2
-rw-r--r--app/controllers/projects/wikis_controller.rb19
-rw-r--r--app/controllers/projects_controller.rb26
-rw-r--r--app/controllers/registrations_controller.rb25
-rw-r--r--app/controllers/root_controller.rb2
-rw-r--r--app/controllers/search_controller.rb10
-rw-r--r--app/controllers/sent_notifications_controller.rb2
-rw-r--r--app/controllers/sessions_controller.rb25
-rw-r--r--app/controllers/uploads_controller.rb13
-rw-r--r--app/finders/admin/runners_finder.rb11
-rw-r--r--app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb44
-rw-r--r--app/finders/autocomplete/users_finder.rb10
-rw-r--r--app/finders/clusters/knative_services_finder.rb112
-rw-r--r--app/finders/groups_finder.rb8
-rw-r--r--app/finders/issuable_finder.rb114
-rw-r--r--app/finders/issues_finder.rb18
-rw-r--r--app/finders/members_finder.rb43
-rw-r--r--app/finders/merge_requests_finder.rb24
-rw-r--r--app/finders/projects/daily_statistics_finder.rb21
-rw-r--r--app/finders/projects/serverless/functions_finder.rb62
-rw-r--r--app/finders/projects_finder.rb8
-rw-r--r--app/finders/snippets_finder.rb2
-rw-r--r--app/graphql/gitlab_schema.rb84
-rw-r--r--app/graphql/mutations/merge_requests/base.rb2
-rw-r--r--app/graphql/resolvers/base_resolver.rb19
-rw-r--r--app/graphql/resolvers/concerns/resolves_pipelines.rb10
-rw-r--r--app/graphql/resolvers/full_path_resolver.rb6
-rw-r--r--app/graphql/resolvers/group_resolver.rb13
-rw-r--r--app/graphql/resolvers/issues_resolver.rb17
-rw-r--r--app/graphql/resolvers/merge_requests_resolver.rb4
-rw-r--r--app/graphql/resolvers/metadata_resolver.rb11
-rw-r--r--app/graphql/resolvers/namespace_projects_resolver.rb33
-rw-r--r--app/graphql/resolvers/namespace_resolver.rb13
-rw-r--r--app/graphql/resolvers/project_resolver.rb4
-rw-r--r--app/graphql/resolvers/tree_resolver.rb26
-rw-r--r--app/graphql/types/base_field.rb42
-rw-r--r--app/graphql/types/base_object.rb5
-rw-r--r--app/graphql/types/ci/detailed_status_type.rb17
-rw-r--r--app/graphql/types/ci/pipeline_type.rb12
-rw-r--r--app/graphql/types/group_type.rb23
-rw-r--r--app/graphql/types/issue_type.rb26
-rw-r--r--app/graphql/types/merge_request_type.rb12
-rw-r--r--app/graphql/types/metadata_type.rb10
-rw-r--r--app/graphql/types/milestone_type.rb2
-rw-r--r--app/graphql/types/namespace_type.rb24
-rw-r--r--app/graphql/types/permission_types/group.rb11
-rw-r--r--app/graphql/types/project_statistics_type.rb16
-rw-r--r--app/graphql/types/project_type.rb27
-rw-r--r--app/graphql/types/query_type.rb22
-rw-r--r--app/graphql/types/repository_type.rb14
-rw-r--r--app/graphql/types/tree/blob_type.rb14
-rw-r--r--app/graphql/types/tree/entry_type.rb14
-rw-r--r--app/graphql/types/tree/submodule_type.rb10
-rw-r--r--app/graphql/types/tree/tree_entry_type.rb15
-rw-r--r--app/graphql/types/tree/tree_type.rb18
-rw-r--r--app/graphql/types/tree/type_enum.rb14
-rw-r--r--app/graphql/types/user_type.rb2
-rw-r--r--app/helpers/appearances_helper.rb8
-rw-r--r--app/helpers/application_settings_helper.rb51
-rw-r--r--app/helpers/auth_helper.rb10
-rw-r--r--app/helpers/auto_devops_helper.rb13
-rw-r--r--app/helpers/blob_helper.rb25
-rw-r--r--app/helpers/boards_helper.rb2
-rw-r--r--app/helpers/broadcast_messages_helper.rb2
-rw-r--r--app/helpers/builds_helper.rb6
-rw-r--r--app/helpers/button_helper.rb2
-rw-r--r--app/helpers/ci_status_helper.rb25
-rw-r--r--app/helpers/ci_variables_helper.rb19
-rw-r--r--app/helpers/clusters_helper.rb6
-rw-r--r--app/helpers/dashboard_helper.rb4
-rw-r--r--app/helpers/emails_helper.rb67
-rw-r--r--app/helpers/environments_helper.rb3
-rw-r--r--app/helpers/events_helper.rb12
-rw-r--r--app/helpers/form_helper.rb44
-rw-r--r--app/helpers/groups/group_members_helper.rb7
-rw-r--r--app/helpers/groups_helper.rb8
-rw-r--r--app/helpers/issuables_helper.rb15
-rw-r--r--app/helpers/labels_helper.rb164
-rw-r--r--app/helpers/markup_helper.rb11
-rw-r--r--app/helpers/merge_requests_helper.rb8
-rw-r--r--app/helpers/milestones_helper.rb25
-rw-r--r--app/helpers/mirror_helper.rb4
-rw-r--r--app/helpers/namespaces_helper.rb7
-rw-r--r--app/helpers/nav_helper.rb8
-rw-r--r--app/helpers/notes_helper.rb12
-rw-r--r--app/helpers/notifications_helper.rb11
-rw-r--r--app/helpers/page_layout_helper.rb3
-rw-r--r--app/helpers/preferences_helper.rb3
-rw-r--r--app/helpers/projects_helper.rb82
-rw-r--r--app/helpers/search_helper.rb73
-rw-r--r--app/helpers/sidekiq_helper.rb2
-rw-r--r--app/helpers/sorting_helper.rb149
-rw-r--r--app/helpers/storage_helper.rb13
-rw-r--r--app/helpers/tracking_helper.rb7
-rw-r--r--app/helpers/tree_helper.rb21
-rw-r--r--app/helpers/user_callouts_helper.rb3
-rw-r--r--app/helpers/visibility_level_helper.rb54
-rw-r--r--app/helpers/wiki_helper.rb20
-rw-r--r--app/mailers/abuse_report_mailer.rb4
-rw-r--r--app/mailers/devise_mailer.rb1
-rw-r--r--app/mailers/email_rejection_mailer.rb4
-rw-r--r--app/mailers/emails/issues.rb12
-rw-r--r--app/mailers/emails/members.rb3
-rw-r--r--app/mailers/emails/merge_requests.rb12
-rw-r--r--app/mailers/emails/notes.rb2
-rw-r--r--app/mailers/emails/pages_domains.rb8
-rw-r--r--app/mailers/emails/pipelines.rb2
-rw-r--r--app/mailers/emails/projects.rb10
-rw-r--r--app/mailers/emails/remote_mirrors.rb2
-rw-r--r--app/mailers/notify.rb18
-rw-r--r--app/mailers/repository_check_mailer.rb4
-rw-r--r--app/models/abuse_report.rb2
-rw-r--r--app/models/active_session.rb55
-rw-r--r--app/models/appearance.rb3
-rw-r--r--app/models/application_record.rb31
-rw-r--r--app/models/application_setting.rb345
-rw-r--r--app/models/application_setting/term.rb2
-rw-r--r--app/models/application_setting_implementation.rb299
-rw-r--r--app/models/audit_event.rb2
-rw-r--r--app/models/award_emoji.rb2
-rw-r--r--app/models/badge.rb4
-rw-r--r--app/models/blob.rb2
-rw-r--r--app/models/board.rb2
-rw-r--r--app/models/board_group_recent_visit.rb11
-rw-r--r--app/models/board_project_recent_visit.rb11
-rw-r--r--app/models/broadcast_message.rb4
-rw-r--r--app/models/chat_name.rb2
-rw-r--r--app/models/chat_team.rb2
-rw-r--r--app/models/ci/bridge.rb9
-rw-r--r--app/models/ci/build.rb310
-rw-r--r--app/models/ci/build_metadata.rb2
-rw-r--r--app/models/ci/build_runner_session.rb22
-rw-r--r--app/models/ci/build_trace_chunk.rb4
-rw-r--r--app/models/ci/build_trace_section.rb2
-rw-r--r--app/models/ci/build_trace_section_name.rb2
-rw-r--r--app/models/ci/group_variable.rb3
-rw-r--r--app/models/ci/job_artifact.rb43
-rw-r--r--app/models/ci/legacy_stage.rb4
-rw-r--r--app/models/ci/pipeline.rb167
-rw-r--r--app/models/ci/pipeline_chat_data.rb2
-rw-r--r--app/models/ci/pipeline_enums.rb2
-rw-r--r--app/models/ci/pipeline_schedule.rb30
-rw-r--r--app/models/ci/pipeline_schedule_variable.rb2
-rw-r--r--app/models/ci/pipeline_variable.rb2
-rw-r--r--app/models/ci/runner.rb5
-rw-r--r--app/models/ci/runner_namespace.rb2
-rw-r--r--app/models/ci/runner_project.rb2
-rw-r--r--app/models/ci/stage.rb13
-rw-r--r--app/models/ci/trigger.rb2
-rw-r--r--app/models/ci/trigger_request.rb2
-rw-r--r--app/models/ci/variable.rb3
-rw-r--r--app/models/clusters/applications/cert_manager.rb8
-rw-r--r--app/models/clusters/applications/helm.rb9
-rw-r--r--app/models/clusters/applications/ingress.rb10
-rw-r--r--app/models/clusters/applications/jupyter.rb41
-rw-r--r--app/models/clusters/applications/knative.rb61
-rw-r--r--app/models/clusters/applications/prometheus.rb20
-rw-r--r--app/models/clusters/applications/runner.rb28
-rw-r--r--app/models/clusters/cluster.rb101
-rw-r--r--app/models/clusters/concerns/application_core.rb16
-rw-r--r--app/models/clusters/concerns/application_data.rb66
-rw-r--r--app/models/clusters/concerns/application_status.rb13
-rw-r--r--app/models/clusters/group.rb2
-rw-r--r--app/models/clusters/instance.rb17
-rw-r--r--app/models/clusters/kubernetes_namespace.rb4
-rw-r--r--app/models/clusters/platforms/kubernetes.rb81
-rw-r--r--app/models/clusters/project.rb3
-rw-r--r--app/models/clusters/providers/gcp.rb2
-rw-r--r--app/models/commit.rb11
-rw-r--r--app/models/commit_collection.rb39
-rw-r--r--app/models/commit_range.rb14
-rw-r--r--app/models/commit_status.rb25
-rw-r--r--app/models/commit_status_enums.rb3
-rw-r--r--app/models/concerns/artifact_migratable.rb46
-rw-r--r--app/models/concerns/atomic_internal_id.rb16
-rw-r--r--app/models/concerns/avatarable.rb3
-rw-r--r--app/models/concerns/blob_language_from_git_attributes.rb2
-rw-r--r--app/models/concerns/cache_markdown_field.rb76
-rw-r--r--app/models/concerns/ci/contextable.rb108
-rw-r--r--app/models/concerns/ci/pipeline_delegator.rb21
-rw-r--r--app/models/concerns/ci/processable.rb4
-rw-r--r--app/models/concerns/deployment_platform.rb13
-rw-r--r--app/models/concerns/deprecated_assignee.rb86
-rw-r--r--app/models/concerns/feature_gate.rb2
-rw-r--r--app/models/concerns/group_descendant.rb4
-rw-r--r--app/models/concerns/has_ref.rb16
-rw-r--r--app/models/concerns/has_status.rb21
-rw-r--r--app/models/concerns/has_variable.rb11
-rw-r--r--app/models/concerns/ignorable_column.rb2
-rw-r--r--app/models/concerns/issuable.rb74
-rw-r--r--app/models/concerns/issuable_states.rb25
-rw-r--r--app/models/concerns/maskable.rb22
-rw-r--r--app/models/concerns/milestoneish.rb43
-rw-r--r--app/models/concerns/mirror_authentication.rb2
-rw-r--r--app/models/concerns/noteable.rb20
-rw-r--r--app/models/concerns/participable.rb2
-rw-r--r--app/models/concerns/prometheus_adapter.rb4
-rw-r--r--app/models/concerns/reactive_caching.rb43
-rw-r--r--app/models/concerns/redactable.rb2
-rw-r--r--app/models/concerns/referable.rb1
-rw-r--r--app/models/concerns/resolvable_note.rb2
-rw-r--r--app/models/concerns/sha_attribute.rb10
-rw-r--r--app/models/concerns/sortable.rb28
-rw-r--r--app/models/concerns/storage/legacy_namespace.rb24
-rw-r--r--app/models/concerns/strip_attribute.rb2
-rw-r--r--app/models/concerns/taskable.rb9
-rw-r--r--app/models/concerns/token_authenticatable.rb19
-rw-r--r--app/models/concerns/token_authenticatable_strategies/base.rb18
-rw-r--r--app/models/concerns/token_authenticatable_strategies/encrypted.rb52
-rw-r--r--app/models/concerns/update_project_statistics.rb72
-rw-r--r--app/models/container_repository.rb2
-rw-r--r--app/models/conversational_development_index/metric.rb2
-rw-r--r--app/models/deploy_keys_project.rb2
-rw-r--r--app/models/deploy_token.rb2
-rw-r--r--app/models/deployment.rb21
-rw-r--r--app/models/diff_note.rb32
-rw-r--r--app/models/email.rb4
-rw-r--r--app/models/environment.rb26
-rw-r--r--app/models/epic.rb2
-rw-r--r--app/models/error_tracking/project_error_tracking_setting.rb60
-rw-r--r--app/models/event.rb56
-rw-r--r--app/models/fork_network.rb2
-rw-r--r--app/models/fork_network_member.rb2
-rw-r--r--app/models/generic_commit_status.rb2
-rw-r--r--app/models/global_label.rb8
-rw-r--r--app/models/global_milestone.rb4
-rw-r--r--app/models/gpg_key.rb2
-rw-r--r--app/models/gpg_key_subkey.rb2
-rw-r--r--app/models/gpg_signature.rb9
-rw-r--r--app/models/group.rb31
-rw-r--r--app/models/group_custom_attribute.rb2
-rw-r--r--app/models/hooks/web_hook.rb2
-rw-r--r--app/models/hooks/web_hook_log.rb2
-rw-r--r--app/models/identity.rb3
-rw-r--r--app/models/identity/uniqueness_scopes.rb2
-rw-r--r--app/models/import_export_upload.rb2
-rw-r--r--app/models/individual_note_discussion.rb2
-rw-r--r--app/models/instance_configuration.rb2
-rw-r--r--app/models/internal_id.rb57
-rw-r--r--app/models/issue.rb49
-rw-r--r--app/models/issue/metrics.rb2
-rw-r--r--app/models/issue_assignee.rb2
-rw-r--r--app/models/key.rb7
-rw-r--r--app/models/label.rb18
-rw-r--r--app/models/label_link.rb2
-rw-r--r--app/models/label_note.rb2
-rw-r--r--app/models/label_priority.rb2
-rw-r--r--app/models/legacy_diff_note.rb2
-rw-r--r--app/models/lfs_file_lock.rb2
-rw-r--r--app/models/lfs_object.rb4
-rw-r--r--app/models/lfs_objects_project.rb2
-rw-r--r--app/models/list.rb2
-rw-r--r--app/models/member.rb10
-rw-r--r--app/models/members/group_member.rb6
-rw-r--r--app/models/members/project_member.rb2
-rw-r--r--app/models/merge_request.rb273
-rw-r--r--app/models/merge_request/metrics.rb2
-rw-r--r--app/models/merge_request_assignee.rb6
-rw-r--r--app/models/merge_request_diff.rb206
-rw-r--r--app/models/merge_request_diff_commit.rb2
-rw-r--r--app/models/merge_request_diff_file.rb4
-rw-r--r--app/models/merge_requests_closing_issues.rb2
-rw-r--r--app/models/milestone.rb28
-rw-r--r--app/models/namespace.rb66
-rw-r--r--app/models/note.rb10
-rw-r--r--app/models/note_diff_file.rb6
-rw-r--r--app/models/notification_recipient.rb70
-rw-r--r--app/models/notification_setting.rb11
-rw-r--r--app/models/pages_domain.rb31
-rw-r--r--app/models/pages_domain_acme_order.rb24
-rw-r--r--app/models/personal_access_token.rb6
-rw-r--r--app/models/pool_repository.rb12
-rw-r--r--app/models/postgresql/replication_slot.rb2
-rw-r--r--app/models/programming_language.rb2
-rw-r--r--app/models/project.rb209
-rw-r--r--app/models/project_authorization.rb2
-rw-r--r--app/models/project_auto_devops.rb27
-rw-r--r--app/models/project_ci_cd_setting.rb20
-rw-r--r--app/models/project_custom_attribute.rb2
-rw-r--r--app/models/project_daily_statistic.rb10
-rw-r--r--app/models/project_deploy_token.rb2
-rw-r--r--app/models/project_feature.rb19
-rw-r--r--app/models/project_group_link.rb6
-rw-r--r--app/models/project_import_data.rb2
-rw-r--r--app/models/project_import_state.rb2
-rw-r--r--app/models/project_metrics_setting.rb9
-rw-r--r--app/models/project_repository.rb2
-rw-r--r--app/models/project_services/asana_service.rb8
-rw-r--r--app/models/project_services/bamboo_service.rb12
-rw-r--r--app/models/project_services/campfire_service.rb2
-rw-r--r--app/models/project_services/chat_message/deployment_message.rb77
-rw-r--r--app/models/project_services/chat_notification_service.rb4
-rw-r--r--app/models/project_services/discord_service.rb9
-rw-r--r--app/models/project_services/emails_on_push_service.rb14
-rw-r--r--app/models/project_services/external_wiki_service.rb6
-rw-r--r--app/models/project_services/flowdock_service.rb4
-rw-r--r--app/models/project_services/hangouts_chat_service.rb5
-rw-r--r--app/models/project_services/hipchat_service.rb311
-rw-r--r--app/models/project_services/irker_service.rb2
-rw-r--r--app/models/project_services/jira_service.rb36
-rw-r--r--app/models/project_services/kubernetes_service.rb18
-rw-r--r--app/models/project_services/microsoft_teams_service.rb5
-rw-r--r--app/models/project_services/mock_ci_service.rb2
-rw-r--r--app/models/project_services/pipelines_email_service.rb24
-rw-r--r--app/models/project_services/pivotaltracker_service.rb8
-rw-r--r--app/models/project_services/prometheus_service.rb6
-rw-r--r--app/models/project_services/pushover_service.rb26
-rw-r--r--app/models/project_services/youtrack_service.rb40
-rw-r--r--app/models/project_statistics.rb27
-rw-r--r--app/models/project_wiki.rb32
-rw-r--r--app/models/prometheus_metric.rb2
-rw-r--r--app/models/protected_branch.rb16
-rw-r--r--app/models/protected_branch/merge_access_level.rb2
-rw-r--r--app/models/protected_branch/push_access_level.rb2
-rw-r--r--app/models/protected_tag.rb2
-rw-r--r--app/models/protected_tag/create_access_level.rb2
-rw-r--r--app/models/push_event.rb2
-rw-r--r--app/models/push_event_payload.rb2
-rw-r--r--app/models/redirect_route.rb2
-rw-r--r--app/models/release.rb10
-rw-r--r--app/models/releases/link.rb4
-rw-r--r--app/models/remote_mirror.rb18
-rw-r--r--app/models/repository.rb127
-rw-r--r--app/models/repository_language.rb2
-rw-r--r--app/models/resource_label_event.rb2
-rw-r--r--app/models/route.rb18
-rw-r--r--app/models/sent_notification.rb2
-rw-r--r--app/models/serverless/function.rb26
-rw-r--r--app/models/service.rb7
-rw-r--r--app/models/shard.rb2
-rw-r--r--app/models/snippet.rb2
-rw-r--r--app/models/spam_log.rb2
-rw-r--r--app/models/ssh_host_key.rb2
-rw-r--r--app/models/storage/legacy_project.rb2
-rw-r--r--app/models/subscription.rb2
-rw-r--r--app/models/suggestion.rb51
-rw-r--r--app/models/system_note_metadata.rb2
-rw-r--r--app/models/term_agreement.rb2
-rw-r--r--app/models/timelog.rb2
-rw-r--r--app/models/todo.rb14
-rw-r--r--app/models/trending_project.rb2
-rw-r--r--app/models/u2f_registration.rb4
-rw-r--r--app/models/upload.rb8
-rw-r--r--app/models/user.rb101
-rw-r--r--app/models/user_agent_detail.rb2
-rw-r--r--app/models/user_callout.rb2
-rw-r--r--app/models/user_custom_attribute.rb2
-rw-r--r--app/models/user_interacted_project.rb20
-rw-r--r--app/models/user_preference.rb6
-rw-r--r--app/models/user_status.rb2
-rw-r--r--app/models/user_synced_attributes_metadata.rb2
-rw-r--r--app/models/users_star_project.rb2
-rw-r--r--app/models/wiki_page.rb21
-rw-r--r--app/policies/base_policy.rb9
-rw-r--r--app/policies/ci/pipeline_policy.rb12
-rw-r--r--app/policies/clusters/cluster_policy.rb1
-rw-r--r--app/policies/clusters/instance_policy.rb21
-rw-r--r--app/policies/global_policy.rb5
-rw-r--r--app/policies/group_policy.rb22
-rw-r--r--app/policies/identity_provider_policy.rb15
-rw-r--r--app/policies/issuable_policy.rb1
-rw-r--r--app/policies/merge_request_policy.rb3
-rw-r--r--app/policies/personal_snippet_policy.rb13
-rw-r--r--app/policies/project_policy.rb41
-rw-r--r--app/presenters/blob_presenter.rb4
-rw-r--r--app/presenters/blobs/unfold_presenter.rb75
-rw-r--r--app/presenters/ci/bridge_presenter.rb9
-rw-r--r--app/presenters/ci/build_runner_presenter.rb22
-rw-r--r--app/presenters/ci/pipeline_presenter.rb45
-rw-r--r--app/presenters/clusterable_presenter.rb4
-rw-r--r--app/presenters/clusters/cluster_presenter.rb12
-rw-r--r--app/presenters/commit_status_presenter.rb3
-rw-r--r--app/presenters/group_clusterable_presenter.rb5
-rw-r--r--app/presenters/instance_clusterable_presenter.rb69
-rw-r--r--app/presenters/issue_presenter.rb12
-rw-r--r--app/presenters/label_presenter.rb51
-rw-r--r--app/presenters/member_presenter.rb5
-rw-r--r--app/presenters/merge_request_presenter.rb51
-rw-r--r--app/presenters/project_clusterable_presenter.rb5
-rw-r--r--app/presenters/project_presenter.rb18
-rw-r--r--app/presenters/tree_entry_presenter.rb9
-rw-r--r--app/serializers/acts_as_taggable_on/tag_entity.rb6
-rw-r--r--app/serializers/acts_as_taggable_on/tag_serializer.rb5
-rw-r--r--app/serializers/analytics_stage_entity.rb5
-rw-r--r--app/serializers/build_details_entity.rb17
-rw-r--r--app/serializers/cluster_application_entity.rb2
-rw-r--r--app/serializers/concerns/user_status_tooltip.rb4
-rw-r--r--app/serializers/deployment_entity.rb33
-rw-r--r--app/serializers/detailed_status_entity.rb16
-rw-r--r--app/serializers/diff_file_base_entity.rb10
-rw-r--r--app/serializers/diff_file_entity.rb6
-rw-r--r--app/serializers/environment_entity.rb3
-rw-r--r--app/serializers/group_variable_entity.rb1
-rw-r--r--app/serializers/issuable_sidebar_extras_entity.rb2
-rw-r--r--app/serializers/issue_board_entity.rb2
-rw-r--r--app/serializers/issue_entity.rb10
-rw-r--r--app/serializers/issue_sidebar_extras_entity.rb1
-rw-r--r--app/serializers/job_artifact_report_entity.rb13
-rw-r--r--app/serializers/merge_request_assignee_entity.rb7
-rw-r--r--app/serializers/merge_request_basic_entity.rb3
-rw-r--r--app/serializers/merge_request_for_pipeline_entity.rb17
-rw-r--r--app/serializers/merge_request_serializer.rb4
-rw-r--r--app/serializers/merge_request_sidebar_basic_entity.rb11
-rw-r--r--app/serializers/merge_request_sidebar_extras_entity.rb7
-rw-r--r--app/serializers/merge_request_widget_entity.rb17
-rw-r--r--app/serializers/pipeline_details_entity.rb5
-rw-r--r--app/serializers/pipeline_entity.rb20
-rw-r--r--app/serializers/pipeline_serializer.rb39
-rw-r--r--app/serializers/projects/serverless/service_entity.rb7
-rw-r--r--app/serializers/suggestion_entity.rb2
-rw-r--r--app/serializers/suggestion_serializer.rb9
-rw-r--r--app/serializers/test_case_entity.rb1
-rw-r--r--app/serializers/variable_entity.rb1
-rw-r--r--app/services/after_branch_delete_service.rb23
-rw-r--r--app/services/application_settings/update_service.rb10
-rw-r--r--app/services/auth/container_registry_authentication_service.rb2
-rw-r--r--app/services/auto_merge/base_service.rb52
-rw-r--r--app/services/auto_merge/merge_when_pipeline_succeeds_service.rb30
-rw-r--r--app/services/auto_merge_service.rb50
-rw-r--r--app/services/boards/visits/latest_service.rb14
-rw-r--r--app/services/ci/create_pipeline_service.rb18
-rw-r--r--app/services/ci/destroy_pipeline_service.rb2
-rw-r--r--app/services/ci/expire_pipeline_cache_service.rb62
-rw-r--r--app/services/ci/pipeline_schedule_service.rb13
-rw-r--r--app/services/ci/play_manual_stage_service.rb29
-rw-r--r--app/services/ci/prepare_build_service.rb27
-rw-r--r--app/services/ci/register_job_service.rb7
-rw-r--r--app/services/ci/stop_environments_service.rb16
-rw-r--r--app/services/clusters/applications/base_helm_service.rb30
-rw-r--r--app/services/clusters/applications/base_service.rb92
-rw-r--r--app/services/clusters/applications/check_ingress_ip_address_service.rb16
-rw-r--r--app/services/clusters/applications/check_installation_progress_service.rb6
-rw-r--r--app/services/clusters/applications/check_uninstall_progress_service.rb62
-rw-r--r--app/services/clusters/applications/create_service.rb75
-rw-r--r--app/services/clusters/applications/destroy_service.rb23
-rw-r--r--app/services/clusters/applications/install_service.rb31
-rw-r--r--app/services/clusters/applications/patch_service.rb32
-rw-r--r--app/services/clusters/applications/schedule_installation_service.rb31
-rw-r--r--app/services/clusters/applications/uninstall_service.rb29
-rw-r--r--app/services/clusters/applications/update_service.rb17
-rw-r--r--app/services/clusters/applications/upgrade_service.rb34
-rw-r--r--app/services/clusters/build_service.rb2
-rw-r--r--app/services/clusters/create_service.rb2
-rw-r--r--app/services/clusters/refresh_service.rb2
-rw-r--r--app/services/commits/create_service.rb13
-rw-r--r--app/services/compare_service.rb4
-rw-r--r--app/services/concerns/suggestible.rb37
-rw-r--r--app/services/concerns/users/participable_service.rb26
-rw-r--r--app/services/concerns/validates_classification_label.rb27
-rw-r--r--app/services/delete_branch_service.rb34
-rw-r--r--app/services/error_tracking/list_issues_service.rb11
-rw-r--r--app/services/error_tracking/list_projects_service.rb8
-rw-r--r--app/services/files/delete_service.rb2
-rw-r--r--app/services/files/multi_service.rb3
-rw-r--r--app/services/files/update_service.rb2
-rw-r--r--app/services/git/base_hooks_service.rb105
-rw-r--r--app/services/git/branch_hooks_service.rb144
-rw-r--r--app/services/git/branch_push_service.rb92
-rw-r--r--app/services/git/tag_hooks_service.rb36
-rw-r--r--app/services/git/tag_push_service.rb14
-rw-r--r--app/services/git_push_service.rb240
-rw-r--r--app/services/git_tag_push_service.rb66
-rw-r--r--app/services/groups/auto_devops_service.rb17
-rw-r--r--app/services/groups/base_service.rb6
-rw-r--r--app/services/groups/create_service.rb12
-rw-r--r--app/services/groups/destroy_service.rb2
-rw-r--r--app/services/groups/nested_create_service.rb2
-rw-r--r--app/services/groups/transfer_service.rb22
-rw-r--r--app/services/groups/update_service.rb1
-rw-r--r--app/services/import/github_service.rb2
-rw-r--r--app/services/issuable/clone/content_rewriter.rb1
-rw-r--r--app/services/issuable_base_service.rb57
-rw-r--r--app/services/issues/base_service.rb22
-rw-r--r--app/services/issues/build_service.rb4
-rw-r--r--app/services/issues/close_service.rb13
-rw-r--r--app/services/issues/move_service.rb4
-rw-r--r--app/services/issues/update_service.rb2
-rw-r--r--app/services/labels/available_labels_service.rb60
-rw-r--r--app/services/lfs/file_transformer.rb9
-rw-r--r--app/services/members/create_service.rb15
-rw-r--r--app/services/members/destroy_service.rb2
-rw-r--r--app/services/merge_requests/add_todo_when_build_fails_service.rb4
-rw-r--r--app/services/merge_requests/base_service.rb48
-rw-r--r--app/services/merge_requests/build_service.rb1
-rw-r--r--app/services/merge_requests/close_service.rb6
-rw-r--r--app/services/merge_requests/create_pipeline_service.rb37
-rw-r--r--app/services/merge_requests/create_service.rb2
-rw-r--r--app/services/merge_requests/delete_non_latest_diffs_service.rb4
-rw-r--r--app/services/merge_requests/merge_base_service.rb63
-rw-r--r--app/services/merge_requests/merge_service.rb60
-rw-r--r--app/services/merge_requests/merge_to_ref_service.rb57
-rw-r--r--app/services/merge_requests/merge_when_pipeline_succeeds_service.rb47
-rw-r--r--app/services/merge_requests/mergeability_check_service.rb82
-rw-r--r--app/services/merge_requests/migrate_external_diffs_service.rb23
-rw-r--r--app/services/merge_requests/post_merge_service.rb1
-rw-r--r--app/services/merge_requests/push_options_handler_service.rb162
-rw-r--r--app/services/merge_requests/rebase_service.rb12
-rw-r--r--app/services/merge_requests/refresh_service.rb29
-rw-r--r--app/services/merge_requests/reopen_service.rb2
-rw-r--r--app/services/merge_requests/squash_service.rb4
-rw-r--r--app/services/merge_requests/update_service.rb22
-rw-r--r--app/services/milestones/promote_service.rb10
-rw-r--r--app/services/note_summary.rb4
-rw-r--r--app/services/notes/create_service.rb11
-rw-r--r--app/services/notes/quick_actions_service.rb26
-rw-r--r--app/services/notes/update_service.rb2
-rw-r--r--app/services/notification_recipient_service.rb28
-rw-r--r--app/services/notification_service.rb30
-rw-r--r--app/services/pages_domains/create_acme_order_service.rb31
-rw-r--r--app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb41
-rw-r--r--app/services/preview_markdown_service.rb30
-rw-r--r--app/services/projects/cleanup_service.rb47
-rw-r--r--app/services/projects/create_service.rb14
-rw-r--r--app/services/projects/destroy_service.rb10
-rw-r--r--app/services/projects/detect_repository_languages_service.rb12
-rw-r--r--app/services/projects/download_service.rb2
-rw-r--r--app/services/projects/fetch_statistics_increment_service.rb32
-rw-r--r--app/services/projects/fork_service.rb20
-rw-r--r--app/services/projects/git_deduplication_service.rb64
-rw-r--r--app/services/projects/group_links/create_service.rb10
-rw-r--r--app/services/projects/hashed_storage/base_attachment_service.rb51
-rw-r--r--app/services/projects/hashed_storage/base_repository_service.rb22
-rw-r--r--app/services/projects/hashed_storage/migrate_attachments_service.rb49
-rw-r--r--app/services/projects/hashed_storage/migrate_repository_service.rb16
-rw-r--r--app/services/projects/hashed_storage/rollback_attachments_service.rb34
-rw-r--r--app/services/projects/hashed_storage/rollback_repository_service.rb40
-rw-r--r--app/services/projects/hashed_storage/rollback_service.rb37
-rw-r--r--app/services/projects/housekeeping_service.rb12
-rw-r--r--app/services/projects/import_error_filter.rb2
-rw-r--r--app/services/projects/import_service.rb23
-rw-r--r--app/services/projects/lfs_pointers/lfs_download_link_list_service.rb32
-rw-r--r--app/services/projects/lfs_pointers/lfs_download_service.rb20
-rw-r--r--app/services/projects/lfs_pointers/lfs_import_service.rb92
-rw-r--r--app/services/projects/lfs_pointers/lfs_link_service.rb4
-rw-r--r--app/services/projects/lfs_pointers/lfs_object_download_list_service.rb96
-rw-r--r--app/services/projects/move_project_group_links_service.rb2
-rw-r--r--app/services/projects/operations/update_service.rb32
-rw-r--r--app/services/projects/propagate_service_template.rb2
-rw-r--r--app/services/projects/repository_languages_service.rb24
-rw-r--r--app/services/projects/transfer_service.rb19
-rw-r--r--app/services/projects/update_service.rb17
-rw-r--r--app/services/projects/update_statistics_service.rb19
-rw-r--r--app/services/prometheus/proxy_service.rb116
-rw-r--r--app/services/push_event_payload_service.rb2
-rw-r--r--app/services/quick_actions/interpret_service.rb641
-rw-r--r--app/services/releases/concerns.rb2
-rw-r--r--app/services/releases/create_service.rb20
-rw-r--r--app/services/releases/destroy_service.rb1
-rw-r--r--app/services/resource_events/change_labels_service.rb2
-rw-r--r--app/services/search/global_service.rb3
-rw-r--r--app/services/search/group_service.rb6
-rw-r--r--app/services/search/project_service.rb7
-rw-r--r--app/services/search_service.rb4
-rw-r--r--app/services/service_response.rb32
-rw-r--r--app/services/suggestions/apply_service.rb14
-rw-r--r--app/services/suggestions/create_service.rb42
-rw-r--r--app/services/suggestions/outdate_service.rb19
-rw-r--r--app/services/system_hooks_service.rb6
-rw-r--r--app/services/system_note_service.rb24
-rw-r--r--app/services/tags/destroy_service.rb11
-rw-r--r--app/services/test_hooks/project_service.rb16
-rw-r--r--app/services/test_hooks/system_service.rb2
-rw-r--r--app/services/todo_service.rb16
-rw-r--r--app/services/todos/destroy/base_service.rb2
-rw-r--r--app/services/todos/destroy/confidential_issue_service.rb35
-rw-r--r--app/services/todos/destroy/entity_leave_service.rb13
-rw-r--r--app/services/update_deployment_service.rb2
-rw-r--r--app/services/upload_service.rb2
-rw-r--r--app/services/users/activity_service.rb2
-rw-r--r--app/services/users/build_service.rb6
-rw-r--r--app/services/users/migrate_to_ghost_user_service.rb2
-rw-r--r--app/services/users/refresh_authorized_projects_service.rb4
-rw-r--r--app/services/validate_new_branch_service.rb4
-rw-r--r--app/services/verify_pages_domain_service.rb15
-rw-r--r--app/uploaders/attachment_uploader.rb2
-rw-r--r--app/uploaders/avatar_uploader.rb2
-rw-r--r--app/uploaders/file_mover.rb8
-rw-r--r--app/uploaders/file_uploader.rb16
-rw-r--r--app/uploaders/import_export_uploader.rb4
-rw-r--r--app/uploaders/legacy_artifact_uploader.rb24
-rw-r--r--app/uploaders/object_storage.rb8
-rw-r--r--app/uploaders/personal_file_uploader.rb70
-rw-r--r--app/uploaders/records_uploads.rb4
-rw-r--r--app/validators/addressable_url_validator.rb112
-rw-r--r--app/validators/cluster_name_validator.rb8
-rw-r--r--app/validators/devise_email_validator.rb36
-rw-r--r--app/validators/email_validator.rb7
-rw-r--r--app/validators/public_url_validator.rb19
-rw-r--r--app/validators/sha_validator.rb9
-rw-r--r--app/validators/url_validator.rb104
-rw-r--r--app/validators/x509_certificate_credentials_validator.rb86
-rw-r--r--app/views/abuse_reports/new.html.haml14
-rw-r--r--app/views/admin/appearances/_system_header_footer_form.html.haml9
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml29
-rw-r--r--app/views/admin/application_settings/_ci_cd.html.haml29
-rw-r--r--app/views/admin/application_settings/_email.html.haml16
-rw-r--r--app/views/admin/application_settings/_external_authorization_service_form.html.haml51
-rw-r--r--app/views/admin/application_settings/_help_page.html.haml12
-rw-r--r--app/views/admin/application_settings/_logging.html.haml6
-rw-r--r--app/views/admin/application_settings/_outbound.html.haml8
-rw-r--r--app/views/admin/application_settings/_pages.html.haml28
-rw-r--r--app/views/admin/application_settings/_performance_bar.html.haml4
-rw-r--r--app/views/admin/application_settings/_repository_mirrors_form.html.haml10
-rw-r--r--app/views/admin/application_settings/_visibility_and_access.html.haml18
-rw-r--r--app/views/admin/application_settings/metrics_and_profiling.html.haml2
-rw-r--r--app/views/admin/application_settings/show.html.haml2
-rw-r--r--app/views/admin/applications/_form.html.haml12
-rw-r--r--app/views/admin/applications/show.html.haml4
-rw-r--r--app/views/admin/broadcast_messages/_form.html.haml16
-rw-r--r--app/views/admin/broadcast_messages/index.html.haml4
-rw-r--r--app/views/admin/dashboard/index.html.haml7
-rw-r--r--app/views/admin/deploy_keys/edit.html.haml8
-rw-r--r--app/views/admin/deploy_keys/index.html.haml20
-rw-r--r--app/views/admin/groups/_form.html.haml7
-rw-r--r--app/views/admin/groups/show.html.haml10
-rw-r--r--app/views/admin/health_check/show.html.haml2
-rw-r--r--app/views/admin/identities/_form.html.haml6
-rw-r--r--app/views/admin/labels/_form.html.haml14
-rw-r--r--app/views/admin/labels/_label.html.haml2
-rw-r--r--app/views/admin/projects/_projects.html.haml6
-rw-r--r--app/views/admin/projects/index.html.haml2
-rw-r--r--app/views/admin/projects/show.html.haml20
-rw-r--r--app/views/admin/runners/_runner.html.haml4
-rw-r--r--app/views/admin/runners/index.html.haml19
-rw-r--r--app/views/admin/users/_access_levels.html.haml24
-rw-r--r--app/views/admin/users/_form.html.haml68
-rw-r--r--app/views/admin/users/_head.html.haml1
-rw-r--r--app/views/admin/users/_user_detail.html.haml2
-rw-r--r--app/views/admin/users/show.html.haml14
-rw-r--r--app/views/award_emoji/_awards_block.html.haml6
-rw-r--r--app/views/ci/status/_dropdown_graph_badge.html.haml8
-rw-r--r--app/views/ci/status/_icon.html.haml16
-rw-r--r--app/views/ci/variables/_content.html.haml2
-rw-r--r--app/views/ci/variables/_header.html.haml2
-rw-r--r--app/views/ci/variables/_index.html.haml3
-rw-r--r--app/views/ci/variables/_variable_header.html.haml16
-rw-r--r--app/views/ci/variables/_variable_row.html.haml38
-rw-r--r--app/views/clusters/clusters/_advanced_settings.html.haml2
-rw-r--r--app/views/clusters/clusters/_banner.html.haml12
-rw-r--r--app/views/clusters/clusters/_form.html.haml6
-rw-r--r--app/views/clusters/clusters/_sidebar.html.haml2
-rw-r--r--app/views/clusters/clusters/gcp/_form.html.haml58
-rw-r--r--app/views/clusters/clusters/show.html.haml16
-rw-r--r--app/views/clusters/clusters/user/_form.html.haml80
-rw-r--r--app/views/clusters/platforms/kubernetes/_form.html.haml94
-rw-r--r--app/views/dashboard/_activity_head.html.haml2
-rw-r--r--app/views/dashboard/_groups_head.html.haml2
-rw-r--r--app/views/dashboard/_projects_head.html.haml26
-rw-r--r--app/views/dashboard/_snippets_head.html.haml2
-rw-r--r--app/views/dashboard/activity.html.haml2
-rw-r--r--app/views/dashboard/groups/_groups.html.haml4
-rw-r--r--app/views/dashboard/groups/index.html.haml2
-rw-r--r--app/views/dashboard/issues.html.haml4
-rw-r--r--app/views/dashboard/merge_requests.html.haml4
-rw-r--r--app/views/dashboard/milestones/index.html.haml2
-rw-r--r--app/views/dashboard/projects/_nav.html.haml27
-rw-r--r--app/views/dashboard/projects/_zero_authorized_projects.html.haml15
-rw-r--r--app/views/dashboard/projects/index.html.haml4
-rw-r--r--app/views/dashboard/projects/starred.html.haml4
-rw-r--r--app/views/dashboard/todos/_todo.html.haml2
-rw-r--r--app/views/dashboard/todos/index.html.haml6
-rw-r--r--app/views/devise/confirmations/new.html.haml2
-rw-r--r--app/views/devise/mailer/email_changed.html.haml2
-rw-r--r--app/views/devise/mailer/email_changed.text.erb2
-rw-r--r--app/views/devise/passwords/edit.html.haml2
-rw-r--r--app/views/devise/passwords/new.html.haml2
-rw-r--r--app/views/devise/registrations/edit.html.erb2
-rw-r--r--app/views/devise/shared/_signin_box.html.haml8
-rw-r--r--app/views/devise/shared/_signup_box.html.haml27
-rw-r--r--app/views/devise/shared/_tabs_ldap.html.haml1
-rw-r--r--app/views/devise/unlocks/new.html.haml2
-rw-r--r--app/views/discussions/_diff_discussion.html.haml2
-rw-r--r--app/views/discussions/_diff_with_notes.html.haml4
-rw-r--r--app/views/discussions/_notes.html.haml20
-rw-r--r--app/views/discussions/_parallel_diff_discussion.html.haml8
-rw-r--r--app/views/doorkeeper/applications/index.html.haml2
-rw-r--r--app/views/doorkeeper/applications/show.html.haml4
-rw-r--r--app/views/events/_event.html.haml6
-rw-r--r--app/views/events/event/_common.html.haml3
-rw-r--r--app/views/events/event/_note.html.haml3
-rw-r--r--app/views/events/event/_push.html.haml3
-rw-r--r--app/views/explore/groups/_groups.html.haml4
-rw-r--r--app/views/explore/groups/index.html.haml2
-rw-r--r--app/views/explore/projects/_filter.html.haml10
-rw-r--r--app/views/explore/projects/index.html.haml6
-rw-r--r--app/views/explore/projects/starred.html.haml6
-rw-r--r--app/views/explore/projects/trending.html.haml6
-rw-r--r--app/views/groups/_archived_projects.html.haml4
-rw-r--r--app/views/groups/_create_chat_team.html.haml9
-rw-r--r--app/views/groups/_group_admin_settings.html.haml11
-rw-r--r--app/views/groups/_home_panel.html.haml4
-rw-r--r--app/views/groups/_shared_projects.html.haml4
-rw-r--r--app/views/groups/_subgroups_and_projects.html.haml4
-rw-r--r--app/views/groups/edit.html.haml4
-rw-r--r--app/views/groups/group_members/_new_group_member.html.haml2
-rw-r--r--app/views/groups/group_members/index.html.haml4
-rw-r--r--app/views/groups/labels/index.html.haml7
-rw-r--r--app/views/groups/new.html.haml4
-rw-r--r--app/views/groups/settings/_general.html.haml20
-rw-r--r--app/views/groups/settings/_permissions.html.haml1
-rw-r--r--app/views/groups/settings/_project_creation_level.html.haml3
-rw-r--r--app/views/groups/settings/ci_cd/_auto_devops_form.html.haml15
-rw-r--r--app/views/groups/settings/ci_cd/show.html.haml16
-rw-r--r--app/views/groups/show.html.haml2
-rw-r--r--app/views/help/_shortcuts.html.haml12
-rw-r--r--app/views/help/index.html.haml13
-rw-r--r--app/views/help/instance_configuration.html.haml2
-rw-r--r--app/views/help/show.html.haml2
-rw-r--r--app/views/help/ui.html.haml4
-rw-r--r--app/views/import/bitbucket_server/status.html.haml2
-rw-r--r--app/views/import/gitea/new.html.haml6
-rw-r--r--app/views/import/gitea/status.html.haml2
-rw-r--r--app/views/import/github/new.html.haml2
-rw-r--r--app/views/import/gitlab_projects/new.html.haml23
-rw-r--r--app/views/import/manifest/new.html.haml6
-rw-r--r--app/views/import/phabricator/new.html.haml25
-rw-r--r--app/views/import/shared/_errors.html.haml4
-rw-r--r--app/views/import/shared/_new_project_form.html.haml21
-rw-r--r--app/views/issues/_issue.atom.builder1
-rw-r--r--app/views/layouts/_head.html.haml1
-rw-r--r--app/views/layouts/_mailer.html.haml6
-rw-r--r--app/views/layouts/_page.html.haml2
-rw-r--r--app/views/layouts/_piwik.html.haml2
-rw-r--r--app/views/layouts/_search.html.haml2
-rw-r--r--app/views/layouts/application.html.haml2
-rw-r--r--app/views/layouts/devise.html.haml15
-rw-r--r--app/views/layouts/empty_mailer.html.haml5
-rw-r--r--app/views/layouts/empty_mailer.text.erb5
-rw-r--r--app/views/layouts/header/_default.html.haml6
-rw-r--r--app/views/layouts/header/_help_dropdown.html.haml5
-rw-r--r--app/views/layouts/header/_new_dropdown.haml2
-rw-r--r--app/views/layouts/mailer.text.erb5
-rw-r--r--app/views/layouts/nav/_classification_level_banner.html.haml5
-rw-r--r--app/views/layouts/nav/_dashboard.html.haml37
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml26
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml17
-rw-r--r--app/views/layouts/nav/sidebar/_profile.html.haml6
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml12
-rw-r--r--app/views/layouts/notify.html.haml4
-rw-r--r--app/views/layouts/notify.text.erb5
-rw-r--r--app/views/notify/_note_email.text.erb4
-rw-r--r--app/views/notify/_reassigned_issuable_email.html.haml10
-rw-r--r--app/views/notify/_removal_notification.html.haml9
-rw-r--r--app/views/notify/closed_issue_email.html.haml2
-rw-r--r--app/views/notify/closed_issue_email.text.haml2
-rw-r--r--app/views/notify/closed_merge_request_email.text.haml2
-rw-r--r--app/views/notify/issue_due_email.html.haml2
-rw-r--r--app/views/notify/issue_due_email.text.erb2
-rw-r--r--app/views/notify/issue_moved_email.html.haml11
-rw-r--r--app/views/notify/issue_moved_email.text.erb4
-rw-r--r--app/views/notify/links/ci/builds/_build.text.erb2
-rw-r--r--app/views/notify/member_access_granted_email.html.haml11
-rw-r--r--app/views/notify/member_access_granted_email.text.erb7
-rw-r--r--app/views/notify/merge_request_status_email.text.haml2
-rw-r--r--app/views/notify/merge_request_unmergeable_email.text.haml2
-rw-r--r--app/views/notify/merged_merge_request_email.text.haml2
-rw-r--r--app/views/notify/new_issue_email.html.haml2
-rw-r--r--app/views/notify/new_issue_email.text.erb2
-rw-r--r--app/views/notify/new_mention_in_issue_email.text.erb2
-rw-r--r--app/views/notify/new_mention_in_merge_request_email.text.erb2
-rw-r--r--app/views/notify/new_merge_request_email.html.haml6
-rw-r--r--app/views/notify/new_merge_request_email.text.erb2
-rw-r--r--app/views/notify/new_user_email.html.haml3
-rw-r--r--app/views/notify/new_user_email.text.erb11
-rw-r--r--app/views/notify/pages_domain_disabled_email.html.haml4
-rw-r--r--app/views/notify/pages_domain_verification_failed_email.html.haml4
-rw-r--r--app/views/notify/reassigned_issue_email.html.haml11
-rw-r--r--app/views/notify/reassigned_merge_request_email.html.haml11
-rw-r--r--app/views/notify/reassigned_merge_request_email.text.erb4
-rw-r--r--app/views/profiles/_email_settings.html.haml16
-rw-r--r--app/views/profiles/_event_table.html.haml6
-rw-r--r--app/views/profiles/accounts/_providers.html.haml21
-rw-r--r--app/views/profiles/accounts/show.html.haml19
-rw-r--r--app/views/profiles/active_sessions/_active_session.html.haml17
-rw-r--r--app/views/profiles/active_sessions/index.html.haml4
-rw-r--r--app/views/profiles/audit_log.html.haml4
-rw-r--r--app/views/profiles/chat_names/_chat_name.html.haml4
-rw-r--r--app/views/profiles/chat_names/index.html.haml16
-rw-r--r--app/views/profiles/emails/index.html.haml6
-rw-r--r--app/views/profiles/gpg_keys/_form.html.haml6
-rw-r--r--app/views/profiles/gpg_keys/_key.html.haml16
-rw-r--r--app/views/profiles/gpg_keys/_key_table.html.haml4
-rw-r--r--app/views/profiles/gpg_keys/index.html.haml12
-rw-r--r--app/views/profiles/keys/_form.html.haml4
-rw-r--r--app/views/profiles/keys/_key.html.haml9
-rw-r--r--app/views/profiles/keys/_key_details.html.haml13
-rw-r--r--app/views/profiles/keys/_key_table.html.haml4
-rw-r--r--app/views/profiles/keys/index.html.haml8
-rw-r--r--app/views/profiles/keys/show.html.haml2
-rw-r--r--app/views/profiles/notifications/_email_settings.html.haml6
-rw-r--r--app/views/profiles/notifications/_group_settings.html.haml23
-rw-r--r--app/views/profiles/notifications/show.html.haml25
-rw-r--r--app/views/profiles/passwords/edit.html.haml25
-rw-r--r--app/views/profiles/passwords/new.html.haml15
-rw-r--r--app/views/profiles/preferences/show.html.haml53
-rw-r--r--app/views/profiles/show.html.haml47
-rw-r--r--app/views/profiles/two_factor_auths/_codes.html.haml10
-rw-r--r--app/views/profiles/two_factor_auths/codes.html.haml5
-rw-r--r--app/views/profiles/two_factor_auths/create.html.haml4
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml61
-rw-r--r--app/views/projects/_classification_policy_settings.html.haml6
-rw-r--r--app/views/projects/_export.html.haml67
-rw-r--r--app/views/projects/_files.html.haml9
-rw-r--r--app/views/projects/_flash_messages.html.haml4
-rw-r--r--app/views/projects/_home_panel.html.haml26
-rw-r--r--app/views/projects/_import_project_pane.html.haml30
-rw-r--r--app/views/projects/_md_preview.html.haml8
-rw-r--r--app/views/projects/_merge_request_merge_checks_settings.html.haml19
-rw-r--r--app/views/projects/_merge_request_merge_method_settings.html.haml46
-rw-r--r--app/views/projects/_merge_request_merge_options_settings.html.haml14
-rw-r--r--app/views/projects/_merge_request_merge_settings.html.haml23
-rw-r--r--app/views/projects/_merge_request_settings.html.haml4
-rw-r--r--app/views/projects/_new_project_fields.html.haml18
-rw-r--r--app/views/projects/_wiki.html.haml5
-rw-r--r--app/views/projects/_zen.html.haml1
-rw-r--r--app/views/projects/artifacts/browse.html.haml2
-rw-r--r--app/views/projects/blob/_header_content.html.haml4
-rw-r--r--app/views/projects/blob/_markdown_buttons.html.haml20
-rw-r--r--app/views/projects/blob/diff.html.haml6
-rw-r--r--app/views/projects/blob/preview.html.haml41
-rw-r--r--app/views/projects/blob/viewers/_dependency_manager.html.haml5
-rw-r--r--app/views/projects/blob/viewers/_markup.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_route_map.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_route_map_loading.html.haml2
-rw-r--r--app/views/projects/branches/_branch.html.haml4
-rw-r--r--app/views/projects/branches/_commit.html.haml4
-rw-r--r--app/views/projects/branches/index.html.haml3
-rw-r--r--app/views/projects/buttons/_clone.html.haml5
-rw-r--r--app/views/projects/buttons/_download.html.haml45
-rw-r--r--app/views/projects/buttons/_download_links.html.haml5
-rw-r--r--app/views/projects/ci/builds/_build.html.haml9
-rw-r--r--app/views/projects/cleanup/_show.html.haml2
-rw-r--r--app/views/projects/commit/_commit_box.html.haml2
-rw-r--r--app/views/projects/commits/_commit.html.haml17
-rw-r--r--app/views/projects/commits/_inline_commit.html.haml2
-rw-r--r--app/views/projects/commits/show.html.haml2
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml2
-rw-r--r--app/views/projects/default_branch/_show.html.haml2
-rw-r--r--app/views/projects/deploy_keys/_index.html.haml2
-rw-r--r--app/views/projects/deployments/_actions.haml4
-rw-r--r--app/views/projects/deployments/_commit.html.haml6
-rw-r--r--app/views/projects/deployments/_confirm_rollback_modal.html.haml23
-rw-r--r--app/views/projects/deployments/_deployment.html.haml2
-rw-r--r--app/views/projects/deployments/_rollback.haml3
-rw-r--r--app/views/projects/diffs/_content.html.haml2
-rw-r--r--app/views/projects/diffs/_line.html.haml2
-rw-r--r--app/views/projects/diffs/_parallel_view.html.haml8
-rw-r--r--app/views/projects/diffs/_replaced_image_diff.html.haml6
-rw-r--r--app/views/projects/diffs/_single_image_diff.html.haml2
-rw-r--r--app/views/projects/diffs/_text_file.html.haml4
-rw-r--r--app/views/projects/edit.html.haml352
-rw-r--r--app/views/projects/empty.html.haml133
-rw-r--r--app/views/projects/environments/_form.html.haml2
-rw-r--r--app/views/projects/forks/error.html.haml14
-rw-r--r--app/views/projects/forks/index.html.haml13
-rw-r--r--app/views/projects/forks/new.html.haml16
-rw-r--r--app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml4
-rw-r--r--app/views/projects/graphs/show.html.haml4
-rw-r--r--app/views/projects/issues/_closed_by_box.html.haml4
-rw-r--r--app/views/projects/issues/_issue.html.haml10
-rw-r--r--app/views/projects/issues/_merge_requests.html.haml36
-rw-r--r--app/views/projects/issues/_merge_requests_status.html.haml25
-rw-r--r--app/views/projects/issues/_new_branch.html.haml8
-rw-r--r--app/views/projects/issues/_related_branches.html.haml2
-rw-r--r--app/views/projects/issues/new.html.haml9
-rw-r--r--app/views/projects/issues/show.html.haml18
-rw-r--r--app/views/projects/jobs/_table.html.haml2
-rw-r--r--app/views/projects/jobs/show.html.haml1
-rw-r--r--app/views/projects/labels/index.html.haml41
-rw-r--r--app/views/projects/merge_requests/_merge_request.html.haml10
-rw-r--r--app/views/projects/merge_requests/_merge_requests.html.haml2
-rw-r--r--app/views/projects/merge_requests/_mr_box.html.haml2
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml6
-rw-r--r--app/views/projects/merge_requests/conflicts/_submit_form.html.haml2
-rw-r--r--app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml2
-rw-r--r--app/views/projects/merge_requests/conflicts/show.html.haml4
-rw-r--r--app/views/projects/merge_requests/show.html.haml46
-rw-r--r--app/views/projects/milestones/show.html.haml7
-rw-r--r--app/views/projects/mirrors/_authentication_method.html.haml22
-rw-r--r--app/views/projects/mirrors/_disabled_mirror_badge.html.haml1
-rw-r--r--app/views/projects/mirrors/_instructions.html.haml2
-rw-r--r--app/views/projects/mirrors/_mirror_repos.html.haml38
-rw-r--r--app/views/projects/mirrors/_mirror_repos_push.html.haml2
-rw-r--r--app/views/projects/mirrors/_ssh_host_keys.html.haml2
-rw-r--r--app/views/projects/new.html.haml11
-rw-r--r--app/views/projects/notes/_actions.html.haml6
-rw-r--r--app/views/projects/notes/_more_actions_dropdown.html.haml2
-rw-r--r--app/views/projects/pages/_https_only.html.haml2
-rw-r--r--app/views/projects/pages_domains/_form.html.haml91
-rw-r--r--app/views/projects/pages_domains/_helper_text.html.haml9
-rw-r--r--app/views/projects/pages_domains/edit.html.haml1
-rw-r--r--app/views/projects/pages_domains/new.html.haml1
-rw-r--r--app/views/projects/pipeline_schedules/_form.html.haml6
-rw-r--r--app/views/projects/pipelines/_info.html.haml14
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml30
-rw-r--r--app/views/projects/pipelines/charts.html.haml6
-rw-r--r--app/views/projects/pipelines/index.html.haml4
-rw-r--r--app/views/projects/pipelines/new.html.haml12
-rw-r--r--app/views/projects/pipelines/show.html.haml10
-rw-r--r--app/views/projects/project_members/_groups.html.haml2
-rw-r--r--app/views/projects/project_members/_new_project_member.html.haml3
-rw-r--r--app/views/projects/project_members/_team.html.haml2
-rw-r--r--app/views/projects/project_members/index.html.haml42
-rw-r--r--app/views/projects/protected_branches/_protected_branch.html.haml2
-rw-r--r--app/views/projects/protected_branches/shared/_create_protected_branch.html.haml4
-rw-r--r--app/views/projects/protected_branches/shared/_index.html.haml2
-rw-r--r--app/views/projects/protected_branches/shared/_protected_branch.html.haml2
-rw-r--r--app/views/projects/protected_tags/shared/_create_protected_tag.html.haml4
-rw-r--r--app/views/projects/protected_tags/shared/_index.html.haml2
-rw-r--r--app/views/projects/registry/repositories/_tag.html.haml2
-rw-r--r--app/views/projects/serverless/functions/index.html.haml5
-rw-r--r--app/views/projects/serverless/functions/show.html.haml11
-rw-r--r--app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml40
-rw-r--r--app/views/projects/services/slack_slash_commands/_help.html.haml38
-rw-r--r--app/views/projects/settings/_general.html.haml42
-rw-r--r--app/views/projects/settings/ci_cd/_autodevops_form.html.haml17
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml17
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml4
-rw-r--r--app/views/projects/settings/operations/_error_tracking.html.haml30
-rw-r--r--app/views/projects/settings/operations/_external_dashboard.html.haml3
-rw-r--r--app/views/projects/settings/operations/show.html.haml7
-rw-r--r--app/views/projects/settings/repository/_protected_branches.html.haml2
-rw-r--r--app/views/projects/settings/repository/show.html.haml7
-rw-r--r--app/views/projects/tags/_tag.html.haml5
-rw-r--r--app/views/projects/tags/index.html.haml2
-rw-r--r--app/views/projects/tags/show.html.haml9
-rw-r--r--app/views/projects/tree/_readme.html.haml2
-rw-r--r--app/views/projects/tree/_tree_commit_column.html.haml3
-rw-r--r--app/views/projects/tree/_tree_header.html.haml121
-rw-r--r--app/views/projects/wikis/pages.html.haml13
-rw-r--r--app/views/projects/wikis/show.html.haml2
-rw-r--r--app/views/repository_check_mailer/notify.html.haml2
-rw-r--r--app/views/repository_check_mailer/notify.text.haml2
-rw-r--r--app/views/search/_category.html.haml10
-rw-r--r--app/views/search/_form.html.haml1
-rw-r--r--app/views/search/_results.html.haml8
-rw-r--r--app/views/search/results/_blob.html.haml2
-rw-r--r--app/views/search/results/_issue.html.haml2
-rw-r--r--app/views/search/results/_merge_request.html.haml2
-rw-r--r--app/views/search/results/_milestone.html.haml2
-rw-r--r--app/views/search/results/_snippet_blob.html.haml2
-rw-r--r--app/views/search/results/_user.html.haml10
-rw-r--r--app/views/search/results/_wiki_blob.html.haml4
-rw-r--r--app/views/sent_notifications/unsubscribe.html.haml2
-rw-r--r--app/views/shared/_choose_avatar_button.html.haml4
-rw-r--r--app/views/shared/_choose_group_avatar_button.html.haml4
-rw-r--r--app/views/shared/_clone_panel.html.haml5
-rw-r--r--app/views/shared/_confirm_modal.html.haml8
-rw-r--r--app/views/shared/_delete_label_modal.html.haml6
-rw-r--r--app/views/shared/_file_highlight.html.haml2
-rw-r--r--app/views/shared/_import_form.html.haml30
-rw-r--r--app/views/shared/_issuable_meta_data.html.haml2
-rw-r--r--app/views/shared/_label.html.haml10
-rw-r--r--app/views/shared/_label_row.html.haml17
-rw-r--r--app/views/shared/_mini_pipeline_graph.html.haml6
-rw-r--r--app/views/shared/_mobile_clone_panel.html.haml1
-rw-r--r--app/views/shared/_old_visibility_level.html.haml2
-rw-r--r--app/views/shared/_remote_mirror_update_button.html.haml2
-rw-r--r--app/views/shared/_sidebar_toggle_button.html.haml4
-rw-r--r--app/views/shared/boards/_show.html.haml10
-rw-r--r--app/views/shared/boards/components/_board.html.haml13
-rw-r--r--app/views/shared/boards/components/_sidebar.html.haml7
-rw-r--r--app/views/shared/boards/components/sidebar/_assignee.html.haml2
-rw-r--r--app/views/shared/boards/components/sidebar/_due_date.html.haml2
-rw-r--r--app/views/shared/boards/components/sidebar/_labels.html.haml27
-rw-r--r--app/views/shared/boards/components/sidebar/_time_tracker.html.haml6
-rw-r--r--app/views/shared/deploy_keys/_form.html.haml9
-rw-r--r--app/views/shared/form_elements/_description.html.haml2
-rw-r--r--app/views/shared/groups/_dropdown.html.haml6
-rw-r--r--app/views/shared/icons/_emoji_slightly_smiling_face.svg1
-rw-r--r--app/views/shared/icons/_emoji_smile.svg1
-rw-r--r--app/views/shared/icons/_emoji_smiley.svg1
-rw-r--r--app/views/shared/icons/_gitea_logo.svg.erb1
-rw-r--r--app/views/shared/issuable/_assignees.html.haml6
-rw-r--r--app/views/shared/issuable/_board_create_list_dropdown.html.haml2
-rw-r--r--app/views/shared/issuable/_bulk_update_sidebar.html.haml5
-rw-r--r--app/views/shared/issuable/_label_dropdown.html.haml4
-rw-r--r--app/views/shared/issuable/_label_page_create.html.haml12
-rw-r--r--app/views/shared/issuable/_label_page_default.html.haml2
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml12
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml16
-rw-r--r--app/views/shared/issuable/_sidebar_assignees.html.haml56
-rw-r--r--app/views/shared/issuable/_sort_dropdown.html.haml17
-rw-r--r--app/views/shared/issuable/form/_contribution.html.haml2
-rw-r--r--app/views/shared/issuable/form/_merge_request_assignee.html.haml31
-rw-r--r--app/views/shared/issuable/form/_metadata.html.haml7
-rw-r--r--app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml (renamed from app/views/shared/issuable/form/_metadata_issue_assignee.html.haml)6
-rw-r--r--app/views/shared/issuable/form/_title.html.haml2
-rw-r--r--app/views/shared/labels/_form.html.haml20
-rw-r--r--app/views/shared/members/_access_request_links.html.haml2
-rw-r--r--app/views/shared/members/_group.html.haml19
-rw-r--r--app/views/shared/members/_member.html.haml41
-rw-r--r--app/views/shared/milestones/_issuable.html.haml3
-rw-r--r--app/views/shared/milestones/_labels_tab.html.haml5
-rw-r--r--app/views/shared/milestones/_milestone.html.haml4
-rw-r--r--app/views/shared/milestones/_tabs.html.haml6
-rw-r--r--app/views/shared/milestones/_top.html.haml32
-rw-r--r--app/views/shared/notes/_form.html.haml2
-rw-r--r--app/views/shared/notes/_hints.html.haml5
-rw-r--r--app/views/shared/notes/_note.html.haml17
-rw-r--r--app/views/shared/notifications/_button.html.haml14
-rw-r--r--app/views/shared/notifications/_new_button.html.haml6
-rw-r--r--app/views/shared/notifications/_notification_dropdown.html.haml2
-rw-r--r--app/views/shared/projects/_dropdown.html.haml21
-rw-r--r--app/views/shared/projects/_project.html.haml5
-rw-r--r--app/views/shared/projects/_search_bar.html.haml28
-rw-r--r--app/views/shared/projects/_search_form.html.haml7
-rw-r--r--app/views/shared/projects/_sort_dropdown.html.haml39
-rw-r--r--app/views/shared/snippets/_form.html.haml12
-rw-r--r--app/views/shared/snippets/_header.html.haml10
-rw-r--r--app/views/snippets/_actions.html.haml2
-rw-r--r--app/views/snippets/new.html.haml2
-rw-r--r--app/views/snippets/notes/_actions.html.haml6
-rw-r--r--app/views/users/calendar_activities.html.haml2
-rw-r--r--app/views/users/show.html.haml8
-rw-r--r--app/workers/all_queues.yml16
-rw-r--r--app/workers/auto_merge_process_worker.rb14
-rw-r--r--app/workers/build_finished_worker.rb1
-rw-r--r--app/workers/ci/build_prepare_worker.rb16
-rw-r--r--app/workers/cluster_configure_worker.rb6
-rw-r--r--app/workers/cluster_patch_app_worker.rb13
-rw-r--r--app/workers/clusters/applications/uninstall_worker.rb17
-rw-r--r--app/workers/clusters/applications/wait_for_uninstall_app_worker.rb20
-rw-r--r--app/workers/concerns/application_worker.rb2
-rw-r--r--app/workers/concerns/waitable_worker.rb8
-rw-r--r--app/workers/create_gpg_signature_worker.rb12
-rw-r--r--app/workers/deployments/finished_worker.rb13
-rw-r--r--app/workers/detect_repository_languages_worker.rb7
-rw-r--r--app/workers/email_receiver_worker.rb16
-rw-r--r--app/workers/emails_on_push_worker.rb34
-rw-r--r--app/workers/expire_pipeline_cache_worker.rb51
-rw-r--r--app/workers/git_garbage_collect_worker.rb15
-rw-r--r--app/workers/hashed_storage/base_worker.rb21
-rw-r--r--app/workers/hashed_storage/project_migrate_worker.rb26
-rw-r--r--app/workers/hashed_storage/project_rollback_worker.rb26
-rw-r--r--app/workers/hashed_storage/rollbacker_worker.rb16
-rw-r--r--app/workers/migrate_external_diffs_worker.rb12
-rw-r--r--app/workers/object_storage/migrate_uploads_worker.rb22
-rw-r--r--app/workers/pages_domain_removal_cron_worker.rb14
-rw-r--r--app/workers/pages_domain_verification_cron_worker.rb2
-rw-r--r--app/workers/pages_domain_verification_worker.rb2
-rw-r--r--app/workers/pipeline_metrics_worker.rb2
-rw-r--r--app/workers/pipeline_schedule_worker.rb36
-rw-r--r--app/workers/pipeline_success_worker.rb8
-rw-r--r--app/workers/post_receive.rb39
-rw-r--r--app/workers/process_commit_worker.rb2
-rw-r--r--app/workers/project_cache_worker.rb25
-rw-r--r--app/workers/project_daily_statistics_worker.rb13
-rw-r--r--app/workers/project_migrate_hashed_storage_worker.rb43
-rw-r--r--app/workers/reactive_caching_worker.rb7
-rw-r--r--app/workers/remove_expired_members_worker.rb8
-rw-r--r--app/workers/run_pipeline_schedule_worker.rb26
-rw-r--r--app/workers/schedule_migrate_external_diffs_worker.rb14
-rw-r--r--app/workers/todos_destroyer/confidential_issue_worker.rb4
-rw-r--r--app/workers/update_project_statistics_worker.rb18
1886 files changed, 29565 insertions, 13635 deletions
diff --git a/app/assets/images/favicon-yellow.png b/app/assets/images/favicon-yellow.png
index 2d5289818b4..a80827808fc 100644
--- a/app/assets/images/favicon-yellow.png
+++ b/app/assets/images/favicon-yellow.png
Binary files differ
diff --git a/app/assets/images/select2-spinner.gif b/app/assets/images/select2-spinner.gif
new file mode 100644
index 00000000000..5b33f7e54f4
--- /dev/null
+++ b/app/assets/images/select2-spinner.gif
Binary files differ
diff --git a/app/assets/images/select2.png b/app/assets/images/select2.png
new file mode 100644
index 00000000000..1d804ffb996
--- /dev/null
+++ b/app/assets/images/select2.png
Binary files differ
diff --git a/app/assets/images/select2x2.png b/app/assets/images/select2x2.png
new file mode 100644
index 00000000000..4bdd5c961d4
--- /dev/null
+++ b/app/assets/images/select2x2.png
Binary files differ
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 85eb08cc97d..7cebb88f3a4 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import _ from 'underscore';
import axios from './lib/utils/axios_utils';
+import { joinPaths } from './lib/utils/url_utility';
const Api = {
groupsPath: '/api/:version/groups.json',
@@ -11,7 +12,8 @@ const Api = {
groupProjectsPath: '/api/:version/groups/:id/projects.json',
projectsPath: '/api/:version/projects.json',
projectPath: '/api/:version/projects/:id',
- projectLabelsPath: '/:namespace_path/:project_path/labels',
+ projectLabelsPath: '/:namespace_path/:project_path/-/labels',
+ projectMergeRequestsPath: '/api/:version/projects/:id/merge_requests',
projectMergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid',
projectMergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes',
projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions',
@@ -111,6 +113,22 @@ const Api = {
return axios.get(url);
},
+ /**
+ * Get all Merge Requests for a project, eventually filtering based on
+ * supplied parameters
+ * @param projectPath
+ * @param params
+ * @returns {Promise}
+ */
+ projectMergeRequests(projectPath, params = {}) {
+ const url = Api.buildUrl(Api.projectMergeRequestsPath).replace(
+ ':id',
+ encodeURIComponent(projectPath),
+ );
+
+ return axios.get(url, { params });
+ },
+
// Return Merge Request for project
projectMergeRequest(projectPath, mergeRequestId, params = {}) {
const url = Api.buildUrl(Api.projectMergeRequestPath)
@@ -322,11 +340,7 @@ const Api = {
},
buildUrl(url) {
- let urlRoot = '';
- if (gon.relative_url_root != null) {
- urlRoot = gon.relative_url_root;
- }
- return urlRoot + url.replace(':version', gon.api_version);
+ return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
},
};
diff --git a/app/assets/javascripts/group_avatar.js b/app/assets/javascripts/avatar_picker.js
index dcda625f587..d38e0b4abaa 100644
--- a/app/assets/javascripts/group_avatar.js
+++ b/app/assets/javascripts/avatar_picker.js
@@ -1,11 +1,12 @@
import $ from 'jquery';
-export default function groupAvatar() {
- $('.js-choose-group-avatar-button').on('click', function onClickGroupAvatar() {
+export default function initAvatarPicker() {
+ $('.js-choose-avatar-button').on('click', function onClickAvatar() {
const form = $(this).closest('form');
- return form.find('.js-group-avatar-input').click();
+ return form.find('.js-avatar-input').click();
});
- $('.js-group-avatar-input').on('change', function onChangeAvatarInput() {
+
+ $('.js-avatar-input').on('change', function onChangeAvatarInput() {
const form = $(this).closest('form');
const filename = $(this)
.val()
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 73ce3e760ab..743f11625bc 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -8,6 +8,7 @@ import { updateTooltipTitle } from './lib/utils/common_utils';
import { isInVueNoteablePage } from './lib/utils/dom_utils';
import flash from './flash';
import axios from './lib/utils/axios_utils';
+import bp from './breakpoints';
const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd';
const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd';
@@ -264,7 +265,10 @@ export class AwardsHandler {
const css = {
top: `${$addBtn.offset().top + $addBtn.outerHeight()}px`,
};
- if (position === 'right') {
+ // for xs screen we position the element on center
+ if (bp.getBreakpointSize() === 'xs') {
+ css.left = '5%';
+ } else if (position === 'right') {
css.left = `${$addBtn.offset().left - $menu.outerWidth() + 20}px`;
$menu.addClass('is-aligned-right');
} else {
diff --git a/app/assets/javascripts/batch_comments/mixins/resolved_status.js b/app/assets/javascripts/batch_comments/mixins/resolved_status.js
new file mode 100644
index 00000000000..3bbbaa86b51
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/mixins/resolved_status.js
@@ -0,0 +1,15 @@
+import { sprintf, __ } from '~/locale';
+
+export default {
+ computed: {
+ resolveButtonTitle() {
+ let title = __('Mark comment as resolved');
+
+ if (this.resolvedBy) {
+ title = sprintf(__('Resolved by %{name}'), { name: this.resolvedBy.name });
+ }
+
+ return title;
+ },
+ },
+};
diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js
index 9a33a060c76..c3541e62568 100644
--- a/app/assets/javascripts/behaviors/copy_to_clipboard.js
+++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import Clipboard from 'clipboard';
+import { sprintf, __ } from '~/locale';
function showTooltip(target, title) {
const $target = $(target);
@@ -16,7 +17,7 @@ function showTooltip(target, title) {
}
function genericSuccess(e) {
- showTooltip(e.trigger, 'Copied');
+ showTooltip(e.trigger, __('Copied'));
// Clear the selection and blur the trigger so it loses its border
e.clearSelection();
$(e.trigger).blur();
@@ -33,7 +34,7 @@ function genericError(e) {
} else {
key = 'Ctrl';
}
- showTooltip(e.trigger, `Press ${key}-C to copy`);
+ showTooltip(e.trigger, sprintf(__(`Press %{key}-C to copy`), { key }));
}
export default function initCopyToClipboard() {
diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
index 9482a9f166d..318b7f77c7b 100644
--- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
@@ -10,10 +10,10 @@ export class CopyAsGFM {
const isIOS = /\b(iPad|iPhone|iPod)(?=;)/.test(userAgent);
if (isIOS) return;
- $(document).on('copy', '.md, .wiki', e => {
+ $(document).on('copy', '.md', e => {
CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformGFMSelection);
});
- $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', e => {
+ $(document).on('copy', 'pre.code.highlight, table.code td.line_content', e => {
CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformCodeSelection);
});
$(document).on('paste', '.js-gfm-input', CopyAsGFM.pasteGFM);
@@ -99,7 +99,7 @@ export class CopyAsGFM {
}
static transformGFMSelection(documentFragment) {
- const gfmElements = documentFragment.querySelectorAll('.md, .wiki');
+ const gfmElements = documentFragment.querySelectorAll('.md');
switch (gfmElements.length) {
case 0: {
return documentFragment;
@@ -173,7 +173,9 @@ export class CopyAsGFM {
wrapEl.appendChild(node.cloneNode(true));
const doc = DOMParser.fromSchema(schema.default).parse(wrapEl);
- const res = markdownSerializer.default.serialize(doc);
+ const res = markdownSerializer.default.serialize(doc, {
+ tightLists: true,
+ });
return res;
})
.catch(() => {});
diff --git a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js
index 55c68139ded..b7200150925 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 '~/gfm_auto_complete';
+import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
export default function initGFMInput() {
$('.js-gfm-input:not(.js-vue-textarea)').each((i, el) => {
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js
index 20c7fa8a9ab..9a2e2c03213 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this */
import { Node } from 'tiptap';
+import { __ } from '~/locale';
// Transforms generated HTML back to GFM for Banzai::Filter::TableOfContentsFilter
export default class TableOfContents extends Node {
@@ -22,7 +23,7 @@ export default class TableOfContents extends Node {
priority: 51,
},
],
- toDOM: () => ['p', { class: 'table-of-contents' }, 'Table of Contents'],
+ toDOM: () => ['p', { class: 'table-of-contents' }, __('Table of Contents')],
};
}
diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js
index fc9286d15e6..bfb073fdcdc 100644
--- a/app/assets/javascripts/behaviors/markdown/render_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js
@@ -4,6 +4,7 @@ import renderMath from './render_math';
import renderMermaid from './render_mermaid';
import highlightCurrentUser from './highlight_current_user';
import initUserPopovers from '../../user_popovers';
+import initMRPopovers from '../../mr_popover';
// Render GitLab flavoured Markdown
//
@@ -15,6 +16,7 @@ $.fn.renderGFM = function renderGFM() {
renderMermaid(this.find('.js-render-mermaid'));
highlightCurrentUser(this.find('.gfm-project_member').get());
initUserPopovers(this.find('.gfm-project_member').get());
+ initMRPopovers(this.find('.gfm-merge_request').get());
return this;
};
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
index 35380ca49fb..d0b7f3ff7a2 100644
--- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
@@ -1,4 +1,5 @@
import flash from '~/flash';
+import { sprintf, __ } from '../../locale';
// Renders diagrams and flowcharts from text using Mermaid in any element with the
// `js-render-mermaid` class.
@@ -14,6 +15,9 @@ import flash from '~/flash';
// </pre>
//
+// This is an arbitrary number; Can be iterated upon when suitable.
+const MAX_CHAR_LIMIT = 5000;
+
export default function renderMermaid($els) {
if (!$els.length) return;
@@ -34,6 +38,21 @@ export default function renderMermaid($els) {
$els.each((i, el) => {
const source = el.textContent;
+ /**
+ * Restrict the rendering to a certain amount of character to
+ * prevent mermaidjs from hanging up the entire thread and
+ * causing a DoS.
+ */
+ if (source && source.length > MAX_CHAR_LIMIT) {
+ el.textContent = sprintf(
+ __(
+ 'Cannot render the image. Maximum character count (%{charLimit}) has been exceeded.',
+ ),
+ { charLimit: MAX_CHAR_LIMIT },
+ );
+ return;
+ }
+
// Remove any extra spans added by the backend syntax highlighting.
Object.assign(el, { textContent: source });
diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js
index 7adccbb062f..35874140bf9 100644
--- a/app/assets/javascripts/behaviors/preview_markdown.js
+++ b/app/assets/javascripts/behaviors/preview_markdown.js
@@ -22,7 +22,7 @@ function MarkdownPreview() {}
// Minimum number of users referenced before triggering a warning
MarkdownPreview.prototype.referenceThreshold = 10;
-MarkdownPreview.prototype.emptyMessage = 'Nothing to preview.';
+MarkdownPreview.prototype.emptyMessage = __('Nothing to preview.');
MarkdownPreview.prototype.ajaxCache = {};
@@ -40,7 +40,7 @@ MarkdownPreview.prototype.showPreview = function($form) {
preview.text(this.emptyMessage);
this.hideReferencedUsers($form);
} else {
- preview.addClass('md-preview-loading').text('Loading...');
+ preview.addClass('md-preview-loading').text(__('Loading...'));
this.fetchMarkdownPreview(
mdText,
url,
diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js
index c1ea67f9293..530ab0bd4d9 100644
--- a/app/assets/javascripts/behaviors/quick_submit.js
+++ b/app/assets/javascripts/behaviors/quick_submit.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import '../commons/bootstrap';
import { isInIssuePage } from '../lib/utils/common_utils';
+import { __ } from '~/locale';
// Quick Submit behavior
//
@@ -65,7 +66,9 @@ $(document).on(
}
const $this = $(this);
- const title = isMac() ? 'You can also press &#8984;-Enter' : 'You can also press Ctrl-Enter';
+ const title = isMac()
+ ? __('You can also press &#8984;-Enter')
+ : __('You can also press Ctrl-Enter');
$this.tooltip({
container: 'body',
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
index 680f2031409..c8eb96a625c 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
@@ -37,7 +37,7 @@ export default class ShortcutsIssuable extends Shortcuts {
}
// Sanity check: Make sure the selected text comes from a discussion : it can either contain a message...
- let foundMessage = !!documentFragment.querySelector('.md, .wiki');
+ let foundMessage = Boolean(documentFragment.querySelector('.md'));
// ... Or come from a message
if (!foundMessage) {
@@ -46,7 +46,7 @@ export default class ShortcutsIssuable extends Shortcuts {
let node = e;
do {
// Text nodes don't define the `matches` method
- if (node.matches && node.matches('.md, .wiki')) {
+ if (node.matches && node.matches('.md')) {
foundMessage = true;
}
node = node.parentNode;
diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js
index b88e69a07bf..2e537d8c000 100644
--- a/app/assets/javascripts/blob/balsamiq_viewer.js
+++ b/app/assets/javascripts/blob/balsamiq_viewer.js
@@ -1,8 +1,9 @@
import Flash from '../flash';
import BalsamiqViewer from './balsamiq/balsamiq_viewer';
+import { __ } from '~/locale';
function onError() {
- const flash = new Flash('Balsamiq file could not be loaded.');
+ const flash = new Flash(__('Balsamiq file could not be loaded.'));
return flash;
}
diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js
index cd3251ad1ca..9010cd0c3c1 100644
--- a/app/assets/javascripts/blob/blob_file_dropzone.js
+++ b/app/assets/javascripts/blob/blob_file_dropzone.js
@@ -5,6 +5,7 @@ import Dropzone from 'dropzone';
import { visitUrl } from '../lib/utils/url_utility';
import { HIDDEN_CLASS } from '../lib/utils/constants';
import csrf from '../lib/utils/csrf';
+import { sprintf, __ } from '~/locale';
Dropzone.autoDiscover = false;
@@ -73,7 +74,7 @@ export default class BlobFileDropzone {
.html(errorMessage)
.text();
$('.dropzone-alerts')
- .html(`Error uploading file: "${stripped}"`)
+ .html(sprintf(__('Error uploading file: %{stripped}'), { stripped }))
.show();
this.removeFile(file);
},
@@ -84,7 +85,7 @@ export default class BlobFileDropzone {
e.stopPropagation();
if (dropzone[0].dropzone.getQueuedFiles().length === 0) {
// eslint-disable-next-line no-alert
- alert('Please select a file');
+ alert(__('Please select a file'));
return false;
}
toggleLoading(submitButton, submitButtonLoadingIcon, true);
diff --git a/app/assets/javascripts/blob/sketch/index.js b/app/assets/javascripts/blob/sketch/index.js
index 57c1baa9886..dbff03dc734 100644
--- a/app/assets/javascripts/blob/sketch/index.js
+++ b/app/assets/javascripts/blob/sketch/index.js
@@ -1,5 +1,6 @@
import JSZip from 'jszip';
import JSZipUtils from 'jszip-utils';
+import { __ } from '~/locale';
export default class SketchLoader {
constructor(container) {
@@ -56,10 +57,10 @@ export default class SketchLoader {
const errorMsg = document.createElement('p');
errorMsg.className = 'prepend-top-default append-bottom-default text-center';
- errorMsg.textContent = `
+ errorMsg.textContent = __(`
Cannot show preview. For previews on sketch files, they must have the file format
introduced by Sketch version 43 and above.
- `;
+ `);
this.container.appendChild(errorMsg);
this.removeLoadingIcon();
diff --git a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
index 4718b642617..659d57e6a6f 100644
--- a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
@@ -1,11 +1,12 @@
import FileTemplateSelector from '../file_template_selector';
+import { __ } from '~/locale';
export default class DockerfileSelector extends FileTemplateSelector {
constructor({ mediator }) {
super(mediator);
this.config = {
key: 'dockerfile',
- name: 'Dockerfile',
+ name: __('Dockerfile'),
pattern: /(Dockerfile)/,
type: 'dockerfiles',
dropdown: '.js-dockerfile-selector',
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index d0359fc5fe9..d246a1f6064 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import Flash from '../../flash';
import { handleLocationHash } from '../../lib/utils/common_utils';
import axios from '../../lib/utils/axios_utils';
+import { __ } from '~/locale';
export default class BlobViewer {
constructor() {
@@ -26,7 +27,7 @@ export default class BlobViewer {
promise
.then(module => module.default(viewer))
.catch(error => {
- Flash('Error loading file viewer.');
+ Flash(__('Error loading file viewer.'));
throw error;
});
@@ -106,16 +107,19 @@ export default class BlobViewer {
if (!this.copySourceBtn) return;
if (this.simpleViewer.getAttribute('data-loaded')) {
- this.copySourceBtn.setAttribute('title', 'Copy source to clipboard');
+ this.copySourceBtn.setAttribute('title', __('Copy source to clipboard'));
this.copySourceBtn.classList.remove('disabled');
} else if (this.activeViewer === this.simpleViewer) {
this.copySourceBtn.setAttribute(
'title',
- 'Wait for the source to load to copy it to the clipboard',
+ __('Wait for the source to load to copy it to the clipboard'),
);
this.copySourceBtn.classList.add('disabled');
} else {
- this.copySourceBtn.setAttribute('title', 'Switch to the source to copy it to the clipboard');
+ this.copySourceBtn.setAttribute(
+ 'title',
+ __('Switch to the source to copy it to the clipboard'),
+ );
this.copySourceBtn.classList.add('disabled');
}
@@ -158,7 +162,7 @@ export default class BlobViewer {
this.toggleCopyButtonState();
})
- .catch(() => new Flash('Error loading viewer'));
+ .catch(() => new Flash(__('Error loading viewer')));
}
static loadViewer(viewerParam) {
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index 5f64175362d..6aaf5bf7296 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -13,7 +13,7 @@ export default () => {
if (editBlobForm.length) {
const urlRoot = editBlobForm.data('relativeUrlRoot');
const assetsPath = editBlobForm.data('assetsPrefix');
- const filePath = editBlobForm.data('blobFilename');
+ const filePath = `${editBlobForm.data('blobFilename')}`;
const currentAction = $('.js-file-title').data('currentAction');
const projectId = editBlobForm.data('project-id');
const isMarkdown = editBlobForm.data('is-markdown');
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
new file mode 100644
index 00000000000..3178bda93b8
--- /dev/null
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -0,0 +1,7 @@
+export function getMilestone() {
+ return null;
+}
+
+export default {
+ getMilestone,
+};
diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js
index fb6e5291a61..45b9e57f9ab 100644
--- a/app/assets/javascripts/boards/components/board.js
+++ b/app/assets/javascripts/boards/components/board.js
@@ -54,7 +54,10 @@ export default Vue.extend({
return `${n__('%d issue', '%d issues', issuesSize)}`;
},
isNewIssueShown() {
- return this.list.type === 'backlog' || (!this.disabled && this.list.type !== 'closed');
+ return (
+ this.list.type === 'backlog' ||
+ (!this.disabled && this.list.type !== 'closed' && this.list.type !== 'blank')
+ );
},
},
watch: {
diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue
index 667eea17d44..1cbd31729cd 100644
--- a/app/assets/javascripts/boards/components/board_blank_state.vue
+++ b/app/assets/javascripts/boards/components/board_blank_state.vue
@@ -1,6 +1,5 @@
<script>
/* global ListLabel */
-import _ from 'underscore';
import Cookies from 'js-cookie';
import boardsStore from '../stores/boards_store';
@@ -29,8 +28,6 @@ export default {
});
});
- boardsStore.state.lists = _.sortBy(boardsStore.state.lists, 'position');
-
// Save the labels
gl.boardService
.generateDefaultLists()
@@ -60,11 +57,15 @@ export default {
</script>
<template>
- <div class="board-blank-state">
+ <div class="board-blank-state p-3">
<p>Add the following default lists to your Issue Board with one click:</p>
- <ul class="board-blank-state-list">
+ <ul class="list-unstyled board-blank-state-list">
<li v-for="(label, index) in predefinedLabels" :key="index">
- <span :style="{ backgroundColor: label.color }" class="label-color"> </span>
+ <span
+ :style="{ backgroundColor: label.color }"
+ class="label-color position-relative d-inline-block rounded"
+ >
+ </span>
{{ label.title }}
</li>
</ul>
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index f569322ab70..179148b6887 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -66,7 +66,7 @@ export default {
eventHub.$emit('clearDetailIssue');
} else {
eventHub.$emit('newDetailIssue', this.issue);
- boardsStore.detail.list = this.list;
+ boardsStore.setListDetail(this.list);
}
}
},
@@ -83,7 +83,7 @@ export default {
}"
:index="index"
:data-issue-id="issue.id"
- class="board-card"
+ class="board-card p-3 rounded"
@mousedown="mouseDown"
@mousemove="mouseMove"
@mouseup="showIssue($event)"
diff --git a/app/assets/javascripts/boards/components/board_delete.js b/app/assets/javascripts/boards/components/board_delete.js
index a5f9d65e4d5..a06db359c94 100644
--- a/app/assets/javascripts/boards/components/board_delete.js
+++ b/app/assets/javascripts/boards/components/board_delete.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import Vue from 'vue';
+import { __ } from '~/locale';
export default Vue.extend({
props: {
@@ -13,7 +14,7 @@ export default Vue.extend({
$(this.$el).tooltip('hide');
// eslint-disable-next-line no-alert
- if (window.confirm('Are you sure you want to delete this list?')) {
+ if (window.confirm(__('Are you sure you want to delete this list?'))) {
this.list.destroy();
}
},
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index f3f341ece5c..b1a8b13f3ac 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -142,8 +142,10 @@ export default {
const card = this.$refs.issue[e.oldIndex];
card.showDetail = false;
- boardsStore.moving.list = card.list;
- boardsStore.moving.issue = boardsStore.moving.list.findIssue(+e.item.dataset.issueId);
+
+ const { list } = card;
+ const issue = list.findIssue(Number(e.item.dataset.issueId));
+ boardsStore.startMoving(list, issue);
sortableStart();
},
@@ -221,7 +223,10 @@ export default {
</script>
<template>
- <div class="board-list-component">
+ <div
+ :class="{ 'd-none': !list.isExpanded, 'd-flex flex-column': list.isExpanded }"
+ class="board-list-component position-relative h-100"
+ >
<div v-if="loading" class="board-list-loading text-center" aria-label="Loading issues">
<gl-loading-icon />
</div>
@@ -236,7 +241,7 @@ export default {
:data-board="list.id"
:data-board-type="list.type"
:class="{ 'is-smaller': showIssueForm }"
- class="board-list js-board-list"
+ class="board-list w-100 h-100 list-unstyled mb-0 p-1 js-board-list"
>
<board-card
v-for="(issue, index) in issues"
diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue
index 28d96dab605..cc6af8e88cd 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -1,6 +1,7 @@
<script>
import $ from 'jquery';
import { GlButton } from '@gitlab/ui';
+import { getMilestone } from 'ee_else_ce/boards/boards_util';
import eventHub from '../eventhub';
import ProjectSelect from './project_select.vue';
import ListIssue from '../models/issue';
@@ -51,11 +52,14 @@ export default {
const labels = this.list.label ? [this.list.label] : [];
const assignees = this.list.assignee ? [this.list.assignee] : [];
+ const milestone = getMilestone(this.list);
+
const issue = new ListIssue({
title: this.title,
labels,
subscribed: true,
assignees,
+ milestone,
project_id: this.selectedProject.id,
});
@@ -68,8 +72,8 @@ export default {
// Need this because our jQuery very kindly disables buttons on ALL form submissions
$(this.$refs.submitButton).enable();
- boardsStore.detail.issue = issue;
- boardsStore.detail.list = this.list;
+ boardsStore.setIssueDetail(issue);
+ boardsStore.setListDetail(this.list);
})
.catch(() => {
// Need this because our jQuery very kindly disables buttons on ALL form submissions
@@ -95,7 +99,7 @@ export default {
<template>
<div class="board-new-issue-form">
- <div class="board-card">
+ <div class="board-card position-relative p-3 rounded">
<form @submit="submit($event)">
<div v-if="error" class="flash-container">
<div class="flash-alert">An error occurred. Please try again.</div>
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index e637e1f1223..c587b276fa3 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -2,19 +2,21 @@
import $ from 'jquery';
import Vue from 'vue';
-import Flash from '../../flash';
-import { sprintf, __ } from '../../locale';
-import Sidebar from '../../right_sidebar';
-import eventHub from '../../sidebar/event_hub';
-import AssigneeTitle from '../../sidebar/components/assignees/assignee_title.vue';
-import Assignees from '../../sidebar/components/assignees/assignees.vue';
-import DueDateSelectors from '../../due_date_select';
+import Flash from '~/flash';
+import { sprintf, __ } from '~/locale';
+import Sidebar from '~/right_sidebar';
+import eventHub from '~/sidebar/event_hub';
+import DueDateSelectors from '~/due_date_select';
+import IssuableContext from '~/issuable_context';
+import LabelsSelect from '~/labels_select';
+import AssigneeTitle from '~/sidebar/components/assignees/assignee_title.vue';
+import Assignees from '~/sidebar/components/assignees/assignees.vue';
+import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue';
+import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
+import MilestoneSelect from '~/milestone_select';
import RemoveBtn from './sidebar/remove_issue.vue';
-import IssuableContext from '../../issuable_context';
-import LabelsSelect from '../../labels_select';
-import Subscriptions from '../../sidebar/components/subscriptions/subscriptions.vue';
-import MilestoneSelect from '../../milestone_select';
import boardsStore from '../stores/boards_store';
+import { isScopedLabel } from '~/lib/utils/common_utils';
export default Vue.extend({
components: {
@@ -22,6 +24,7 @@ export default Vue.extend({
Assignees,
RemoveBtn,
Subscriptions,
+ TimeTracker,
},
props: {
currentUser: {
@@ -42,7 +45,7 @@ export default Vue.extend({
return Object.keys(this.issue).length;
},
milestoneTitle() {
- return this.issue.milestone ? this.issue.milestone.title : 'No Milestone';
+ return this.issue.milestone ? this.issue.milestone.title : __('No Milestone');
},
canRemove() {
return !this.list.preset;
@@ -138,5 +141,11 @@ export default Vue.extend({
Flash(__('An error occurred while saving assignees'));
});
},
+ showScopedLabels(label) {
+ return boardsStore.scopedLabels.enabled && isScopedLabel(label);
+ },
+ helpLink() {
+ return boardsStore.scopedLabels.helpLink;
+ },
},
});
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue
index 90ab3a76342..a8516f178fc 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.vue
+++ b/app/assets/javascripts/boards/components/issue_card_inner.vue
@@ -1,13 +1,16 @@
<script>
+import _ from 'underscore';
import { GlTooltipDirective } from '@gitlab/ui';
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 eventHub from '../eventhub';
import IssueDueDate from './issue_due_date.vue';
import IssueTimeEstimate from './issue_time_estimate.vue';
import boardsStore from '../stores/boards_store';
+import IssueCardInnerScopedLabel from './issue_card_inner_scoped_label.vue';
+import { isScopedLabel } from '~/lib/utils/common_utils';
export default {
components: {
@@ -16,10 +19,13 @@ export default {
TooltipOnTruncate,
IssueDueDate,
IssueTimeEstimate,
+ IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
+ IssueCardInnerScopedLabel,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [issueCardInner],
props: {
issue: {
type: Object,
@@ -92,6 +98,12 @@ export default {
const { referencePath, groupId } = this.issue;
return !groupId ? referencePath.split('#')[0] : null;
},
+ orderedLabels() {
+ return _.sortBy(this.issue.labels, 'title');
+ },
+ helpLink() {
+ return boardsStore.scopedLabels.helpLink;
+ },
},
methods: {
isIndexLessThanlimit(index) {
@@ -123,31 +135,7 @@ export default {
const labelTitle = encodeURIComponent(label.title);
const filter = `label_name[]=${labelTitle}`;
- this.applyFilter(filter);
- },
- filterByWeight(weight) {
- if (!this.updateFilters) return;
-
- const issueWeight = encodeURIComponent(weight);
- const filter = `weight=${issueWeight}`;
-
- this.applyFilter(filter);
- },
- applyFilter(filter) {
- const filterPath = boardsStore.filter.path.split('&');
- const filterIndex = filterPath.indexOf(filter);
-
- if (filterIndex === -1) {
- filterPath.push(filter);
- } else {
- filterPath.splice(filterIndex, 1);
- }
-
- boardsStore.filter.path = filterPath.join('&');
-
- boardsStore.updateFiltersUrl();
-
- eventHub.$emit('updateTokens');
+ boardsStore.toggleFilter(filter);
},
labelStyle(label) {
return {
@@ -155,12 +143,15 @@ export default {
color: label.textColor,
};
},
+ showScopedLabel(label) {
+ return boardsStore.scopedLabels.enabled && isScopedLabel(label);
+ },
},
};
</script>
<template>
<div>
- <div class="board-card-header">
+ <div class="d-flex board-card-header" dir="auto">
<h4 class="board-card-title append-bottom-0 prepend-top-0">
<icon
v-if="issue.confidential"
@@ -175,27 +166,37 @@ export default {
</h4>
</div>
<div v-if="showLabelFooter" class="board-card-labels prepend-top-4 d-flex flex-wrap">
- <button
- v-for="label in issue.labels"
- v-if="showLabel(label)"
- :key="label.id"
- v-gl-tooltip
- :style="labelStyle(label)"
- :title="label.description"
- class="badge color-label append-right-4 prepend-top-4"
- type="button"
- @click="filterByLabel(label)"
- >
- {{ label.title }}
- </button>
+ <template v-for="label in orderedLabels" v-if="showLabel(label)">
+ <issue-card-inner-scoped-label
+ v-if="showScopedLabel(label)"
+ :key="label.id"
+ :label="label"
+ :label-style="labelStyle(label)"
+ :scoped-labels-documentation-link="helpLink"
+ @scoped-label-click="filterByLabel($event)"
+ />
+
+ <button
+ v-else
+ :key="label.id"
+ v-gl-tooltip
+ :style="labelStyle(label)"
+ :title="label.description"
+ class="badge color-label append-right-4 prepend-top-4"
+ type="button"
+ @click="filterByLabel(label)"
+ >
+ {{ label.title }}
+ </button>
+ </template>
</div>
<div class="board-card-footer d-flex justify-content-between align-items-end">
<div
- class="d-flex align-items-start flex-wrap-reverse board-card-number-container js-board-card-number-container"
+ class="d-flex align-items-start flex-wrap-reverse board-card-number-container overflow-hidden js-board-card-number-container"
>
<span
v-if="issue.referencePath"
- class="board-card-number d-flex append-right-8 prepend-top-8"
+ class="board-card-number overflow-hidden d-flex append-right-8 prepend-top-8"
>
<tooltip-on-truncate
v-if="issueReferencePath"
@@ -209,10 +210,14 @@ export default {
<issue-due-date v-if="issue.dueDate" :date="issue.dueDate" /><issue-time-estimate
v-if="issue.timeEstimate"
:estimate="issue.timeEstimate"
+ /><issue-card-weight
+ v-if="issue.weight"
+ :weight="issue.weight"
+ @click="filterByWeight(issue.weight)"
/>
</span>
</div>
- <div class="board-card-assignee">
+ <div class="board-card-assignee d-flex">
<user-avatar-link
v-for="(assignee, index) in issue.assignees"
v-if="shouldRenderAssignee(index)"
diff --git a/app/assets/javascripts/boards/components/issue_card_inner_scoped_label.vue b/app/assets/javascripts/boards/components/issue_card_inner_scoped_label.vue
new file mode 100644
index 00000000000..fa4c68964cb
--- /dev/null
+++ b/app/assets/javascripts/boards/components/issue_card_inner_scoped_label.vue
@@ -0,0 +1,45 @@
+<script>
+import { GlLink, GlTooltip } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlTooltip,
+ GlLink,
+ },
+ props: {
+ label: {
+ type: Object,
+ required: true,
+ },
+ labelStyle: {
+ type: Object,
+ required: true,
+ },
+ scopedLabelsDocumentationLink: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <span
+ class="d-inline-block position-relative scoped-label-wrapper append-right-4 prepend-top-4 board-label"
+ >
+ <a @click="$emit('scoped-label-click', label)">
+ <span :ref="'labelTitleRef'" :style="labelStyle" class="badge label color-label">
+ {{ label.title }}
+ </span>
+ <gl-tooltip :target="() => $refs.labelTitleRef" placement="top" boundary="viewport">
+ <span class="font-weight-bold scoped-label-tooltip-title">{{ __('Scoped label') }}</span
+ ><br />
+ {{ label.description }}
+ </gl-tooltip>
+ </a>
+
+ <gl-link :href="scopedLabelsDocumentationLink" target="_blank" class="label scoped-label"
+ ><i class="fa fa-question-circle" :style="labelStyle"></i
+ ></gl-link>
+ </span>
+</template>
diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue
index 9c4c6632976..3bc7f13a9e6 100644
--- a/app/assets/javascripts/boards/components/issue_due_date.vue
+++ b/app/assets/javascripts/boards/components/issue_due_date.vue
@@ -53,7 +53,7 @@ export default {
} else if (timeDifference === -1) {
return __('Yesterday');
} else if (timeDifference > 0 && timeDifference < 7) {
- return dateFormat(issueDueDate, 'dddd', true);
+ return dateFormat(issueDueDate, 'dddd');
}
return standardDateFormat;
@@ -82,7 +82,11 @@ export default {
<template>
<span>
<span ref="issueDueDate" :class="cssClass" class="board-card-info card-number">
- <icon :class="{ 'text-danger': isPastDue, 'board-card-info-icon': true }" name="calendar" />
+ <icon
+ :class="{ 'text-danger': isPastDue }"
+ class="board-card-info-icon align-top"
+ name="calendar"
+ />
<time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{
body
}}</time>
diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue
index 5acc3025b2c..98c1d29db16 100644
--- a/app/assets/javascripts/boards/components/issue_time_estimate.vue
+++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue
@@ -28,7 +28,7 @@ export default {
<template>
<span>
<span ref="issueTimeEstimate" class="board-card-info card-number">
- <icon name="hourglass" css-classes="board-card-info-icon" /><time
+ <icon name="hourglass" css-classes="board-card-info-icon align-top" /><time
class="board-card-info-text"
>{{ timeEstimate }}</time
>
diff --git a/app/assets/javascripts/boards/components/modal/empty_state.vue b/app/assets/javascripts/boards/components/modal/empty_state.vue
index 2a0008467c4..091700de93f 100644
--- a/app/assets/javascripts/boards/components/modal/empty_state.vue
+++ b/app/assets/javascripts/boards/components/modal/empty_state.vue
@@ -42,8 +42,8 @@ export default {
</script>
<template>
- <section class="empty-state">
- <div class="row">
+ <section class="empty-state d-flex mt-0 h-100">
+ <div class="row w-100 my-auto mx-0">
<div class="col-12 col-md-6 order-md-last">
<aside class="svg-content d-none d-md-block"><img :src="emptyStateSvg" /></aside>
</div>
diff --git a/app/assets/javascripts/boards/components/modal/header.vue b/app/assets/javascripts/boards/components/modal/header.vue
index 1f0961e02d8..1cfa6d39362 100644
--- a/app/assets/javascripts/boards/components/modal/header.vue
+++ b/app/assets/javascripts/boards/components/modal/header.vue
@@ -50,8 +50,8 @@ export default {
</script>
<template>
<div>
- <header class="add-issues-header form-actions">
- <h2>
+ <header class="add-issues-header border-top-0 form-actions">
+ <h2 class="m-0">
Add issues
<button
type="button"
@@ -65,7 +65,7 @@ export default {
</h2>
</header>
<modal-tabs v-if="!loading && issuesCount > 0" />
- <div v-if="showSearch" class="add-issues-search append-bottom-10">
+ <div v-if="showSearch" class="d-flex append-bottom-10">
<modal-filters :store="filter" />
<button
ref="selectAllBtn"
diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue
index 1e5761cf268..defa1f75ba2 100644
--- a/app/assets/javascripts/boards/components/modal/index.vue
+++ b/app/assets/javascripts/boards/components/modal/index.vue
@@ -124,7 +124,7 @@ export default {
data.issues.forEach(issueObj => {
const issue = new ListIssue(issueObj);
const foundSelectedIssue = ModalStore.findSelectedIssue(issue);
- issue.selected = !!foundSelectedIssue;
+ issue.selected = Boolean(foundSelectedIssue);
this.issues.push(issue);
});
@@ -143,8 +143,11 @@ export default {
};
</script>
<template>
- <div v-if="showAddIssuesModal" class="add-issues-modal">
- <div class="add-issues-container">
+ <div
+ v-if="showAddIssuesModal"
+ class="add-issues-modal d-flex position-fixed position-top-0 position-bottom-0 position-left-0 position-right-0 h-100"
+ >
+ <div class="add-issues-container d-flex flex-column m-auto rounded">
<modal-header
:project-id="projectId"
:milestone-path="milestonePath"
@@ -161,8 +164,10 @@ export default {
:new-issue-path="newIssuePath"
:empty-state-svg="emptyStateSvg"
/>
- <section v-if="loading || filterLoading" class="add-issues-list text-center">
- <div class="add-issues-list-loading"><gl-loading-icon /></div>
+ <section v-if="loading || filterLoading" class="add-issues-list d-flex h-100 text-center">
+ <div class="add-issues-list-loading w-100 align-self-center">
+ <gl-loading-icon size="md" />
+ </div>
</section>
<modal-footer />
</div>
diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue
index e9ed2de859d..28d2019af2f 100644
--- a/app/assets/javascripts/boards/components/modal/list.vue
+++ b/app/assets/javascripts/boards/components/modal/list.vue
@@ -117,7 +117,7 @@ export default {
};
</script>
<template>
- <section ref="list" class="add-issues-list add-issues-list-columns">
+ <section ref="list" class="add-issues-list add-issues-list-columns d-flex h-100">
<div
v-if="issuesCount > 0 && issues.length === 0"
class="empty-state add-issues-empty-state-filter text-center"
@@ -129,7 +129,7 @@ export default {
<div v-for="issue in group" v-if="showIssue(issue)" :key="issue.id" class="board-card-parent">
<div
:class="{ 'is-active': issue.selected }"
- class="board-card"
+ class="board-card position-relative p-3 rounded"
@click="toggleIssue($event, issue)"
>
<issue-card-inner :issue="issue" :issue-link-base="issueLinkBase" :root-path="rootPath" />
diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js
index 10577da9305..c8a9cb1c296 100644
--- a/app/assets/javascripts/boards/components/new_list_dropdown.js
+++ b/app/assets/javascripts/boards/components/new_list_dropdown.js
@@ -2,13 +2,16 @@
import $ from 'jquery';
import axios from '~/lib/utils/axios_utils';
-import _ from 'underscore';
import CreateLabelDropdown from '../../create_label';
import boardsStore from '../stores/boards_store';
$(document)
.off('created.label')
- .on('created.label', (e, label) => {
+ .on('created.label', (e, label, addNewList) => {
+ if (!addNewList) {
+ return;
+ }
+
boardsStore.new({
title: label.title,
position: boardsStore.state.lists.length - 2,
@@ -74,8 +77,6 @@ export default function initNewListDropdown() {
color: label.color,
},
});
-
- boardsStore.state.lists = _.sortBy(boardsStore.state.lists, 'position');
}
},
});
diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue
index a2b8a0af236..4ab2b17301f 100644
--- a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue
+++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue
@@ -48,7 +48,7 @@ export default Vue.extend({
list.removeIssue(issue);
});
- boardsStore.detail.issue = {};
+ boardsStore.clearDetailIssue();
},
/**
* Build the default patch request.
diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js
index c14d69c5d18..6b54e8baefb 100644
--- a/app/assets/javascripts/boards/filtered_search_boards.js
+++ b/app/assets/javascripts/boards/filtered_search_boards.js
@@ -1,6 +1,8 @@
+import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import FilteredSearchContainer from '../filtered_search/container';
import FilteredSearchManager from '../filtered_search/filtered_search_manager';
import boardsStore from './stores/boards_store';
+import { isEE } from '~/lib/utils/common_utils';
export default class FilteredSearchBoards extends FilteredSearchManager {
constructor(store, updateUrl = false, cantEdit = []) {
@@ -8,6 +10,8 @@ export default class FilteredSearchBoards extends FilteredSearchManager {
page: 'boards',
isGroupDecendent: true,
stateFiltersSelector: '.issues-state-filters',
+ isGroup: isEE(),
+ filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
});
this.store = store;
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index f88e9b55988..f2f37d22b97 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -1,11 +1,10 @@
import $ from 'jquery';
-import _ from 'underscore';
import Vue from 'vue';
import Flash from '~/flash';
import { __ } from '~/locale';
-import '~/vue_shared/models/label';
-import '~/vue_shared/models/assignee';
+import './models/label';
+import './models/assignee';
import FilteredSearchBoards from './filtered_search_boards';
import eventHub from './eventhub';
@@ -24,7 +23,11 @@ import BoardSidebar from './components/board_sidebar';
import initNewListDropdown from './components/new_list_dropdown';
import BoardAddIssuesModal from './components/modal/index.vue';
import '~/vue_shared/vue_resource_interceptor';
-import { NavigationType, parseBoolean } from '~/lib/utils/common_utils';
+import {
+ NavigationType,
+ convertObjectPropsToCamelCase,
+ parseBoolean,
+} from '~/lib/utils/common_utils';
let issueBoardsApp;
@@ -58,6 +61,7 @@ export default () => {
state: boardsStore.state,
loading: true,
boardsEndpoint: $boardApp.dataset.boardsEndpoint,
+ recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint,
listsEndpoint: $boardApp.dataset.listsEndpoint,
boardId: $boardApp.dataset.boardId,
disabled: parseBoolean($boardApp.dataset.disabled),
@@ -75,6 +79,7 @@ export default () => {
created() {
gl.boardService = new BoardService({
boardsEndpoint: this.boardsEndpoint,
+ recentBoardsEndpoint: this.recentBoardsEndpoint,
listsEndpoint: this.listsEndpoint,
bulkUpdatePath: this.bulkUpdatePath,
boardId: this.boardId,
@@ -100,24 +105,29 @@ export default () => {
gl.boardService
.all()
.then(res => res.data)
- .then(data => {
- data.forEach(board => {
- const list = boardsStore.addList(board, this.defaultAvatar);
-
- if (list.type === 'closed') {
- list.position = Infinity;
- } else if (list.type === 'backlog') {
- list.position = -1;
+ .then(lists => {
+ lists.forEach(listObj => {
+ let { position } = listObj;
+ if (listObj.list_type === 'closed') {
+ position = Infinity;
+ } else if (listObj.list_type === 'backlog') {
+ position = -1;
}
- });
- this.state.lists = _.sortBy(this.state.lists, 'position');
+ boardsStore.addList(
+ {
+ ...listObj,
+ position,
+ },
+ this.defaultAvatar,
+ );
+ });
boardsStore.addBlankState();
this.loading = false;
})
.catch(() => {
- Flash('An error occurred while fetching the board lists. Please try again.');
+ Flash(__('An error occurred while fetching the board lists. Please try again.'));
});
},
methods: {
@@ -131,9 +141,25 @@ export default () => {
BoardService.getIssueInfo(sidebarInfoEndpoint)
.then(res => res.data)
.then(data => {
+ const {
+ subscribed,
+ totalTimeSpent,
+ timeEstimate,
+ humanTimeEstimate,
+ humanTotalTimeSpent,
+ weight,
+ epic,
+ } = convertObjectPropsToCamelCase(data);
+
newIssue.setFetchingState('subscriptions', false);
newIssue.updateData({
- subscribed: data.subscribed,
+ humanTimeSpent: humanTotalTimeSpent,
+ timeSpent: totalTimeSpent,
+ humanTimeEstimate,
+ timeEstimate,
+ subscribed,
+ weight,
+ epic,
});
})
.catch(() => {
@@ -142,10 +168,10 @@ export default () => {
});
}
- boardsStore.detail.issue = newIssue;
+ boardsStore.setIssueDetail(newIssue);
},
clearDetailIssue() {
- boardsStore.detail.issue = {};
+ boardsStore.clearDetailIssue();
},
toggleSubscription(id) {
const { issue } = boardsStore.detail;
@@ -201,7 +227,7 @@ export default () => {
},
tooltipTitle() {
if (this.disabled) {
- return 'Please add a list to your board first';
+ return __('Please add a list to your board first');
}
return '';
diff --git a/app/assets/javascripts/boards/mixins/issue_card_inner.js b/app/assets/javascripts/boards/mixins/issue_card_inner.js
new file mode 100644
index 00000000000..8000237da6d
--- /dev/null
+++ b/app/assets/javascripts/boards/mixins/issue_card_inner.js
@@ -0,0 +1,5 @@
+export default {
+ methods: {
+ filterByWeight() {},
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/models/assignee.js b/app/assets/javascripts/boards/models/assignee.js
index 4a29b0d0581..4a29b0d0581 100644
--- a/app/assets/javascripts/vue_shared/models/assignee.js
+++ b/app/assets/javascripts/boards/models/assignee.js
diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js
index dd92d3c8552..f858b162c6b 100644
--- a/app/assets/javascripts/boards/models/issue.js
+++ b/app/assets/javascripts/boards/models/issue.js
@@ -4,7 +4,8 @@
/* global ListAssignee */
import Vue from 'vue';
-import '~/vue_shared/models/label';
+import './label';
+import { isEE, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import IssueProject from './project';
import boardsStore from '../stores/boards_store';
@@ -28,7 +29,6 @@ class ListIssue {
this.referencePath = obj.reference_path;
this.path = obj.real_path;
this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint;
- this.milestone_id = obj.milestone_id;
this.project_id = obj.project_id;
this.timeEstimate = obj.time_estimate;
this.assignableLabelsEndpoint = obj.assignable_labels_endpoint;
@@ -39,6 +39,7 @@ class ListIssue {
if (obj.milestone) {
this.milestone = new ListMilestone(obj.milestone);
+ this.milestone_id = obj.milestone.id;
}
obj.labels.forEach(label => {
@@ -88,6 +89,19 @@ class ListIssue {
this.assignees = [];
}
+ addMilestone(milestone) {
+ const miletoneId = this.milestone ? this.milestone.id : null;
+ if (isEE && milestone.id !== miletoneId) {
+ this.milestone = new ListMilestone(milestone);
+ }
+ }
+
+ removeMilestone(removeMilestone) {
+ if (isEE && removeMilestone && removeMilestone.id === this.milestone.id) {
+ this.milestone = {};
+ }
+ }
+
getLists() {
return boardsStore.state.lists.filter(list => list.findIssue(this.id));
}
@@ -119,7 +133,17 @@ class ListIssue {
}
const projectPath = this.project ? this.project.path : '';
- return Vue.http.patch(`${this.path}.json`, data);
+ return Vue.http.patch(`${this.path}.json`, data).then(({ body = {} } = {}) => {
+ /**
+ * Since post implementation of Scoped labels, server can reject
+ * same key-ed labels. To keep the UI and server Model consistent,
+ * we're just assigning labels that server echo's back to us when we
+ * PATCH the said object.
+ */
+ if (body) {
+ this.labels = convertObjectPropsToCamelCase(body.labels, { deep: true });
+ }
+ });
}
}
diff --git a/app/assets/javascripts/boards/models/label.js b/app/assets/javascripts/boards/models/label.js
new file mode 100644
index 00000000000..cd2a2c0137f
--- /dev/null
+++ b/app/assets/javascripts/boards/models/label.js
@@ -0,0 +1,11 @@
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+export default class ListLabel {
+ constructor(obj) {
+ Object.assign(this, convertObjectPropsToCamelCase(obj, { dropKeys: ['priority'] }), {
+ priority: obj.priority !== null ? obj.priority : Infinity,
+ });
+ }
+}
+
+window.ListLabel = ListLabel;
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index 9f6d9a853da..a9d88f19146 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -2,10 +2,11 @@
/* global ListIssue */
import { __ } from '~/locale';
-import ListLabel from '~/vue_shared/models/label';
-import ListAssignee from '~/vue_shared/models/assignee';
-import { urlParamsToObject } from '~/lib/utils/common_utils';
+import ListLabel from './label';
+import ListAssignee from './assignee';
+import { isEE, urlParamsToObject } from '~/lib/utils/common_utils';
import boardsStore from '../stores/boards_store';
+import ListMilestone from './milestone';
const PER_PAGE = 20;
@@ -36,8 +37,8 @@ class List {
this.type = obj.list_type;
const typeInfo = this.getTypeInfo(this.type);
- this.preset = !!typeInfo.isPreset;
- this.isExpandable = !!typeInfo.isExpandable;
+ this.preset = Boolean(typeInfo.isPreset);
+ this.isExpandable = Boolean(typeInfo.isExpandable);
this.isExpanded = true;
this.page = 1;
this.loading = true;
@@ -51,6 +52,9 @@ class List {
} else if (obj.user) {
this.assignee = new ListAssignee(obj.user);
this.title = this.assignee.name;
+ } else if (isEE && obj.milestone) {
+ this.milestone = new ListMilestone(obj.milestone);
+ this.title = this.milestone.title;
}
if (!typeInfo.isBlank && this.id) {
@@ -69,12 +73,14 @@ class List {
}
save() {
- const entity = this.label || this.assignee;
+ const entity = this.label || this.assignee || this.milestone;
let entityType = '';
if (this.label) {
entityType = 'label_id';
- } else {
+ } else if (this.assignee) {
entityType = 'assignee_id';
+ } else if (isEE && this.milestone) {
+ entityType = 'milestone_id';
}
return gl.boardService
@@ -84,6 +90,7 @@ class List {
this.id = data.id;
this.type = data.list_type;
this.position = data.position;
+ this.label = data.label;
return this.getIssues();
});
@@ -192,6 +199,13 @@ class List {
issue.addAssignee(this.assignee);
}
+ if (isEE && this.milestone) {
+ if (listFrom && listFrom.type === 'milestone') {
+ issue.removeMilestone(listFrom.milestone);
+ }
+ issue.addMilestone(this.milestone);
+ }
+
if (listFrom) {
this.issuesSize += 1;
diff --git a/app/assets/javascripts/boards/models/milestone.js b/app/assets/javascripts/boards/models/milestone.js
index 17d15278a74..6f81d6bc6f8 100644
--- a/app/assets/javascripts/boards/models/milestone.js
+++ b/app/assets/javascripts/boards/models/milestone.js
@@ -1,7 +1,16 @@
-class ListMilestone {
+import { isEE } from '~/lib/utils/common_utils';
+
+export default class ListMilestone {
constructor(obj) {
this.id = obj.id;
this.title = obj.title;
+
+ if (isEE) {
+ this.path = obj.path;
+ this.state = obj.state;
+ this.webUrl = obj.web_url || obj.webUrl;
+ this.description = obj.description;
+ }
}
}
diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js
index 3de6eb056c2..7d463f17ab1 100644
--- a/app/assets/javascripts/boards/services/board_service.js
+++ b/app/assets/javascripts/boards/services/board_service.js
@@ -2,12 +2,13 @@ import axios from '../../lib/utils/axios_utils';
import { mergeUrlParams } from '../../lib/utils/url_utility';
export default class BoardService {
- constructor({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId }) {
+ constructor({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) {
this.boardsEndpoint = boardsEndpoint;
this.boardId = boardId;
this.listsEndpoint = listsEndpoint;
this.listsEndpointGenerate = `${listsEndpoint}/generate.json`;
this.bulkUpdatePath = bulkUpdatePath;
+ this.recentBoardsEndpoint = `${recentBoardsEndpoint}.json`;
}
generateBoardsPath(id) {
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
new file mode 100644
index 00000000000..da82b52330a
--- /dev/null
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -0,0 +1,65 @@
+const notImplemented = () => {
+ throw new Error('Not implemented!');
+};
+
+export default {
+ setEndpoints: () => {
+ notImplemented();
+ },
+
+ fetchLists: () => {
+ notImplemented();
+ },
+
+ generateDefaultLists: () => {
+ notImplemented();
+ },
+
+ createList: () => {
+ notImplemented();
+ },
+
+ updateList: () => {
+ notImplemented();
+ },
+
+ deleteList: () => {
+ notImplemented();
+ },
+
+ fetchIssuesForList: () => {
+ notImplemented();
+ },
+
+ moveIssue: () => {
+ notImplemented();
+ },
+
+ createNewIssue: () => {
+ notImplemented();
+ },
+
+ fetchBacklog: () => {
+ notImplemented();
+ },
+
+ bulkUpdateIssues: () => {
+ notImplemented();
+ },
+
+ fetchIssue: () => {
+ notImplemented();
+ },
+
+ toggleIssueSubscription: () => {
+ notImplemented();
+ },
+
+ showPage: () => {
+ notImplemented();
+ },
+
+ toggleEmptyState: () => {
+ notImplemented();
+ },
+};
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index 802796208c2..4b3b44574a8 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -5,14 +5,27 @@ import $ from 'jquery';
import _ from 'underscore';
import Vue from 'vue';
import Cookies from 'js-cookie';
+import BoardsStoreEE from 'ee_else_ce/boards/stores/boards_store_ee';
import { getUrlParamsArray, parseBoolean } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import eventHub from '../eventhub';
const boardsStore = {
disabled: false,
+ scopedLabels: {
+ helpLink: '',
+ enabled: false,
+ },
filter: {
path: '',
},
- state: {},
+ state: {
+ currentBoard: {
+ labels: [],
+ },
+ currentPage: '',
+ reload: false,
+ },
detail: {
issue: {},
},
@@ -27,9 +40,13 @@ const boardsStore = {
issue: {},
};
},
+ showPage(page) {
+ this.state.reload = false;
+ this.state.currentPage = page;
+ },
addList(listObj, defaultAvatar) {
const list = new List(listObj, defaultAvatar);
- this.state.lists.push(list);
+ this.state.lists = _.sortBy([...this.state.lists, list], 'position');
return list;
},
@@ -63,11 +80,9 @@ const boardsStore = {
this.addList({
id: 'blank',
list_type: 'blank',
- title: 'Welcome to your Issue Board!',
+ title: __('Welcome to your Issue Board!'),
position: 0,
});
-
- this.state.lists = _.sortBy(this.state.lists, 'position');
},
removeBlankState() {
this.removeList('blank');
@@ -95,6 +110,11 @@ const boardsStore = {
});
listFrom.update();
},
+
+ startMoving(list, issue) {
+ Object.assign(this.moving, { list, issue });
+ },
+
moveIssueToList(listFrom, listTo, issue, newIndex) {
const issueTo = listTo.findIssue(issue.id);
const issueLists = issue.getLists();
@@ -169,11 +189,43 @@ const boardsStore = {
findListByLabelId(id) {
return this.state.lists.find(list => list.type === 'label' && list.label.id === id);
},
+
+ toggleFilter(filter) {
+ const filterPath = this.filter.path.split('&');
+ const filterIndex = filterPath.indexOf(filter);
+
+ if (filterIndex === -1) {
+ filterPath.push(filter);
+ } else {
+ filterPath.splice(filterIndex, 1);
+ }
+
+ this.filter.path = filterPath.join('&');
+
+ this.updateFiltersUrl();
+
+ eventHub.$emit('updateTokens');
+ },
+
+ setListDetail(newList) {
+ this.detail.list = newList;
+ },
+
updateFiltersUrl() {
window.history.pushState(null, null, `?${this.filter.path}`);
},
+
+ clearDetailIssue() {
+ this.setIssueDetail({});
+ },
+
+ setIssueDetail(issueDetail) {
+ this.detail.issue = issueDetail;
+ },
};
+BoardsStoreEE.initEESpecific(boardsStore);
+
// hacks added in order to allow milestone_select to function properly
// TODO: remove these
diff --git a/app/assets/javascripts/boards/stores/boards_store_ee.js b/app/assets/javascripts/boards/stores/boards_store_ee.js
new file mode 100644
index 00000000000..09e3a938fbe
--- /dev/null
+++ b/app/assets/javascripts/boards/stores/boards_store_ee.js
@@ -0,0 +1,5 @@
+// this is just to make ee_else_ce happy and will be cleaned up in https://gitlab.com/gitlab-org/gitlab-ce/issues/59807
+
+export default {
+ initEESpecific() {},
+};
diff --git a/app/assets/javascripts/boards/stores/index.js b/app/assets/javascripts/boards/stores/index.js
new file mode 100644
index 00000000000..f70395a3771
--- /dev/null
+++ b/app/assets/javascripts/boards/stores/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import state from 'ee_else_ce/boards/stores/state';
+import actions from 'ee_else_ce/boards/stores/actions';
+import mutations from 'ee_else_ce/boards/stores/mutations';
+
+Vue.use(Vuex);
+
+export default () =>
+ new Vuex.Store({
+ state,
+ actions,
+ mutations,
+ });
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
new file mode 100644
index 00000000000..fcdfa6799b6
--- /dev/null
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -0,0 +1,21 @@
+export const SET_ENDPOINTS = 'SET_ENDPOINTS';
+export const REQUEST_ADD_LIST = 'REQUEST_ADD_LIST';
+export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS';
+export const RECEIVE_ADD_LIST_ERROR = 'RECEIVE_ADD_LIST_ERROR';
+export const REQUEST_UPDATE_LIST = 'REQUEST_UPDATE_LIST';
+export const RECEIVE_UPDATE_LIST_SUCCESS = 'RECEIVE_UPDATE_LIST_SUCCESS';
+export const RECEIVE_UPDATE_LIST_ERROR = 'RECEIVE_UPDATE_LIST_ERROR';
+export const REQUEST_REMOVE_LIST = 'REQUEST_REMOVE_LIST';
+export const RECEIVE_REMOVE_LIST_SUCCESS = 'RECEIVE_REMOVE_LIST_SUCCESS';
+export const RECEIVE_REMOVE_LIST_ERROR = 'RECEIVE_REMOVE_LIST_ERROR';
+export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE';
+export const RECEIVE_ADD_ISSUE_SUCCESS = 'RECEIVE_ADD_ISSUE_SUCCESS';
+export const RECEIVE_ADD_ISSUE_ERROR = 'RECEIVE_ADD_ISSUE_ERROR';
+export const REQUEST_MOVE_ISSUE = 'REQUEST_MOVE_ISSUE';
+export const RECEIVE_MOVE_ISSUE_SUCCESS = 'RECEIVE_MOVE_ISSUE_SUCCESS';
+export const RECEIVE_MOVE_ISSUE_ERROR = 'RECEIVE_MOVE_ISSUE_ERROR';
+export const REQUEST_UPDATE_ISSUE = 'REQUEST_UPDATE_ISSUE';
+export const RECEIVE_UPDATE_ISSUE_SUCCESS = 'RECEIVE_UPDATE_ISSUE_SUCCESS';
+export const RECEIVE_UPDATE_ISSUE_ERROR = 'RECEIVE_UPDATE_ISSUE_ERROR';
+export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE';
+export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
new file mode 100644
index 00000000000..77ba68be07e
--- /dev/null
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -0,0 +1,91 @@
+import * as mutationTypes from './mutation_types';
+
+const notImplemented = () => {
+ throw new Error('Not implemented!');
+};
+
+export default {
+ [mutationTypes.SET_ENDPOINTS]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.REQUEST_ADD_LIST]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_ADD_LIST_SUCCESS]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_ADD_LIST_ERROR]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.REQUEST_UPDATE_LIST]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_UPDATE_LIST_SUCCESS]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_UPDATE_LIST_ERROR]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.REQUEST_REMOVE_LIST]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_REMOVE_LIST_SUCCESS]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_REMOVE_LIST_ERROR]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.REQUEST_ADD_ISSUE]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_ADD_ISSUE_SUCCESS]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_ADD_ISSUE_ERROR]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.REQUEST_MOVE_ISSUE]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_MOVE_ISSUE_SUCCESS]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_MOVE_ISSUE_ERROR]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.REQUEST_UPDATE_ISSUE]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_UPDATE_ISSUE_SUCCESS]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_UPDATE_ISSUE_ERROR]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.SET_CURRENT_PAGE]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.TOGGLE_EMPTY_STATE]: () => {
+ notImplemented();
+ },
+};
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
new file mode 100644
index 00000000000..dd16abb01a5
--- /dev/null
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -0,0 +1,3 @@
+export default () => ({
+ // ...
+});
diff --git a/app/assets/javascripts/branches/branches_delete_modal.js b/app/assets/javascripts/branches/branches_delete_modal.js
index f34496f84c6..f4c3fa185d8 100644
--- a/app/assets/javascripts/branches/branches_delete_modal.js
+++ b/app/assets/javascripts/branches/branches_delete_modal.js
@@ -23,7 +23,7 @@ class DeleteModal {
const branchData = e.currentTarget.dataset;
this.branchName = branchData.branchName || '';
this.deletePath = branchData.deletePath || '';
- this.isMerged = !!branchData.isMerged;
+ this.isMerged = Boolean(branchData.isMerged);
this.updateModal();
}
diff --git a/app/assets/javascripts/breakpoints.js b/app/assets/javascripts/breakpoints.js
index 7951348d8b2..93aacba0e8e 100644
--- a/app/assets/javascripts/breakpoints.js
+++ b/app/assets/javascripts/breakpoints.js
@@ -14,6 +14,9 @@ const BreakpointInstance = {
return breakpoint;
},
+ isDesktop() {
+ return ['lg', 'md'].includes(this.getBreakpointSize());
+ },
};
export default BreakpointInstance;
diff --git a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js
index 592e1fd1c31..0bba2a2e160 100644
--- a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js
+++ b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js
@@ -27,15 +27,24 @@ function generateErrorBoxContent(errors) {
// Used for the variable list on CI/CD projects/groups settings page
export default class AjaxVariableList {
- constructor({ container, saveButton, errorBox, formField = 'variables', saveEndpoint }) {
+ constructor({
+ container,
+ saveButton,
+ errorBox,
+ formField = 'variables',
+ saveEndpoint,
+ maskableRegex,
+ }) {
this.container = container;
this.saveButton = saveButton;
this.errorBox = errorBox;
this.saveEndpoint = saveEndpoint;
+ this.maskableRegex = maskableRegex;
this.variableList = new VariableList({
container: this.container,
formField,
+ maskableRegex,
});
this.bindEvents();
diff --git a/app/assets/javascripts/ci_variable_list/ci_variable_list.js b/app/assets/javascripts/ci_variable_list/ci_variable_list.js
index 5b20fa141cd..0303e4e51dd 100644
--- a/app/assets/javascripts/ci_variable_list/ci_variable_list.js
+++ b/app/assets/javascripts/ci_variable_list/ci_variable_list.js
@@ -16,9 +16,10 @@ function createEnvironmentItem(value) {
}
export default class VariableList {
- constructor({ container, formField }) {
+ constructor({ container, formField, maskableRegex }) {
this.$container = $(container);
this.formField = formField;
+ this.maskableRegex = new RegExp(maskableRegex);
this.environmentDropdownMap = new WeakMap();
this.inputMap = {
@@ -26,6 +27,10 @@ export default class VariableList {
selector: '.js-ci-variable-input-id',
default: '',
},
+ variable_type: {
+ selector: '.js-ci-variable-input-variable-type',
+ default: 'env_var',
+ },
key: {
selector: '.js-ci-variable-input-key',
default: '',
@@ -40,6 +45,12 @@ export default class VariableList {
// converted. we need the value as a string.
default: $('.js-ci-variable-input-protected').attr('data-default'),
},
+ masked: {
+ selector: '.js-ci-variable-input-masked',
+ // use `attr` instead of `data` as we don't want the value to be
+ // converted. we need the value as a string.
+ default: $('.js-ci-variable-input-masked').attr('data-default'),
+ },
environment_scope: {
// We can't use a `.js-` class here because
// gl_dropdown replaces the <input> and doesn't copy over the class
@@ -88,13 +99,16 @@ export default class VariableList {
}
});
- // Always make sure there is an empty last row
- this.$container.on('input trigger-change', inputSelector, () => {
+ this.$container.on('input trigger-change', inputSelector, e => {
+ // Always make sure there is an empty last row
const $lastRow = this.$container.find('.js-row').last();
if (this.checkIfRowTouched($lastRow)) {
this.insertRow($lastRow);
}
+
+ // If masked, validate value against regex
+ this.validateMaskability($(e.currentTarget).closest('.js-row'));
});
}
@@ -171,12 +185,32 @@ export default class VariableList {
checkIfRowTouched($row) {
return Object.keys(this.inputMap).some(name => {
+ // Row should not qualify as touched if only switches have been touched
+ if (['protected', 'masked'].includes(name)) return false;
+
const entry = this.inputMap[name];
const $el = $row.find(entry.selector);
return $el.length && $el.val() !== entry.default;
});
}
+ validateMaskability($row) {
+ const invalidInputClass = 'gl-field-error-outline';
+
+ const variableValue = $row.find(this.inputMap.secret_value.selector).val();
+ const isValueMaskable = this.maskableRegex.test(variableValue) || variableValue === '';
+ const isMaskedChecked = $row.find(this.inputMap.masked.selector).val() === 'true';
+
+ // Show a validation error if the user wants to mask an unmaskable variable value
+ $row
+ .find(this.inputMap.secret_value.selector)
+ .toggleClass(invalidInputClass, isMaskedChecked && !isValueMaskable);
+ $row
+ .find('.js-secret-value-placeholder')
+ .toggleClass(invalidInputClass, isMaskedChecked && !isValueMaskable);
+ $row.find('.masking-validation-error').toggle(isMaskedChecked && !isValueMaskable);
+ }
+
toggleEnableRow(isEnabled = true) {
this.$container.find(this.inputMap.key.selector).attr('disabled', !isEnabled);
this.$container.find('.js-row-remove-button').attr('disabled', !isEnabled);
diff --git a/app/assets/javascripts/ci_variable_list/native_form_variable_list.js b/app/assets/javascripts/ci_variable_list/native_form_variable_list.js
index e7111c666a2..fdbefd8c313 100644
--- a/app/assets/javascripts/ci_variable_list/native_form_variable_list.js
+++ b/app/assets/javascripts/ci_variable_list/native_form_variable_list.js
@@ -19,6 +19,7 @@ export default function setupNativeFormVariableList({ container, formField = 'va
const isTouched = variableList.checkIfRowTouched($lastRow);
if (!isTouched) {
$lastRow.find('input, textarea').attr('name', '');
+ $lastRow.find('select').attr('name', '');
}
});
}
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index 6ebd1ad109e..aacfa0d87e6 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -1,23 +1,21 @@
import Visibility from 'visibilityjs';
import Vue from 'vue';
+import AccessorUtilities from '~/lib/utils/accessor';
+import { GlToast } from '@gitlab/ui';
import PersistentUserCallout from '../persistent_user_callout';
import { s__, sprintf } from '../locale';
import Flash from '../flash';
import Poll from '../lib/utils/poll';
import initSettingsPanels from '../settings_panels';
import eventHub from './event_hub';
-import {
- APPLICATION_STATUS,
- REQUEST_SUBMITTED,
- REQUEST_FAILURE,
- UPGRADE_REQUESTED,
- UPGRADE_REQUEST_FAILURE,
-} from './constants';
+import { APPLICATION_STATUS, INGRESS, INGRESS_DOMAIN_SUFFIX } from './constants';
import ClustersService from './services/clusters_service';
import ClustersStore from './stores/clusters_store';
import Applications from './components/applications.vue';
import setupToggleButtons from '../toggle_buttons';
+Vue.use(GlToast);
+
/**
* Cluster page has 2 separate parts:
* Toggle button and applications section
@@ -36,6 +34,7 @@ export default class Clusters {
installRunnerPath,
installJupyterPath,
installKnativePath,
+ updateKnativePath,
installPrometheusPath,
managePrometheusPath,
hasRbac,
@@ -45,8 +44,10 @@ export default class Clusters {
helpPath,
ingressHelpPath,
ingressDnsHelpPath,
+ clusterId,
} = document.querySelector('.js-edit-cluster-form').dataset;
+ this.clusterId = clusterId;
this.store = new ClustersStore();
this.store.setHelpPaths(helpPath, ingressHelpPath, ingressDnsHelpPath);
this.store.setManagePrometheusPath(managePrometheusPath);
@@ -62,6 +63,7 @@ export default class Clusters {
installPrometheusEndpoint: installPrometheusPath,
installJupyterEndpoint: installJupyterPath,
installKnativeEndpoint: installKnativePath,
+ updateKnativeEndpoint: updateKnativePath,
});
this.installApplication = this.installApplication.bind(this);
@@ -70,10 +72,18 @@ export default class Clusters {
this.errorContainer = document.querySelector('.js-cluster-error');
this.successContainer = document.querySelector('.js-cluster-success');
this.creatingContainer = document.querySelector('.js-cluster-creating');
+ this.unreachableContainer = document.querySelector('.js-cluster-api-unreachable');
+ this.authenticationFailureContainer = document.querySelector(
+ '.js-cluster-authentication-failure',
+ );
this.errorReasonContainer = this.errorContainer.querySelector('.js-error-reason');
this.successApplicationContainer = document.querySelector('.js-cluster-application-notice');
this.showTokenButton = document.querySelector('.js-show-cluster-token');
this.tokenField = document.querySelector('.js-cluster-token');
+ this.ingressDomainHelpText = document.querySelector('.js-ingress-domain-help-text');
+ this.ingressDomainSnippet = this.ingressDomainHelpText.querySelector(
+ '.js-ingress-domain-snippet',
+ );
Clusters.initDismissableCallout();
initSettingsPanels();
@@ -119,24 +129,35 @@ export default class Clusters {
static initDismissableCallout() {
const callout = document.querySelector('.js-cluster-security-warning');
+ PersistentUserCallout.factory(callout);
+ }
- if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new
+ addBannerCloseHandler(el, status) {
+ el.querySelector('.js-close-banner').addEventListener('click', () => {
+ el.classList.add('hidden');
+ this.setBannerDismissedState(status, true);
+ });
}
addListeners() {
if (this.showTokenButton) this.showTokenButton.addEventListener('click', this.showToken);
eventHub.$on('installApplication', this.installApplication);
- eventHub.$on('upgradeApplication', data => this.upgradeApplication(data));
- eventHub.$on('upgradeFailed', appId => this.upgradeFailed(appId));
- eventHub.$on('dismissUpgradeSuccess', appId => this.dismissUpgradeSuccess(appId));
+ eventHub.$on('updateApplication', data => this.updateApplication(data));
+ eventHub.$on('saveKnativeDomain', data => this.saveKnativeDomain(data));
+ eventHub.$on('setKnativeHostname', data => this.setKnativeHostname(data));
+ eventHub.$on('uninstallApplication', data => this.uninstallApplication(data));
+ // Add event listener to all the banner close buttons
+ this.addBannerCloseHandler(this.unreachableContainer, 'unreachable');
+ this.addBannerCloseHandler(this.authenticationFailureContainer, 'authentication_failure');
}
removeListeners() {
if (this.showTokenButton) this.showTokenButton.removeEventListener('click', this.showToken);
eventHub.$off('installApplication', this.installApplication);
- eventHub.$off('upgradeApplication', this.upgradeApplication);
- eventHub.$off('upgradeFailed', this.upgradeFailed);
- eventHub.$off('dismissUpgradeSuccess', this.dismissUpgradeSuccess);
+ eventHub.$off('updateApplication', this.updateApplication);
+ eventHub.$off('saveKnativeDomain');
+ eventHub.$off('setKnativeHostname');
+ eventHub.$off('uninstallApplication');
}
initPolling() {
@@ -177,6 +198,10 @@ export default class Clusters {
this.checkForNewInstalls(prevApplicationMap, this.store.state.applications);
this.updateContainer(prevStatus, this.store.state.status, this.store.state.statusReason);
+ this.toggleIngressDomainHelpText(
+ prevApplicationMap[INGRESS],
+ this.store.state.applications[INGRESS],
+ );
}
showToken() {
@@ -195,6 +220,8 @@ export default class Clusters {
this.errorContainer.classList.add('hidden');
this.successContainer.classList.add('hidden');
this.creatingContainer.classList.add('hidden');
+ this.unreachableContainer.classList.add('hidden');
+ this.authenticationFailureContainer.classList.add('hidden');
}
checkForNewInstalls(prevApplicationMap, newApplicationMap) {
@@ -218,9 +245,32 @@ export default class Clusters {
}
}
+ setBannerDismissedState(status, isDismissed) {
+ if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ window.localStorage.setItem(
+ `cluster_${this.clusterId}_banner_dismissed`,
+ `${status}_${isDismissed}`,
+ );
+ }
+ }
+
+ isBannerDismissed(status) {
+ let bannerState;
+ if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ bannerState = window.localStorage.getItem(`cluster_${this.clusterId}_banner_dismissed`);
+ }
+
+ return bannerState === `${status}_true`;
+ }
+
updateContainer(prevStatus, status, error) {
this.hideAll();
+ if (this.isBannerDismissed(status)) {
+ return;
+ }
+ this.setBannerDismissedState(status, false);
+
// We poll all the time but only want the `created` banner to show when newly created
if (this.store.state.status !== 'created' || prevStatus !== this.store.state.status) {
switch (status) {
@@ -231,6 +281,12 @@ export default class Clusters {
this.errorContainer.classList.remove('hidden');
this.errorReasonContainer.textContent = error;
break;
+ case 'unreachable':
+ this.unreachableContainer.classList.remove('hidden');
+ break;
+ case 'authentication_failure':
+ this.authenticationFailureContainer.classList.remove('hidden');
+ break;
case 'scheduled':
case 'creating':
this.creatingContainer.classList.remove('hidden');
@@ -241,14 +297,14 @@ export default class Clusters {
}
}
- installApplication(data) {
- const appId = data.id;
- this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUBMITTED);
+ installApplication({ id: appId, params }) {
this.store.updateAppProperty(appId, 'requestReason', null);
this.store.updateAppProperty(appId, 'statusReason', null);
- this.service.installApplication(appId, data.params).catch(() => {
- this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE);
+ this.store.installApplication(appId);
+
+ return this.service.installApplication(appId, params).catch(() => {
+ this.store.notifyInstallFailure(appId);
this.store.updateAppProperty(
appId,
'requestReason',
@@ -257,19 +313,48 @@ export default class Clusters {
});
}
- upgradeApplication(data) {
- const appId = data.id;
- this.store.updateAppProperty(appId, 'requestStatus', UPGRADE_REQUESTED);
- this.store.updateAppProperty(appId, 'status', APPLICATION_STATUS.UPDATING);
- this.service.installApplication(appId, data.params).catch(() => this.upgradeFailed(appId));
+ uninstallApplication({ id: appId }) {
+ this.store.updateAppProperty(appId, 'requestReason', null);
+ this.store.updateAppProperty(appId, 'statusReason', null);
+
+ this.store.uninstallApplication(appId);
+
+ return this.service.uninstallApplication(appId).catch(() => {
+ this.store.notifyUninstallFailure(appId);
+ this.store.updateAppProperty(
+ appId,
+ 'requestReason',
+ s__('ClusterIntegration|Request to begin uninstalling failed'),
+ );
+ });
}
- upgradeFailed(appId) {
- this.store.updateAppProperty(appId, 'requestStatus', UPGRADE_REQUEST_FAILURE);
+ updateApplication({ id: appId, params }) {
+ this.store.updateApplication(appId);
+ this.service.installApplication(appId, params).catch(() => {
+ this.store.notifyUpdateFailure(appId);
+ });
}
- dismissUpgradeSuccess(appId) {
- this.store.updateAppProperty(appId, 'requestStatus', null);
+ toggleIngressDomainHelpText({ externalIp }, { externalIp: newExternalIp }) {
+ if (externalIp !== newExternalIp) {
+ this.ingressDomainHelpText.classList.toggle('hide', !newExternalIp);
+ this.ingressDomainSnippet.textContent = `${newExternalIp}${INGRESS_DOMAIN_SUFFIX}`;
+ }
+ }
+
+ saveKnativeDomain(data) {
+ const appId = data.id;
+ this.store.updateApplication(appId);
+ this.service.updateApplication(appId, data.params).catch(() => {
+ this.store.notifyUpdateFailure(appId);
+ });
+ }
+
+ setKnativeHostname(data) {
+ const appId = data.id;
+ this.store.updateAppProperty(appId, 'isEditingHostName', true);
+ this.store.updateAppProperty(appId, 'hostname', data.hostname);
}
destroy() {
diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue
index 5952e93b9a7..4771090aa7e 100644
--- a/app/assets/javascripts/clusters/components/application_row.vue
+++ b/app/assets/javascripts/clusters/components/application_row.vue
@@ -1,17 +1,15 @@
<script>
/* eslint-disable vue/require-default-prop */
-import { GlLink } from '@gitlab/ui';
+import { GlLink, GlModalDirective } from '@gitlab/ui';
import TimeagoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
-import { s__, sprintf } from '../../locale';
+import { s__, __, sprintf } from '~/locale';
import eventHub from '../event_hub';
import identicon from '../../vue_shared/components/identicon.vue';
import loadingButton from '../../vue_shared/components/loading_button.vue';
-import {
- APPLICATION_STATUS,
- REQUEST_SUBMITTED,
- REQUEST_FAILURE,
- UPGRADE_REQUESTED,
-} from '../constants';
+import UninstallApplicationButton from './uninstall_application_button.vue';
+import UninstallApplicationConfirmationModal from './uninstall_application_confirmation_modal.vue';
+
+import { APPLICATION_STATUS } from '../constants';
export default {
components: {
@@ -19,6 +17,11 @@ export default {
identicon,
TimeagoTooltip,
GlLink,
+ UninstallApplicationButton,
+ UninstallApplicationConfirmationModal,
+ },
+ directives: {
+ GlModalDirective,
},
props: {
id: {
@@ -47,6 +50,11 @@ export default {
required: false,
default: false,
},
+ uninstallable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
status: {
type: String,
required: false,
@@ -55,13 +63,19 @@ export default {
type: String,
required: false,
},
- requestStatus: {
+ requestReason: {
type: String,
required: false,
},
- requestReason: {
- type: String,
+ installed: {
+ type: Boolean,
required: false,
+ default: false,
+ },
+ installFailed: {
+ type: Boolean,
+ required: false,
+ default: false,
},
version: {
type: String,
@@ -71,9 +85,33 @@ export default {
type: String,
required: false,
},
- upgradeAvailable: {
+ updateAvailable: {
+ type: Boolean,
+ required: false,
+ },
+ updateable: {
+ type: Boolean,
+ default: true,
+ },
+ updateSuccessful: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ updateFailed: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ uninstallFailed: {
type: Boolean,
required: false,
+ default: false,
+ },
+ uninstallSuccessful: {
+ type: Boolean,
+ required: false,
+ default: false,
},
installApplicationRequestParams: {
type: Object,
@@ -89,34 +127,17 @@ export default {
return Object.values(APPLICATION_STATUS).includes(this.status);
},
isInstalling() {
- return (
- this.status === APPLICATION_STATUS.SCHEDULED ||
- this.status === APPLICATION_STATUS.INSTALLING ||
- (this.requestStatus === REQUEST_SUBMITTED && !this.statusReason && !this.isInstalled)
- );
- },
- isInstalled() {
- return (
- this.status === APPLICATION_STATUS.INSTALLED ||
- this.status === APPLICATION_STATUS.UPDATED ||
- this.status === APPLICATION_STATUS.UPDATING ||
- this.status === APPLICATION_STATUS.UPDATE_ERRORED
- );
+ return this.status === APPLICATION_STATUS.INSTALLING;
},
canInstall() {
- if (this.isInstalling) {
- return false;
- }
-
return (
this.status === APPLICATION_STATUS.NOT_INSTALLABLE ||
this.status === APPLICATION_STATUS.INSTALLABLE ||
- this.status === APPLICATION_STATUS.ERROR ||
this.isUnknownStatus
);
},
hasLogo() {
- return !!this.logoUrl;
+ return Boolean(this.logoUrl);
},
identiconId() {
// generate a deterministic integer id for the identicon background
@@ -125,8 +146,14 @@ export default {
rowJsClass() {
return `js-cluster-application-row-${this.id}`;
},
+ displayUninstallButton() {
+ return this.installed && this.uninstallable;
+ },
+ displayInstallButton() {
+ return !this.installed || !this.uninstallable;
+ },
installButtonLoading() {
- return !this.status || this.status === APPLICATION_STATUS.SCHEDULED || this.isInstalling;
+ return !this.status || this.isInstalling;
},
installButtonDisabled() {
// Avoid the potential for the real-time data to say APPLICATION_STATUS.INSTALLABLE but
@@ -142,11 +169,11 @@ export default {
installButtonLabel() {
let label;
if (this.canInstall) {
- label = s__('ClusterIntegration|Install');
+ label = __('Install');
} else if (this.isInstalling) {
- label = s__('ClusterIntegration|Installing');
- } else if (this.isInstalled) {
- label = s__('ClusterIntegration|Installed');
+ label = __('Installing');
+ } else if (this.installed) {
+ label = __('Installed');
}
return label;
@@ -155,80 +182,78 @@ export default {
return this.manageLink && this.status === APPLICATION_STATUS.INSTALLED;
},
manageButtonLabel() {
- return s__('ClusterIntegration|Manage');
+ return __('Manage');
},
hasError() {
- return (
- !this.isInstalling &&
- (this.status === APPLICATION_STATUS.ERROR || this.requestStatus === REQUEST_FAILURE)
- );
+ return this.installFailed || this.uninstallFailed;
},
generalErrorDescription() {
- return sprintf(s__('ClusterIntegration|Something went wrong while installing %{title}'), {
- title: this.title,
- });
- },
- versionLabel() {
- if (this.upgradeFailed) {
- return s__('ClusterIntegration|Upgrade failed');
- } else if (this.isUpgrading) {
- return s__('ClusterIntegration|Upgrading');
+ let errorDescription;
+
+ if (this.installFailed) {
+ errorDescription = s__('ClusterIntegration|Something went wrong while installing %{title}');
+ } else if (this.uninstallFailed) {
+ errorDescription = s__(
+ 'ClusterIntegration|Something went wrong while uninstalling %{title}',
+ );
}
- return s__('ClusterIntegration|Upgraded');
- },
- upgradeRequested() {
- return this.requestStatus === UPGRADE_REQUESTED;
- },
- upgradeSuccessful() {
- return this.status === APPLICATION_STATUS.UPDATED;
+ return sprintf(errorDescription, { title: this.title });
},
- upgradeFailed() {
- if (this.isUpgrading) {
- return false;
+ versionLabel() {
+ if (this.updateFailed) {
+ return __('Update failed');
+ } else if (this.isUpdating) {
+ return __('Updating');
}
- return this.status === APPLICATION_STATUS.UPDATE_ERRORED;
- },
- upgradeFailureDescription() {
- return sprintf(
- s__(
- 'ClusterIntegration|Something went wrong when upgrading %{title}. Please check the logs and try again.',
- ),
- {
- title: this.title,
- },
- );
+ return __('Updated');
},
- upgradeSuccessDescription() {
- return sprintf(s__('ClusterIntegration|%{title} upgraded successfully.'), {
+ updateFailureDescription() {
+ return s__('ClusterIntegration|Update failed. Please check the logs and try again.');
+ },
+ updateSuccessDescription() {
+ return sprintf(s__('ClusterIntegration|%{title} updated successfully.'), {
title: this.title,
});
},
- upgradeButtonLabel() {
+ updateButtonLabel() {
let label;
- if (this.upgradeAvailable && !this.upgradeFailed && !this.isUpgrading) {
- label = s__('ClusterIntegration|Upgrade');
- } else if (this.isUpgrading) {
- label = s__('ClusterIntegration|Upgrading');
- } else if (this.upgradeFailed) {
- label = s__('ClusterIntegration|Retry upgrade');
+ if (this.updateAvailable && !this.updateFailed && !this.isUpdating) {
+ label = __('Update');
+ } else if (this.isUpdating) {
+ label = __('Updating');
+ } else if (this.updateFailed) {
+ label = __('Retry update');
}
return label;
},
- isUpgrading() {
+ isUpdating() {
// Since upgrading is handled asynchronously on the backend we need this check to prevent any delay on the frontend
- return (
- this.status === APPLICATION_STATUS.UPDATING ||
- (this.upgradeRequested && !this.upgradeSuccessful)
- );
+ return this.status === APPLICATION_STATUS.UPDATING;
+ },
+ shouldShowUpdateDetails() {
+ // This method only returns true when;
+ // Update was successful OR Update failed
+ // AND new update is unavailable AND version information is present.
+ return (this.updateSuccessful || this.updateFailed) && !this.updateAvailable && this.version;
+ },
+ uninstallSuccessDescription() {
+ return sprintf(s__('ClusterIntegration|%{title} uninstalled successfully.'), {
+ title: this.title,
+ });
},
},
watch: {
- status() {
- if (this.status === APPLICATION_STATUS.UPDATE_ERRORED) {
- eventHub.$emit('upgradeFailed', this.id);
+ updateSuccessful(updateSuccessful) {
+ if (updateSuccessful) {
+ this.$toast.show(this.updateSuccessDescription);
+ }
+ },
+ uninstallSuccessful(uninstallSuccessful) {
+ if (uninstallSuccessful) {
+ this.$toast.show(this.uninstallSuccessDescription);
}
},
},
@@ -239,14 +264,16 @@ export default {
params: this.installApplicationRequestParams,
});
},
- upgradeClicked() {
- eventHub.$emit('upgradeApplication', {
+ updateClicked() {
+ eventHub.$emit('updateApplication', {
id: this.id,
params: this.installApplicationRequestParams,
});
},
- dismissUpgradeSuccess() {
- eventHub.$emit('dismissUpgradeSuccess', this.id);
+ uninstallConfirmed() {
+ eventHub.$emit('uninstallApplication', {
+ id: this.id,
+ });
},
},
};
@@ -256,7 +283,7 @@ export default {
<div
:class="[
rowJsClass,
- isInstalled && 'cluster-application-installed',
+ installed && 'cluster-application-installed',
disabled && 'cluster-application-disabled',
]"
class="cluster-application-row gl-responsive-table-row gl-responsive-table-row-col-span"
@@ -279,16 +306,12 @@ export default {
target="blank"
rel="noopener noreferrer"
class="js-cluster-application-title"
+ >{{ title }}</a
>
- {{ title }}
- </a>
- <span v-else class="js-cluster-application-title"> {{ title }} </span>
+ <span v-else class="js-cluster-application-title">{{ title }}</span>
</strong>
<slot name="description"></slot>
- <div
- v-if="hasError || isUnknownStatus"
- class="cluster-application-error text-danger prepend-top-10"
- >
+ <div v-if="hasError" class="cluster-application-error text-danger prepend-top-10">
<p class="js-cluster-application-general-error-message append-bottom-0">
{{ generalErrorDescription }}
</p>
@@ -302,50 +325,38 @@ export default {
</ul>
</div>
- <div
- v-if="(upgradeSuccessful || upgradeFailed) && !upgradeAvailable"
- class="form-text text-muted label p-0 js-cluster-application-upgrade-details"
- >
- {{ versionLabel }}
-
- <span v-if="upgradeSuccessful"> to</span>
-
- <gl-link
- v-if="upgradeSuccessful"
- :href="chartRepo"
- target="_blank"
- class="js-cluster-application-upgrade-version"
+ <div v-if="updateable">
+ <div
+ v-if="shouldShowUpdateDetails"
+ class="form-text text-muted label p-0 js-cluster-application-update-details"
>
- chart v{{ version }}
- </gl-link>
- </div>
+ {{ versionLabel }}
+ <span v-if="updateSuccessful">to</span>
- <div
- v-if="upgradeFailed && !isUpgrading"
- class="bs-callout bs-callout-danger cluster-application-banner mt-2 mb-0 js-cluster-application-upgrade-failure-message"
- >
- {{ upgradeFailureDescription }}
- </div>
-
- <div
- v-if="upgradeRequested && upgradeSuccessful"
- class="bs-callout bs-callout-success cluster-application-banner mt-2 mb-0 p-0 pl-3"
- >
- {{ upgradeSuccessDescription }}
+ <gl-link
+ v-if="updateSuccessful"
+ :href="chartRepo"
+ target="_blank"
+ class="js-cluster-application-update-version"
+ >chart v{{ version }}</gl-link
+ >
+ </div>
- <button class="close cluster-application-banner-close" @click="dismissUpgradeSuccess">
- &times;
- </button>
+ <div
+ v-if="updateFailed && !isUpdating"
+ class="bs-callout bs-callout-danger cluster-application-banner mt-2 mb-0 js-cluster-application-update-details"
+ >
+ {{ updateFailureDescription }}
+ </div>
+ <loading-button
+ v-if="updateAvailable || updateFailed || isUpdating"
+ class="btn btn-primary js-cluster-application-update-button mt-2"
+ :loading="isUpdating"
+ :disabled="isUpdating"
+ :label="updateButtonLabel"
+ @click="updateClicked"
+ />
</div>
-
- <loading-button
- v-if="upgradeAvailable || upgradeFailed || isUpgrading"
- class="btn btn-primary js-cluster-application-upgrade-button mt-2"
- :loading="isUpgrading"
- :disabled="isUpgrading"
- :label="upgradeButtonLabel"
- @click="upgradeClicked"
- />
</div>
<div
:class="{ 'section-25': showManageButton, 'section-15': !showManageButton }"
@@ -353,18 +364,30 @@ export default {
role="gridcell"
>
<div v-if="showManageButton" class="btn-group table-action-buttons">
- <a :href="manageLink" :class="{ disabled: disabled }" class="btn">
- {{ manageButtonLabel }}
- </a>
+ <a :href="manageLink" :class="{ disabled: disabled }" class="btn">{{
+ manageButtonLabel
+ }}</a>
</div>
<div class="btn-group table-action-buttons">
<loading-button
+ v-if="displayInstallButton"
:loading="installButtonLoading"
:disabled="disabled || installButtonDisabled"
:label="installButtonLabel"
class="js-cluster-application-install-button"
@click="installClicked"
/>
+ <uninstall-application-button
+ v-if="displayUninstallButton"
+ v-gl-modal-directive="'uninstall-' + id"
+ :status="status"
+ class="js-cluster-application-uninstall-button"
+ />
+ <uninstall-application-confirmation-modal
+ :application="id"
+ :application-title="title"
+ @confirm="uninstallConfirmed()"
+ />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
index 0cf187d4189..970f5a7b297 100644
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -1,6 +1,7 @@
<script>
import _ from 'underscore';
import helmInstallIllustration from '@gitlab/svgs/dist/illustrations/kubernetes-installation.svg';
+import { GlLoadingIcon } from '@gitlab/ui';
import elasticsearchLogo from 'images/cluster_app_logos/elasticsearch.png';
import gitlabLogo from 'images/cluster_app_logos/gitlab.png';
import helmLogo from 'images/cluster_app_logos/helm.png';
@@ -14,12 +15,18 @@ import prometheusLogo from 'images/cluster_app_logos/prometheus.png';
import { s__, sprintf } from '../../locale';
import applicationRow from './application_row.vue';
import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
+import KnativeDomainEditor from './knative_domain_editor.vue';
import { CLUSTER_TYPE, APPLICATION_STATUS, INGRESS } from '../constants';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import eventHub from '~/clusters/event_hub';
export default {
components: {
applicationRow,
clipboardButton,
+ LoadingButton,
+ GlLoadingIcon,
+ KnativeDomainEditor,
},
props: {
type: {
@@ -86,53 +93,26 @@ export default {
ingressInstalled() {
return this.applications.ingress.status === APPLICATION_STATUS.INSTALLED;
},
- ingressExternalIp() {
- return this.applications.ingress.externalIp;
+ ingressExternalEndpoint() {
+ return this.applications.ingress.externalIp || this.applications.ingress.externalHostname;
},
certManagerInstalled() {
return this.applications.cert_manager.status === APPLICATION_STATUS.INSTALLED;
},
ingressDescription() {
- const extraCostParagraph = sprintf(
- _.escape(
- s__(
- `ClusterIntegration|%{boldNotice} This will add some extra resources
- like a load balancer, which may incur additional costs depending on
- the hosting provider your Kubernetes cluster is installed on. If you are using
- Google Kubernetes Engine, you can %{pricingLink}.`,
- ),
- ),
- {
- boldNotice: `<strong>${_.escape(s__('ClusterIntegration|Note:'))}</strong>`,
- pricingLink: `<a href="https://cloud.google.com/compute/pricing#lb" target="_blank" rel="noopener noreferrer">
- ${_.escape(s__('ClusterIntegration|check the pricing here'))}</a>`,
- },
- false,
- );
-
- const externalIpParagraph = sprintf(
+ return sprintf(
_.escape(
s__(
- `ClusterIntegration|After installing Ingress, you will need to point your wildcard DNS
- at the generated external IP address in order to view your app after it is deployed. %{ingressHelpLink}`,
+ `ClusterIntegration|Installing Ingress may incur additional costs. Learn more about %{pricingLink}.`,
),
),
{
- ingressHelpLink: `<a href="${this.ingressHelpPath}">
- ${_.escape(s__('ClusterIntegration|More information'))}
- </a>`,
+ pricingLink: `<strong><a href="https://cloud.google.com/compute/pricing#lb"
+ target="_blank" rel="noopener noreferrer">
+ ${_.escape(s__('ClusterIntegration|pricing'))}</a></strong>`,
},
false,
);
-
- return `
- <p>
- ${extraCostParagraph}
- </p>
- <p class="settings-message append-bottom-0">
- ${externalIpParagraph}
- </p>
- `;
},
certManagerDescription() {
return sprintf(
@@ -173,16 +153,27 @@ export default {
jupyterHostname() {
return this.applications.jupyter.hostname;
},
- knativeInstalled() {
- return this.applications.knative.status === APPLICATION_STATUS.INSTALLED;
- },
- knativeExternalIp() {
- return this.applications.knative.externalIp;
+ knative() {
+ return this.applications.knative;
},
},
created() {
this.helmInstallIllustration = helmInstallIllustration;
},
+ methods: {
+ saveKnativeDomain(hostname) {
+ eventHub.$emit('saveKnativeDomain', {
+ id: 'knative',
+ params: { hostname },
+ });
+ },
+ setKnativeHostname(hostname) {
+ eventHub.$emit('setKnativeHostname', {
+ id: 'knative',
+ hostname,
+ });
+ },
+ },
};
</script>
@@ -192,9 +183,9 @@ export default {
<p class="append-bottom-0">
{{
s__(`ClusterIntegration|Choose which applications to install on your Kubernetes cluster.
- Helm Tiller is required to install any of the following applications.`)
+ Helm Tiller is required to install any of the following applications.`)
}}
- <a :href="helpPath"> {{ __('More information') }} </a>
+ <a :href="helpPath">{{ __('More information') }}</a>
</p>
<div class="cluster-application-list prepend-top-10">
@@ -206,15 +197,20 @@ export default {
:status-reason="applications.helm.statusReason"
:request-status="applications.helm.requestStatus"
:request-reason="applications.helm.requestReason"
+ :installed="applications.helm.installed"
+ :install-failed="applications.helm.installFailed"
+ :uninstallable="applications.helm.uninstallable"
+ :uninstall-successful="applications.helm.uninstallSuccessful"
+ :uninstall-failed="applications.helm.uninstallFailed"
class="rounded-top"
title-link="https://docs.helm.sh/"
>
<div slot="description">
{{
s__(`ClusterIntegration|Helm streamlines installing
- and managing Kubernetes applications.
- Tiller runs inside of your Kubernetes Cluster,
- and manages releases of your charts.`)
+ and managing Kubernetes applications.
+ Tiller runs inside of your Kubernetes Cluster,
+ and manages releases of your charts.`)
}}
</div>
</application-row>
@@ -222,7 +218,7 @@ export default {
<div class="svg-container" v-html="helmInstallIllustration"></div>
{{
s__(`ClusterIntegration|You must first install Helm Tiller before
- installing the applications below`)
+ installing the applications below`)
}}
</div>
<application-row
@@ -233,6 +229,11 @@ export default {
:status-reason="applications.ingress.statusReason"
:request-status="applications.ingress.requestStatus"
:request-reason="applications.ingress.requestReason"
+ :installed="applications.ingress.installed"
+ :install-failed="applications.ingress.installFailed"
+ :uninstallable="applications.ingress.uninstallable"
+ :uninstall-successful="applications.ingress.uninstallSuccessful"
+ :uninstall-failed="applications.ingress.uninstallFailed"
:disabled="!helmInstalled"
title-link="https://kubernetes.io/docs/concepts/services-networking/ingress/"
>
@@ -240,37 +241,40 @@ export default {
<p>
{{
s__(`ClusterIntegration|Ingress gives you a way to route
- requests to services based on the request host or path,
- centralizing a number of services into a single entrypoint.`)
+ requests to services based on the request host or path,
+ centralizing a number of services into a single entrypoint.`)
}}
</p>
<template v-if="ingressInstalled">
<div class="form-group">
- <label for="ingress-ip-address">
- {{ s__('ClusterIntegration|Ingress IP Address') }}
- </label>
- <div v-if="ingressExternalIp" class="input-group">
+ <label for="ingress-endpoint">{{ s__('ClusterIntegration|Ingress Endpoint') }}</label>
+ <div v-if="ingressExternalEndpoint" class="input-group">
<input
- id="ingress-ip-address"
- :value="ingressExternalIp"
+ id="ingress-endpoint"
+ :value="ingressExternalEndpoint"
type="text"
- class="form-control js-ip-address"
+ class="form-control js-endpoint"
readonly
/>
<span class="input-group-append">
<clipboard-button
- :text="ingressExternalIp"
- :title="s__('ClusterIntegration|Copy Ingress IP Address to clipboard')"
+ :text="ingressExternalEndpoint"
+ :title="s__('ClusterIntegration|Copy Ingress Endpoint to clipboard')"
class="input-group-text js-clipboard-btn"
/>
</span>
</div>
- <input v-else type="text" class="form-control js-ip-address" readonly value="?" />
+ <div v-else class="input-group">
+ <input type="text" class="form-control js-endpoint" readonly />
+ <gl-loading-icon
+ class="position-absolute align-self-center ml-2 js-ingress-ip-loading-icon"
+ />
+ </div>
<p class="form-text text-muted">
{{
s__(`ClusterIntegration|Point a wildcard DNS to this
- generated IP address in order to access
+ generated endpoint in order to access
your application after it has been deployed.`)
}}
<a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
@@ -279,19 +283,20 @@ export default {
</p>
</div>
- <p v-if="!ingressExternalIp" class="settings-message js-no-ip-message">
+ <p v-if="!ingressExternalEndpoint" class="settings-message js-no-endpoint-message">
{{
- s__(`ClusterIntegration|The IP address is in
+ s__(`ClusterIntegration|The endpoint is in
the process of being assigned. Please check your Kubernetes
cluster or Quotas on Google Kubernetes Engine if it takes a long time.`)
}}
-
- <a :href="ingressHelpPath" target="_blank" rel="noopener noreferrer">
+ <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
{{ __('More information') }}
</a>
</p>
</template>
- <div v-html="ingressDescription"></div>
+ <template v-if="!ingressInstalled">
+ <div class="bs-callout bs-callout-info" v-html="ingressDescription"></div>
+ </template>
</div>
</application-row>
<application-row
@@ -302,7 +307,12 @@ export default {
:status-reason="applications.cert_manager.statusReason"
:request-status="applications.cert_manager.requestStatus"
:request-reason="applications.cert_manager.requestReason"
+ :installed="applications.cert_manager.installed"
+ :install-failed="applications.cert_manager.installFailed"
:install-application-request-params="{ email: applications.cert_manager.email }"
+ :uninstallable="applications.cert_manager.uninstallable"
+ :uninstall-successful="applications.cert_manager.uninstallSuccessful"
+ :uninstall-failed="applications.cert_manager.uninstallFailed"
:disabled="!helmInstalled"
title-link="https://cert-manager.readthedocs.io/en/latest/#"
>
@@ -324,22 +334,20 @@ export default {
<p class="form-text text-muted">
{{
s__(`ClusterIntegration|Issuers represent a certificate authority.
- You must provide an email address for your Issuer. `)
+ You must provide an email address for your Issuer. `)
}}
<a
href="http://docs.cert-manager.io/en/latest/reference/issuers.html?highlight=email"
target="_blank"
rel="noopener noreferrer"
+ >{{ __('More information') }}</a
>
- {{ __('More information') }}
- </a>
</p>
</div>
</div>
</template>
</application-row>
<application-row
- v-if="isProjectCluster"
id="prometheus"
:logo-url="prometheusLogo"
:title="applications.prometheus.title"
@@ -348,13 +356,17 @@ export default {
:status-reason="applications.prometheus.statusReason"
:request-status="applications.prometheus.requestStatus"
:request-reason="applications.prometheus.requestReason"
+ :installed="applications.prometheus.installed"
+ :install-failed="applications.prometheus.installFailed"
+ :uninstallable="applications.prometheus.uninstallable"
+ :uninstall-successful="applications.prometheus.uninstallSuccessful"
+ :uninstall-failed="applications.prometheus.uninstallFailed"
:disabled="!helmInstalled"
title-link="https://prometheus.io/docs/introduction/overview/"
>
<div slot="description" v-html="prometheusDescription"></div>
</application-row>
<application-row
- v-if="isProjectCluster"
id="runner"
:logo-url="gitlabLogo"
:title="applications.runner.title"
@@ -364,16 +376,23 @@ export default {
:request-reason="applications.runner.requestReason"
:version="applications.runner.version"
:chart-repo="applications.runner.chartRepo"
- :upgrade-available="applications.runner.upgradeAvailable"
+ :update-available="applications.runner.updateAvailable"
+ :installed="applications.runner.installed"
+ :install-failed="applications.runner.installFailed"
+ :update-successful="applications.runner.updateSuccessful"
+ :update-failed="applications.runner.updateFailed"
+ :uninstallable="applications.runner.uninstallable"
+ :uninstall-successful="applications.runner.uninstallSuccessful"
+ :uninstall-failed="applications.runner.uninstallFailed"
:disabled="!helmInstalled"
title-link="https://docs.gitlab.com/runner/"
>
<div slot="description">
{{
- s__(`ClusterIntegration|GitLab Runner connects to this
- project's repository and executes CI/CD jobs,
- pushing results back and deploying,
- applications to production.`)
+ s__(`ClusterIntegration|GitLab Runner connects to the
+ repository and executes CI/CD jobs,
+ pushing results back and deploying
+ applications to production.`)
}}
</div>
</application-row>
@@ -386,6 +405,11 @@ export default {
:status-reason="applications.jupyter.statusReason"
:request-status="applications.jupyter.requestStatus"
:request-reason="applications.jupyter.requestReason"
+ :installed="applications.jupyter.installed"
+ :install-failed="applications.jupyter.installFailed"
+ :uninstallable="applications.jupyter.uninstallable"
+ :uninstall-successful="applications.jupyter.uninstallSuccessful"
+ :uninstall-failed="applications.jupyter.uninstallFailed"
:install-application-request-params="{ hostname: applications.jupyter.hostname }"
:disabled="!helmInstalled"
title-link="https://jupyterhub.readthedocs.io/en/stable/"
@@ -394,18 +418,16 @@ export default {
<p>
{{
s__(`ClusterIntegration|JupyterHub, a multi-user Hub, spawns,
- manages, and proxies multiple instances of the single-user
- Jupyter notebook server. JupyterHub can be used to serve
- notebooks to a class of students, a corporate data science group,
- or a scientific research group.`)
+ manages, and proxies multiple instances of the single-user
+ Jupyter notebook server. JupyterHub can be used to serve
+ notebooks to a class of students, a corporate data science group,
+ or a scientific research group.`)
}}
</p>
- <template v-if="ingressExternalIp">
+ <template v-if="ingressExternalEndpoint">
<div class="form-group">
- <label for="jupyter-hostname">
- {{ s__('ClusterIntegration|Jupyter Hostname') }}
- </label>
+ <label for="jupyter-hostname">{{ s__('ClusterIntegration|Jupyter Hostname') }}</label>
<div class="input-group">
<input
@@ -445,13 +467,20 @@ export default {
:status-reason="applications.knative.statusReason"
:request-status="applications.knative.requestStatus"
:request-reason="applications.knative.requestReason"
+ :installed="applications.knative.installed"
+ :install-failed="applications.knative.installFailed"
:install-application-request-params="{ hostname: applications.knative.hostname }"
+ :uninstallable="applications.knative.uninstallable"
+ :uninstall-successful="applications.knative.uninstallSuccessful"
+ :uninstall-failed="applications.knative.uninstallFailed"
+ :updateable="false"
:disabled="!helmInstalled"
+ v-bind="applications.knative"
title-link="https://github.com/knative/docs"
>
<div slot="description">
<span v-if="!rbac">
- <p v-if="!rbac" class="bs-callout bs-callout-info append-bottom-0">
+ <p v-if="!rbac" class="rbac-notice bs-callout bs-callout-info append-bottom-0">
{{
s__(`ClusterIntegration|You must have an RBAC-enabled cluster
to install Knative.`)
@@ -465,82 +494,19 @@ export default {
<p>
{{
s__(`ClusterIntegration|Knative extends Kubernetes to provide
- a set of middleware components that are essential to build modern,
- source-centric, and container-based applications that can run
- anywhere: on premises, in the cloud, or even in a third-party data center.`)
+ a set of middleware components that are essential to build modern,
+ source-centric, and container-based applications that can run
+ anywhere: on premises, in the cloud, or even in a third-party data center.`)
}}
</p>
- <template v-if="knativeInstalled">
- <div class="form-group">
- <label for="knative-domainname">
- {{ s__('ClusterIntegration|Knative Domain Name:') }}
- </label>
- <input
- id="knative-domainname"
- v-model="applications.knative.hostname"
- type="text"
- class="form-control js-domainname"
- readonly
- />
- </div>
- </template>
- <template v-else-if="helmInstalled && rbac">
- <div class="form-group">
- <label for="knative-domainname">
- {{ s__('ClusterIntegration|Knative Domain Name:') }}
- </label>
- <input
- id="knative-domainname"
- v-model="applications.knative.hostname"
- type="text"
- class="form-control js-domainname"
- />
- </div>
- </template>
- <template v-if="knativeInstalled">
- <div class="form-group">
- <label for="knative-ip-address">
- {{ s__('ClusterIntegration|Knative IP Address:') }}
- </label>
- <div v-if="knativeExternalIp" class="input-group">
- <input
- id="knative-ip-address"
- :value="knativeExternalIp"
- type="text"
- class="form-control js-ip-address"
- readonly
- />
- <span class="input-group-append">
- <clipboard-button
- :text="knativeExternalIp"
- :title="s__('ClusterIntegration|Copy Knative IP Address to clipboard')"
- class="input-group-text js-clipboard-btn"
- />
- </span>
- </div>
- <input v-else type="text" class="form-control js-ip-address" readonly value="?" />
- </div>
-
- <p v-if="!knativeExternalIp" class="settings-message js-no-ip-message">
- {{
- s__(`ClusterIntegration|The IP address is in
- the process of being assigned. Please check your Kubernetes
- cluster or Quotas on Google Kubernetes Engine if it takes a long time.`)
- }}
- </p>
-
- <p>
- {{
- s__(`ClusterIntegration|Point a wildcard DNS to this
- generated IP address in order to access
- your application after it has been deployed.`)
- }}
- <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
- {{ __('More information') }}
- </a>
- </p>
- </template>
+ <knative-domain-editor
+ v-if="knative.installed || (helmInstalled && rbac)"
+ :knative="knative"
+ :ingress-dns-help-path="ingressDnsHelpPath"
+ @save="saveKnativeDomain"
+ @set="setKnativeHostname"
+ />
</div>
</application-row>
</div>
diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue
new file mode 100644
index 00000000000..480228619a5
--- /dev/null
+++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue
@@ -0,0 +1,150 @@
+<script>
+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';
+
+const { UPDATING, UNINSTALLING } = APPLICATION_STATUS;
+
+export default {
+ components: {
+ LoadingButton,
+ ClipboardButton,
+ GlLoadingIcon,
+ },
+ props: {
+ knative: {
+ type: Object,
+ required: true,
+ },
+ ingressDnsHelpPath: {
+ type: String,
+ default: '',
+ },
+ },
+ computed: {
+ saveButtonDisabled() {
+ return [UNINSTALLING, UPDATING].includes(this.knative.status);
+ },
+ saving() {
+ return [UPDATING].includes(this.knative.status);
+ },
+ saveButtonLabel() {
+ return this.saving ? this.__('Saving') : this.__('Save changes');
+ },
+ knativeInstalled() {
+ return this.knative.installed;
+ },
+ knativeExternalEndpoint() {
+ return this.knative.externalIp || this.knative.externalHostname;
+ },
+ knativeUpdateSuccessful() {
+ return this.knative.updateSuccessful;
+ },
+ knativeHostname: {
+ get() {
+ return this.knative.hostname;
+ },
+ set(hostname) {
+ this.$emit('set', hostname);
+ },
+ },
+ },
+ watch: {
+ knativeUpdateSuccessful(updateSuccessful) {
+ if (updateSuccessful) {
+ this.$toast.show(s__('ClusterIntegration|Knative domain name was updated successfully.'));
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="row">
+ <div
+ v-if="knative.updateFailed"
+ class="bs-callout bs-callout-danger cluster-application-banner col-12 mt-2 mb-2 js-cluster-knative-domain-name-failure-message"
+ >
+ {{ s__('ClusterIntegration|Something went wrong while updating Knative domain name.') }}
+ </div>
+
+ <template>
+ <div
+ :class="{ 'col-md-6': knativeInstalled, 'col-12': !knativeInstalled }"
+ class="form-group col-sm-12 mb-0"
+ >
+ <label for="knative-domainname">
+ <strong>{{ s__('ClusterIntegration|Knative Domain Name:') }}</strong>
+ </label>
+ <input
+ id="knative-domainname"
+ v-model="knativeHostname"
+ type="text"
+ class="form-control js-knative-domainname"
+ />
+ </div>
+ </template>
+ <template v-if="knativeInstalled">
+ <div class="form-group col-sm-12 col-md-6 pl-md-0 mb-0 mt-3 mt-md-0">
+ <label for="knative-endpoint">
+ <strong>{{ s__('ClusterIntegration|Knative Endpoint:') }}</strong>
+ </label>
+ <div v-if="knativeExternalEndpoint" class="input-group">
+ <input
+ id="knative-endpoint"
+ :value="knativeExternalEndpoint"
+ type="text"
+ class="form-control js-knative-endpoint"
+ readonly
+ />
+ <span class="input-group-append">
+ <clipboard-button
+ :text="knativeExternalEndpoint"
+ :title="s__('ClusterIntegration|Copy Knative Endpoint to clipboard')"
+ class="input-group-text js-knative-endpoint-clipboard-btn"
+ />
+ </span>
+ </div>
+ <div v-else class="input-group">
+ <input type="text" class="form-control js-endpoint" readonly />
+ <gl-loading-icon
+ class="position-absolute align-self-center ml-2 js-knative-ip-loading-icon"
+ />
+ </div>
+ </div>
+
+ <p class="form-text text-muted col-12">
+ {{
+ s__(
+ `ClusterIntegration|To access your application after deployment, point a wildcard DNS to the Knative Endpoint.`,
+ )
+ }}
+ <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
+ {{ __('More information') }}
+ </a>
+ </p>
+
+ <p
+ v-if="!knativeExternalEndpoint"
+ class="settings-message js-no-knative-endpoint-message mt-2 mr-3 mb-0 ml-3"
+ >
+ {{
+ s__(`ClusterIntegration|The endpoint is in
+ the process of being assigned. Please check your Kubernetes
+ cluster or Quotas on Google Kubernetes Engine if it takes a long time.`)
+ }}
+ </p>
+
+ <loading-button
+ class="btn-success js-knative-save-domain-button mt-3 ml-3"
+ :loading="saving"
+ :disabled="saveButtonDisabled"
+ :label="saveButtonLabel"
+ @click="$emit('save', knativeHostname)"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters/components/uninstall_application_button.vue b/app/assets/javascripts/clusters/components/uninstall_application_button.vue
new file mode 100644
index 00000000000..ef4bcbe14dd
--- /dev/null
+++ b/app/assets/javascripts/clusters/components/uninstall_application_button.vue
@@ -0,0 +1,33 @@
+<script>
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import { APPLICATION_STATUS } from '~/clusters/constants';
+
+const { UPDATING, UNINSTALLING } = APPLICATION_STATUS;
+
+export default {
+ components: {
+ LoadingButton,
+ },
+ props: {
+ status: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ disabled() {
+ return [UNINSTALLING, UPDATING].includes(this.status);
+ },
+ loading() {
+ return this.status === UNINSTALLING;
+ },
+ label() {
+ return this.loading ? this.__('Uninstalling') : this.__('Uninstall');
+ },
+ },
+};
+</script>
+
+<template>
+ <loading-button :label="label" :disabled="disabled" :loading="loading" />
+</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
new file mode 100644
index 00000000000..65827f1cb6a
--- /dev/null
+++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
@@ -0,0 +1,74 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { sprintf, s__ } from '~/locale';
+import trackUninstallButtonClickMixin from 'ee_else_ce/clusters/mixins/track_uninstall_button_click';
+import { INGRESS, CERT_MANAGER, PROMETHEUS, RUNNER, KNATIVE, JUPYTER } from '../constants';
+
+const CUSTOM_APP_WARNING_TEXT = {
+ [INGRESS]: s__(
+ 'ClusterIntegration|The associated load balancer and IP will be deleted and cannot be restored.',
+ ),
+ [CERT_MANAGER]: s__(
+ 'ClusterIntegration|The associated certifcate will be deleted and cannot be restored.',
+ ),
+ [PROMETHEUS]: s__('ClusterIntegration|All data will be deleted and cannot be restored.'),
+ [RUNNER]: s__('ClusterIntegration|Any running pipelines will be canceled.'),
+ [KNATIVE]: s__('ClusterIntegration|The associated IP will be deleted and cannot be restored.'),
+ [JUPYTER]: '',
+};
+
+export default {
+ components: {
+ GlModal,
+ },
+ mixins: [trackUninstallButtonClickMixin],
+ props: {
+ application: {
+ type: String,
+ required: true,
+ },
+ applicationTitle: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ title() {
+ return sprintf(s__('ClusterIntegration|Uninstall %{appTitle}'), {
+ appTitle: this.applicationTitle,
+ });
+ },
+ warningText() {
+ return sprintf(
+ s__('ClusterIntegration|You are about to uninstall %{appTitle} from your cluster.'),
+ {
+ appTitle: this.applicationTitle,
+ },
+ );
+ },
+ customAppWarningText() {
+ return CUSTOM_APP_WARNING_TEXT[this.application];
+ },
+ modalId() {
+ return `uninstall-${this.application}`;
+ },
+ },
+ methods: {
+ confirmUninstall() {
+ this.trackUninstallButtonClick(this.application);
+ this.$emit('confirm');
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ ok-variant="danger"
+ cancel-variant="light"
+ :ok-title="title"
+ :modal-id="modalId"
+ :title="title"
+ @ok="confirmUninstall()"
+ >{{ warningText }} {{ customAppWarningText }}</gl-modal
+ >
+</template>
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
index 39022879d91..8fd752092c9 100644
--- a/app/assets/javascripts/clusters/constants.js
+++ b/app/assets/javascripts/clusters/constants.js
@@ -7,6 +7,7 @@ export const CLUSTER_TYPE = {
// These need to match what is returned from the server
export const APPLICATION_STATUS = {
+ NO_STATUS: null,
NOT_INSTALLABLE: 'not_installable',
INSTALLABLE: 'installable',
SCHEDULED: 'scheduled',
@@ -15,16 +16,35 @@ export const APPLICATION_STATUS = {
UPDATING: 'updating',
UPDATED: 'updated',
UPDATE_ERRORED: 'update_errored',
+ UNINSTALLING: 'uninstalling',
+ UNINSTALL_ERRORED: 'uninstall_errored',
ERROR: 'errored',
};
+/*
+ * The application cannot be in any of the following states without
+ * not being installed.
+ */
+export const APPLICATION_INSTALLED_STATUSES = [
+ APPLICATION_STATUS.INSTALLED,
+ APPLICATION_STATUS.UPDATING,
+ APPLICATION_STATUS.UNINSTALLING,
+];
+
// These are only used client-side
-export const REQUEST_SUBMITTED = 'request-submitted';
-export const REQUEST_FAILURE = 'request-failure';
-export const UPGRADE_REQUESTED = 'upgrade-requested';
-export const UPGRADE_REQUEST_FAILURE = 'upgrade-request-failure';
+
+export const UPDATE_EVENT = 'update';
+export const INSTALL_EVENT = 'install';
+export const UNINSTALL_EVENT = 'uninstall';
+
+export const HELM = 'helm';
export const INGRESS = 'ingress';
export const JUPYTER = 'jupyter';
export const KNATIVE = 'knative';
export const RUNNER = 'runner';
export const CERT_MANAGER = 'cert_manager';
+export const PROMETHEUS = 'prometheus';
+
+export const APPLICATIONS = [HELM, INGRESS, JUPYTER, KNATIVE, RUNNER, CERT_MANAGER, PROMETHEUS];
+
+export const INGRESS_DOMAIN_SUFFIX = '.nip.io';
diff --git a/app/assets/javascripts/clusters/mixins/track_uninstall_button_click.js b/app/assets/javascripts/clusters/mixins/track_uninstall_button_click.js
new file mode 100644
index 00000000000..18f65b234d3
--- /dev/null
+++ b/app/assets/javascripts/clusters/mixins/track_uninstall_button_click.js
@@ -0,0 +1,5 @@
+export default {
+ methods: {
+ trackUninstallButtonClick: () => {},
+ },
+};
diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js
new file mode 100644
index 00000000000..17ea4d77795
--- /dev/null
+++ b/app/assets/javascripts/clusters/services/application_state_machine.js
@@ -0,0 +1,174 @@
+import { APPLICATION_STATUS, UPDATE_EVENT, INSTALL_EVENT, UNINSTALL_EVENT } from '../constants';
+
+const {
+ NO_STATUS,
+ SCHEDULED,
+ NOT_INSTALLABLE,
+ INSTALLABLE,
+ INSTALLING,
+ INSTALLED,
+ ERROR,
+ UPDATING,
+ UPDATED,
+ UPDATE_ERRORED,
+ UNINSTALLING,
+ UNINSTALL_ERRORED,
+} = APPLICATION_STATUS;
+
+const applicationStateMachine = {
+ /* When the application initially loads, it will have `NO_STATUS`
+ * It will transition from `NO_STATUS` once the async backend call is completed
+ */
+ [NO_STATUS]: {
+ on: {
+ [SCHEDULED]: {
+ target: INSTALLING,
+ },
+ [NOT_INSTALLABLE]: {
+ target: NOT_INSTALLABLE,
+ },
+ [INSTALLABLE]: {
+ target: INSTALLABLE,
+ },
+ [INSTALLING]: {
+ target: INSTALLING,
+ },
+ [INSTALLED]: {
+ target: INSTALLED,
+ },
+ [ERROR]: {
+ target: INSTALLABLE,
+ effects: {
+ installFailed: true,
+ },
+ },
+ [UPDATING]: {
+ target: UPDATING,
+ },
+ [UPDATED]: {
+ target: INSTALLED,
+ },
+ [UPDATE_ERRORED]: {
+ target: INSTALLED,
+ effects: {
+ updateFailed: true,
+ },
+ },
+ [UNINSTALLING]: {
+ target: UNINSTALLING,
+ },
+ [UNINSTALL_ERRORED]: {
+ target: INSTALLED,
+ effects: {
+ uninstallFailed: true,
+ },
+ },
+ },
+ },
+ [NOT_INSTALLABLE]: {
+ on: {
+ [INSTALLABLE]: {
+ target: INSTALLABLE,
+ },
+ },
+ },
+ [INSTALLABLE]: {
+ on: {
+ [INSTALL_EVENT]: {
+ target: INSTALLING,
+ effects: {
+ installFailed: false,
+ },
+ },
+ // This is possible in artificial environments for E2E testing
+ [INSTALLED]: {
+ target: INSTALLED,
+ },
+ },
+ },
+ [INSTALLING]: {
+ on: {
+ [INSTALLED]: {
+ target: INSTALLED,
+ },
+ [ERROR]: {
+ target: INSTALLABLE,
+ effects: {
+ installFailed: true,
+ },
+ },
+ },
+ },
+ [INSTALLED]: {
+ on: {
+ [UPDATE_EVENT]: {
+ target: UPDATING,
+ effects: {
+ updateFailed: false,
+ updateSuccessful: false,
+ },
+ },
+ [UNINSTALL_EVENT]: {
+ target: UNINSTALLING,
+ effects: {
+ uninstallFailed: false,
+ uninstallSuccessful: false,
+ },
+ },
+ },
+ },
+ [UPDATING]: {
+ on: {
+ [UPDATED]: {
+ target: INSTALLED,
+ effects: {
+ updateSuccessful: true,
+ },
+ },
+ [UPDATE_ERRORED]: {
+ target: INSTALLED,
+ effects: {
+ updateFailed: true,
+ },
+ },
+ },
+ },
+ [UNINSTALLING]: {
+ on: {
+ [INSTALLABLE]: {
+ target: INSTALLABLE,
+ effects: {
+ uninstallSuccessful: true,
+ },
+ },
+ [UNINSTALL_ERRORED]: {
+ target: INSTALLED,
+ effects: {
+ uninstallFailed: true,
+ },
+ },
+ },
+ },
+};
+
+/**
+ * Determines an application new state based on the application current state
+ * and an event. If the application current state cannot handle a given event,
+ * the current state is returned.
+ *
+ * @param {*} application
+ * @param {*} event
+ */
+const transitionApplicationState = (application, event) => {
+ const newState = applicationStateMachine[application.status].on[event];
+
+ return newState
+ ? {
+ ...application,
+ status: newState.target,
+ ...newState.effects,
+ }
+ : application;
+};
+
+export default transitionApplicationState;
diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js
index 89dda4b7902..01f3732de7e 100644
--- a/app/assets/javascripts/clusters/services/clusters_service.js
+++ b/app/assets/javascripts/clusters/services/clusters_service.js
@@ -12,6 +12,9 @@ export default class ClusterService {
jupyter: this.options.installJupyterEndpoint,
knative: this.options.installKnativeEndpoint,
};
+ this.appUpdateEndpointMap = {
+ knative: this.options.updateKnativeEndpoint,
+ };
}
fetchData() {
@@ -22,6 +25,14 @@ export default class ClusterService {
return axios.post(this.appInstallEndpointMap[appId], params);
}
+ updateApplication(appId, params) {
+ return axios.patch(this.appUpdateEndpointMap[appId], params);
+ }
+
+ uninstallApplication(appId, params) {
+ return axios.delete(this.appInstallEndpointMap[appId], params);
+ }
+
static updateCluster(endpoint, data) {
return axios.put(endpoint, data);
}
diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js
index d309678be27..f64f0ca616f 100644
--- a/app/assets/javascripts/clusters/stores/clusters_store.js
+++ b/app/assets/javascripts/clusters/stores/clusters_store.js
@@ -1,6 +1,31 @@
import { s__ } from '../../locale';
import { parseBoolean } from '../../lib/utils/common_utils';
-import { INGRESS, JUPYTER, KNATIVE, CERT_MANAGER, RUNNER } from '../constants';
+import {
+ INGRESS,
+ JUPYTER,
+ KNATIVE,
+ CERT_MANAGER,
+ RUNNER,
+ APPLICATION_INSTALLED_STATUSES,
+ APPLICATION_STATUS,
+ INSTALL_EVENT,
+ UPDATE_EVENT,
+ UNINSTALL_EVENT,
+} from '../constants';
+import transitionApplicationState from '../services/application_state_machine';
+
+const isApplicationInstalled = appStatus => APPLICATION_INSTALLED_STATUSES.includes(appStatus);
+
+const applicationInitialState = {
+ status: null,
+ statusReason: null,
+ requestReason: null,
+ installed: false,
+ installFailed: false,
+ uninstallable: false,
+ uninstallFailed: false,
+ uninstallSuccessful: false,
+};
export default class ClusterStore {
constructor() {
@@ -12,61 +37,47 @@ export default class ClusterStore {
statusReason: null,
applications: {
helm: {
+ ...applicationInitialState,
title: s__('ClusterIntegration|Helm Tiller'),
- status: null,
- statusReason: null,
- requestStatus: null,
- requestReason: null,
},
ingress: {
+ ...applicationInitialState,
title: s__('ClusterIntegration|Ingress'),
- status: null,
- statusReason: null,
- requestStatus: null,
- requestReason: null,
externalIp: null,
+ externalHostname: null,
},
cert_manager: {
+ ...applicationInitialState,
title: s__('ClusterIntegration|Cert-Manager'),
- status: null,
- statusReason: null,
- requestStatus: null,
- requestReason: null,
email: null,
},
runner: {
+ ...applicationInitialState,
title: s__('ClusterIntegration|GitLab Runner'),
- status: null,
- statusReason: null,
- requestStatus: null,
- requestReason: null,
version: null,
chartRepo: 'https://gitlab.com/charts/gitlab-runner',
- upgradeAvailable: null,
+ updateAvailable: null,
+ updateSuccessful: false,
+ updateFailed: false,
},
prometheus: {
+ ...applicationInitialState,
title: s__('ClusterIntegration|Prometheus'),
- status: null,
- statusReason: null,
- requestStatus: null,
- requestReason: null,
},
jupyter: {
+ ...applicationInitialState,
title: s__('ClusterIntegration|JupyterHub'),
- status: null,
- statusReason: null,
- requestStatus: null,
- requestReason: null,
hostname: null,
},
knative: {
+ ...applicationInitialState,
title: s__('ClusterIntegration|Knative'),
- status: null,
- statusReason: null,
- requestStatus: null,
- requestReason: null,
hostname: null,
+ isEditingHostName: false,
externalIp: null,
+ externalHostname: null,
+ updateSuccessful: false,
+ updateFailed: false,
},
},
};
@@ -94,6 +105,36 @@ export default class ClusterStore {
this.state.statusReason = reason;
}
+ installApplication(appId) {
+ this.handleApplicationEvent(appId, INSTALL_EVENT);
+ }
+
+ notifyInstallFailure(appId) {
+ this.handleApplicationEvent(appId, APPLICATION_STATUS.ERROR);
+ }
+
+ updateApplication(appId) {
+ this.handleApplicationEvent(appId, UPDATE_EVENT);
+ }
+
+ notifyUpdateFailure(appId) {
+ this.handleApplicationEvent(appId, APPLICATION_STATUS.UPDATE_ERRORED);
+ }
+
+ uninstallApplication(appId) {
+ this.handleApplicationEvent(appId, UNINSTALL_EVENT);
+ }
+
+ notifyUninstallFailure(appId) {
+ this.handleApplicationEvent(appId, APPLICATION_STATUS.UNINSTALL_ERRORED);
+ }
+
+ handleApplicationEvent(appId, event) {
+ const currentAppState = this.state.applications[appId];
+
+ this.state.applications[appId] = transitionApplicationState(currentAppState, event);
+ }
+
updateAppProperty(appId, prop, value) {
this.state.applications[appId][prop] = value;
}
@@ -108,17 +149,23 @@ export default class ClusterStore {
status,
status_reason: statusReason,
version,
- update_available: upgradeAvailable,
+ update_available: updateAvailable,
+ can_uninstall: uninstallable,
} = serverAppEntry;
+ const currentApplicationState = this.state.applications[appId] || {};
+ const nextApplicationState = transitionApplicationState(currentApplicationState, status);
this.state.applications[appId] = {
- ...(this.state.applications[appId] || {}),
- status,
+ ...currentApplicationState,
+ ...nextApplicationState,
statusReason,
+ installed: isApplicationInstalled(nextApplicationState.status),
+ uninstallable,
};
if (appId === INGRESS) {
this.state.applications.ingress.externalIp = serverAppEntry.external_ip;
+ this.state.applications.ingress.externalHostname = serverAppEntry.external_hostname;
} else if (appId === CERT_MANAGER) {
this.state.applications.cert_manager.email =
this.state.applications.cert_manager.email || serverAppEntry.email;
@@ -129,13 +176,17 @@ export default class ClusterStore {
? `jupyter.${this.state.applications.ingress.externalIp}.nip.io`
: '');
} else if (appId === KNATIVE) {
- this.state.applications.knative.hostname =
- serverAppEntry.hostname || this.state.applications.knative.hostname;
+ if (!this.state.applications.knative.isEditingHostName) {
+ this.state.applications.knative.hostname =
+ serverAppEntry.hostname || this.state.applications.knative.hostname;
+ }
this.state.applications.knative.externalIp =
serverAppEntry.external_ip || this.state.applications.knative.externalIp;
+ this.state.applications.knative.externalHostname =
+ serverAppEntry.external_hostname || this.state.applications.knative.externalHostname;
} else if (appId === RUNNER) {
this.state.applications.runner.version = version;
- this.state.applications.runner.upgradeAvailable = upgradeAvailable;
+ this.state.applications.runner.updateAvailable = updateAvailable;
}
});
}
diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js
index d4ecfa4aa93..bc666aef54b 100644
--- a/app/assets/javascripts/commit/image_file.js
+++ b/app/assets/javascripts/commit/image_file.js
@@ -71,29 +71,39 @@ export default class ImageFile {
// eslint-disable-next-line class-methods-use-this
initDraggable($el, padding, callback) {
var dragging = false;
- var $body = $('body');
- var $offsetEl = $el.parent();
-
- $el.off('mousedown').on('mousedown', function() {
+ const $body = $('body');
+ const $offsetEl = $el.parent();
+ const dragStart = function() {
dragging = true;
$body.css('user-select', 'none');
- });
+ };
+ const dragStop = function() {
+ dragging = false;
+ $body.css('user-select', '');
+ };
+ const dragMove = function(e) {
+ const moveX = e.pageX || e.touches[0].pageX;
+ const left = moveX - ($offsetEl.offset().left + padding);
+ if (!dragging) return;
+
+ callback(e, left);
+ };
+
+ $el
+ .off('mousedown')
+ .off('touchstart')
+ .on('mousedown', dragStart)
+ .on('touchstart', dragStart);
$body
.off('mouseup')
.off('mousemove')
- .on('mouseup', function() {
- dragging = false;
- $body.css('user-select', '');
- })
- .on('mousemove', function(e) {
- var left;
- if (!dragging) return;
-
- left = e.pageX - ($offsetEl.offset().left + padding);
-
- callback(e, left);
- });
+ .off('touchend')
+ .off('touchmove')
+ .on('mouseup', dragStop)
+ .on('touchend', dragStop)
+ .on('mousemove', dragMove)
+ .on('touchmove', dragMove);
}
prepareFrames(view) {
diff --git a/app/assets/javascripts/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js
index fba30aea9ae..e5e1cbb1e62 100644
--- a/app/assets/javascripts/commons/bootstrap.js
+++ b/app/assets/javascripts/commons/bootstrap.js
@@ -16,3 +16,63 @@ $.fn.extend({
.removeClass('disabled');
},
});
+
+/*
+ Starting with bootstrap 4.3.1, bootstrap sanitizes html used for tooltips / popovers.
+ This extends the default whitelists with more elements / attributes:
+ https://getbootstrap.com/docs/4.3/getting-started/javascript/#sanitizer
+ */
+const whitelist = $.fn.tooltip.Constructor.Default.whiteList;
+
+const inputAttributes = ['value', 'type'];
+
+const dataAttributes = [
+ 'data-toggle',
+ 'data-placement',
+ 'data-container',
+ 'data-title',
+ 'data-class',
+ 'data-clipboard-text',
+ 'data-placement',
+];
+
+// Whitelisting data attributes
+whitelist['*'] = [
+ ...whitelist['*'],
+ ...dataAttributes,
+ 'title',
+ 'width height',
+ 'abbr',
+ 'datetime',
+ 'name',
+ 'width',
+ 'height',
+];
+
+// Whitelist missing elements:
+whitelist.label = ['for'];
+whitelist.button = [...inputAttributes];
+whitelist.input = [...inputAttributes];
+
+whitelist.tt = [];
+whitelist.samp = [];
+whitelist.kbd = [];
+whitelist.var = [];
+whitelist.dfn = [];
+whitelist.cite = [];
+whitelist.big = [];
+whitelist.address = [];
+whitelist.dl = [];
+whitelist.dt = [];
+whitelist.dd = [];
+whitelist.abbr = [];
+whitelist.acronym = [];
+whitelist.blockquote = [];
+whitelist.del = [];
+whitelist.ins = [];
+whitelist['gl-emoji'] = [];
+
+// Whitelisting SVG tags and attributes
+whitelist.svg = ['viewBox'];
+whitelist.use = ['xlink:href'];
+whitelist.path = ['d'];
diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js
index 009153d0703..2f268419bff 100644
--- a/app/assets/javascripts/commons/jquery.js
+++ b/app/assets/javascripts/commons/jquery.js
@@ -3,7 +3,7 @@ import 'jquery';
// common jQuery plugins
import 'jquery-ujs';
import 'vendor/jquery.endless-scroll';
-import 'vendor/jquery.caret';
-import 'vendor/jquery.atwho';
+import 'jquery.caret'; // must be imported before at.js
+import 'at.js';
import 'vendor/jquery.scrollTo';
import 'jquery.waitforimages';
diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js
index bffc025ced3..d0cc4897aeb 100644
--- a/app/assets/javascripts/commons/polyfills.js
+++ b/app/assets/javascripts/commons/polyfills.js
@@ -1,18 +1,21 @@
// ECMAScript polyfills
-import 'core-js/fn/array/fill';
-import 'core-js/fn/array/find';
-import 'core-js/fn/array/find-index';
-import 'core-js/fn/array/from';
-import 'core-js/fn/array/includes';
-import 'core-js/fn/object/assign';
-import 'core-js/fn/object/values';
-import 'core-js/fn/promise';
-import 'core-js/fn/string/code-point-at';
-import 'core-js/fn/string/from-code-point';
-import 'core-js/fn/string/includes';
-import 'core-js/fn/symbol';
-import 'core-js/es6/map';
-import 'core-js/es6/weak-map';
+import 'core-js/es/array/fill';
+import 'core-js/es/array/find';
+import 'core-js/es/array/find-index';
+import 'core-js/es/array/from';
+import 'core-js/es/array/includes';
+import 'core-js/es/object/assign';
+import 'core-js/es/object/values';
+import 'core-js/es/object/entries';
+import 'core-js/es/promise';
+import 'core-js/es/promise/finally';
+import 'core-js/es/string/code-point-at';
+import 'core-js/es/string/from-code-point';
+import 'core-js/es/string/includes';
+import 'core-js/es/symbol';
+import 'core-js/es/map';
+import 'core-js/es/weak-map';
+import 'core-js/modules/web.url';
// Browser polyfills
import 'formdata-polyfill';
diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js
index 37a3ceb5341..5bfe158ceda 100644
--- a/app/assets/javascripts/compare_autocomplete.js
+++ b/app/assets/javascripts/compare_autocomplete.js
@@ -40,7 +40,7 @@ export default function initCompareAutocomplete(limitTo = null, clickHandler = (
},
selectable: true,
filterable: true,
- filterRemote: !!$dropdown.data('refsUrl'),
+ filterRemote: Boolean($dropdown.data('refsUrl')),
fieldName: $dropdown.data('fieldName'),
filterInput: 'input[type="search"]',
renderRow: function(ref) {
diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js
index 50efecb3475..b62ec8a651b 100644
--- a/app/assets/javascripts/contextual_sidebar.js
+++ b/app/assets/javascripts/contextual_sidebar.js
@@ -4,6 +4,12 @@ import _ from 'underscore';
import bp from './breakpoints';
import { parseBoolean } from '~/lib/utils/common_utils';
+// NOTE: at 1200px nav sidebar should not overlap the content
+// https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/24555#note_134136110
+const NAV_SIDEBAR_BREAKPOINT = 1200;
+
+export const SIDEBAR_COLLAPSED_CLASS = 'js-sidebar-collapsed';
+
export default class ContextualSidebar {
constructor() {
this.initDomElements();
@@ -26,44 +32,58 @@ export default class ContextualSidebar {
bindEvents() {
if (!this.$sidebar.length) return;
- document.addEventListener('click', e => {
- if (
- !e.target.closest('.nav-sidebar') &&
- (bp.getBreakpointSize() === 'sm' || bp.getBreakpointSize() === 'md')
- ) {
- this.toggleCollapsedSidebar(true, true);
- }
- });
this.$openSidebar.on('click', () => this.toggleSidebarNav(true));
this.$closeSidebar.on('click', () => this.toggleSidebarNav(false));
this.$overlay.on('click', () => this.toggleSidebarNav(false));
this.$sidebarToggle.on('click', () => {
- const value = !this.$sidebar.hasClass('sidebar-collapsed-desktop');
- this.toggleCollapsedSidebar(value, true);
+ if (!ContextualSidebar.isDesktopBreakpoint()) {
+ this.toggleSidebarNav(!this.$sidebar.hasClass('sidebar-expanded-mobile'));
+ } else {
+ const value = !this.$sidebar.hasClass('sidebar-collapsed-desktop');
+ this.toggleCollapsedSidebar(value, true);
+ }
+ });
+ this.$page.on('transitionstart transitionend', () => {
+ $(document).trigger('content.resize');
});
$(window).on('resize', () => _.debounce(this.render(), 100));
}
+ // TODO: use the breakpoints from breakpoints.js once they have been updated for bootstrap 4
+ // See documentation: https://design.gitlab.com/regions/navigation#contextual-navigation
+ static isDesktopBreakpoint = () => bp.windowWidth() >= NAV_SIDEBAR_BREAKPOINT;
static setCollapsedCookie(value) {
- if (bp.getBreakpointSize() !== 'lg') {
+ if (!ContextualSidebar.isDesktopBreakpoint()) {
return;
}
Cookies.set('sidebar_collapsed', value, { expires: 365 * 10 });
}
toggleSidebarNav(show) {
- this.$sidebar.toggleClass('sidebar-expanded-mobile', show);
- this.$overlay.toggleClass('mobile-nav-open', show);
+ const breakpoint = bp.getBreakpointSize();
+ const dbp = ContextualSidebar.isDesktopBreakpoint();
+
+ this.$sidebar.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show);
+ this.$sidebar.toggleClass('sidebar-expanded-mobile', !dbp ? show : false);
+ this.$overlay.toggleClass(
+ 'mobile-nav-open',
+ breakpoint === 'xs' || breakpoint === 'sm' ? show : false,
+ );
this.$sidebar.removeClass('sidebar-collapsed-desktop');
}
toggleCollapsedSidebar(collapsed, saveCookie) {
const breakpoint = bp.getBreakpointSize();
+ const dbp = ContextualSidebar.isDesktopBreakpoint();
if (this.$sidebar.length) {
this.$sidebar.toggleClass('sidebar-collapsed-desktop', collapsed);
- this.$page.toggleClass('page-with-icon-sidebar', breakpoint === 'sm' ? true : collapsed);
+ this.$sidebar.toggleClass('sidebar-expanded-mobile', !dbp ? !collapsed : false);
+ this.$page.toggleClass(
+ 'page-with-icon-sidebar',
+ breakpoint === 'xs' || breakpoint === 'sm' ? true : collapsed,
+ );
}
if (saveCookie) {
@@ -84,13 +104,11 @@ export default class ContextualSidebar {
render() {
if (!this.$sidebar.length) return;
- const breakpoint = bp.getBreakpointSize();
-
- if (breakpoint === 'sm' || breakpoint === 'md') {
- this.toggleCollapsedSidebar(true, false);
- } else if (breakpoint === 'lg') {
+ if (!ContextualSidebar.isDesktopBreakpoint()) {
+ this.toggleSidebarNav(false);
+ } else {
const collapse = parseBoolean(Cookies.get('sidebar_collapsed'));
- this.toggleCollapsedSidebar(collapse, false);
+ this.toggleCollapsedSidebar(collapse, true);
}
}
}
diff --git a/app/assets/javascripts/create_item_dropdown.js b/app/assets/javascripts/create_item_dropdown.js
index 916b190f469..fa0f04c7d82 100644
--- a/app/assets/javascripts/create_item_dropdown.js
+++ b/app/assets/javascripts/create_item_dropdown.js
@@ -12,7 +12,7 @@ export default class CreateItemDropdown {
this.fieldName = options.fieldName;
this.onSelect = options.onSelect || (() => {});
this.getDataOption = options.getData;
- this.getDataRemote = !!options.filterRemote;
+ this.getDataRemote = Boolean(options.filterRemote);
this.createNewItemFromValueOption = options.createNewItemFromValue;
this.$dropdown = options.$dropdown;
this.$dropdownContainer = this.$dropdown.parent();
diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/create_label.js
index 28ca7d97314..eac0e37bcaa 100644
--- a/app/assets/javascripts/create_label.js
+++ b/app/assets/javascripts/create_label.js
@@ -14,6 +14,7 @@ export default class CreateLabelDropdown {
this.$newLabelField = $('#new_label_name', this.$el);
this.$newColorField = $('#new_label_color', this.$el);
this.$colorPreview = $('.js-dropdown-label-color-preview', this.$el);
+ this.$addList = $('.js-add-list', this.$el);
this.$newLabelError = $('.js-label-error', this.$el);
this.$newLabelCreateButton = $('.js-new-label-btn', this.$el);
this.$colorSuggestions = $('.suggest-colors-dropdown a', this.$el);
@@ -21,6 +22,8 @@ export default class CreateLabelDropdown {
this.$newLabelError.hide();
this.$newLabelCreateButton.disable();
+ this.addListDefault = this.$addList.is(':checked');
+
this.cleanBinding();
this.addBinding();
}
@@ -83,6 +86,8 @@ export default class CreateLabelDropdown {
this.$newColorField.val('').trigger('change');
+ this.$addList.prop('checked', this.addListDefault);
+
this.$colorPreview
.css('background-color', '')
.parent()
@@ -116,9 +121,9 @@ export default class CreateLabelDropdown {
this.$newLabelError.html(errors).show();
} else {
+ const addNewList = this.$addList.is(':checked');
this.$dropdownBack.trigger('click');
-
- $(document).trigger('created.label', label);
+ $(document).trigger('created.label', [label, addNewList]);
}
},
);
diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js
index 02aa507ba03..8f5cece0788 100644
--- a/app/assets/javascripts/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/create_merge_request_dropdown.js
@@ -118,7 +118,7 @@ export default class CreateMergeRequestDropdown {
this.branchCreated = true;
window.location.href = data.url;
})
- .catch(() => Flash('Failed to create a branch for this issue. Please try again.'));
+ .catch(() => Flash(__('Failed to create a branch for this issue. Please try again.')));
}
createMergeRequest() {
@@ -130,7 +130,7 @@ export default class CreateMergeRequestDropdown {
this.mergeRequestCreated = true;
window.location.href = data.url;
})
- .catch(() => Flash('Failed to create Merge Request. Please try again.'));
+ .catch(() => Flash(__('Failed to create Merge Request. Please try again.')));
}
disable() {
@@ -227,7 +227,7 @@ export default class CreateMergeRequestDropdown {
.catch(() => {
this.unavailable();
this.disable();
- new Flash('Failed to get ref.');
+ new Flash(__('Failed to get ref.'));
this.isGettingRef = false;
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
index 4de425b48e7..3f0a9f2602c 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
@@ -12,6 +12,7 @@ import stageStagingComponent from './components/stage_staging_component.vue';
import stageTestComponent from './components/stage_test_component.vue';
import CycleAnalyticsService from './cycle_analytics_service';
import CycleAnalyticsStore from './cycle_analytics_store';
+import { __ } from '~/locale';
Vue.use(Translate);
@@ -61,7 +62,7 @@ export default () => {
methods: {
handleError() {
this.store.setErrorState(true);
- return new Flash('There was an error while fetching cycle analytics data.');
+ return new Flash(__('There was an error while fetching cycle analytics data.'));
},
initDropdown() {
const $dropdown = $('.js-ca-dropdown');
diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
index 4ae4ceabc21..f66e07ba31a 100644
--- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
+++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
@@ -3,6 +3,7 @@
import $ from 'jquery';
import Vue from 'vue';
+import { __ } from '~/locale';
const CommentAndResolveBtn = Vue.extend({
props: {
@@ -31,15 +32,15 @@ const CommentAndResolveBtn = Vue.extend({
buttonText: function() {
if (this.isDiscussionResolved) {
if (this.textareaIsEmpty) {
- return 'Unresolve discussion';
+ return __('Unresolve discussion');
} else {
- return 'Comment & unresolve discussion';
+ return __('Comment & unresolve discussion');
}
} else {
if (this.textareaIsEmpty) {
- return 'Resolve discussion';
+ return __('Resolve discussion');
} else {
- return 'Comment & resolve discussion';
+ return __('Comment & resolve discussion');
}
}
},
diff --git a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
index 5bdeaaade68..b5a781cbc92 100644
--- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
+++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
@@ -5,6 +5,7 @@ import Vue from 'vue';
import collapseIcon from '../icons/collapse_icon.svg';
import Notes from '../../notes';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
+import { n__ } from '~/locale';
const DiffNoteAvatars = Vue.extend({
components: {
@@ -44,7 +45,7 @@ const DiffNoteAvatars = Vue.extend({
if (this.discussion) {
const extra = this.discussion.notesCount() - this.shownAvatars;
- return `${extra} more comment${extra > 1 ? 's' : ''}`;
+ return n__('%d more comment', '%d more comments', extra);
}
return '';
diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
index 8542a6e718a..fe4088cadda 100644
--- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
+++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
@@ -3,6 +3,7 @@
import $ from 'jquery';
import Vue from 'vue';
+import { __ } from '~/locale';
import DiscussionMixins from '../mixins/discussion';
@@ -23,9 +24,9 @@ const JumpToDiscussion = Vue.extend({
computed: {
buttonText: function() {
if (this.discussionId) {
- return 'Jump to next unresolved discussion';
+ return __('Jump to next unresolved discussion');
} else {
- return 'Jump to first unresolved discussion';
+ return __('Jump to first unresolved discussion');
}
},
allResolved: function() {
diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js
index a69b34b0db8..87e7dd18e0c 100644
--- a/app/assets/javascripts/diff_notes/components/resolve_btn.js
+++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js
@@ -4,6 +4,7 @@
import $ from 'jquery';
import Vue from 'vue';
import Flash from '../../flash';
+import { sprintf, __ } from '~/locale';
const ResolveBtn = Vue.extend({
props: {
@@ -55,12 +56,14 @@ const ResolveBtn = Vue.extend({
},
buttonText() {
if (this.isResolved) {
- return `Resolved by ${this.resolvedByName}`;
+ return sprintf(__('Resolved by %{resolvedByName}'), {
+ resolvedByName: this.resolvedByName,
+ });
} else if (this.canResolve) {
- return 'Mark as resolved';
+ return __('Mark as resolved');
}
- return 'Unable to resolve';
+ return __('Unable to resolve');
},
isResolved() {
if (this.note) {
@@ -132,7 +135,8 @@ const ResolveBtn = Vue.extend({
this.updateTooltip();
})
.catch(
- () => new Flash('An error occurred when trying to resolve a comment. Please try again.'),
+ () =>
+ new Flash(__('An error occurred when trying to resolve a comment. Please try again.')),
);
},
},
diff --git a/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js
index 6fcad187b35..4b204fdfeb0 100644
--- a/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js
+++ b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js
@@ -3,6 +3,7 @@
/* global ResolveService */
import Vue from 'vue';
+import { __ } from '~/locale';
const ResolveDiscussionBtn = Vue.extend({
props: {
@@ -41,9 +42,9 @@ const ResolveDiscussionBtn = Vue.extend({
},
buttonText: function() {
if (this.isDiscussionResolved) {
- return 'Unresolve discussion';
+ return __('Unresolve discussion');
} else {
- return 'Resolve discussion';
+ return __('Resolve discussion');
}
},
loading: function() {
diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js
index e69eaad4423..0687028ca54 100644
--- a/app/assets/javascripts/diff_notes/services/resolve.js
+++ b/app/assets/javascripts/diff_notes/services/resolve.js
@@ -3,6 +3,7 @@
import Vue from 'vue';
import Flash from '../../flash';
import '../../vue_shared/vue_resource_interceptor';
+import { __ } from '~/locale';
window.gl = window.gl || {};
@@ -49,7 +50,8 @@ class ResolveServiceClass {
discussion.updateHeadline(data);
})
.catch(
- () => new Flash('An error occurred when trying to resolve a discussion. Please try again.'),
+ () =>
+ new Flash(__('An error occurred when trying to resolve a discussion. Please try again.')),
);
}
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 8f47931d14a..11d6672cacf 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -5,6 +5,7 @@ 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 eventHub from '../../notes/event_hub';
import CompareVersions from './compare_versions.vue';
import DiffFile from './diff_file.vue';
@@ -18,6 +19,8 @@ import {
MIN_TREE_WIDTH,
MAX_TREE_WIDTH,
TREE_HIDE_STATS_WIDTH,
+ MR_TREE_SHOW_KEY,
+ CENTERED_LIMITED_CONTAINER_CLASSES,
} from '../constants';
export default {
@@ -61,6 +64,11 @@ export default {
required: false,
default: '',
},
+ isFluidLayout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
const treeWidth =
@@ -87,7 +95,7 @@ export default {
emailPatchPath: state => state.diffs.emailPatchPath,
}),
...mapState('diffs', ['showTreeList', 'isLoading', 'startVersion']),
- ...mapGetters('diffs', ['isParallelView']),
+ ...mapGetters('diffs', ['isParallelView', 'currentDiffIndex']),
...mapGetters(['isNotesFetched', 'getNoteableData']),
targetBranch() {
return {
@@ -112,6 +120,9 @@ export default {
hideFileStats() {
return this.treeWidth <= TREE_HIDE_STATS_WIDTH;
},
+ isLimitedContainer() {
+ return !this.showTreeList && !this.isParallelView && !this.isFluidLayout;
+ },
},
watch: {
diffViewType() {
@@ -146,9 +157,13 @@ export default {
this.adjustView();
eventHub.$once('fetchedNotesData', this.setDiscussions);
eventHub.$once('fetchDiffData', this.fetchData);
+ eventHub.$on('refetchDiffData', this.refetchDiffData);
+ this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES;
},
beforeDestroy() {
eventHub.$off('fetchDiffData', this.fetchData);
+ eventHub.$off('refetchDiffData', this.refetchDiffData);
+ this.removeEventListeners();
},
methods: {
...mapActions(['startTaskList']),
@@ -159,10 +174,20 @@ export default {
'assignDiscussionsToDiff',
'setHighlightedRow',
'cacheTreeListWidth',
+ 'scrollToFile',
+ 'toggleShowTreeList',
]),
- fetchData() {
+ refetchDiffData() {
+ this.assignedDiscussions = false;
+ this.fetchData(false);
+ },
+ fetchData(toggleTree = true) {
this.fetchDiffFiles()
.then(() => {
+ if (toggleTree) {
+ this.hideTreeListIfJustOneFile();
+ }
+
requestIdleCallback(
() => {
this.setDiscussions();
@@ -195,9 +220,42 @@ export default {
adjustView() {
if (this.shouldShow) {
this.$nextTick(() => {
- window.mrTabs.resetViewContainer();
- window.mrTabs.expandViewContainer(this.showTreeList);
+ this.setEventListeners();
});
+ } else {
+ this.removeEventListeners();
+ }
+ },
+ setEventListeners() {
+ Mousetrap.bind(['[', 'k', ']', 'j'], (e, combo) => {
+ switch (combo) {
+ case '[':
+ case 'k':
+ this.jumpToFile(-1);
+ break;
+ case ']':
+ case 'j':
+ this.jumpToFile(+1);
+ break;
+ default:
+ break;
+ }
+ });
+ },
+ removeEventListeners() {
+ Mousetrap.unbind(['[', 'k', ']', 'j']);
+ },
+ jumpToFile(step) {
+ const targetIndex = this.currentDiffIndex + step;
+ if (targetIndex >= 0 && targetIndex < this.diffFiles.length) {
+ this.scrollToFile(this.diffFiles[targetIndex].file_path);
+ }
+ },
+ hideTreeListIfJustOneFile() {
+ const storedTreeShow = localStorage.getItem(MR_TREE_SHOW_KEY);
+
+ if ((storedTreeShow === null && this.diffFiles.length <= 1) || storedTreeShow === 'false') {
+ this.toggleShowTreeList(false);
}
},
},
@@ -214,6 +272,7 @@ export default {
:merge-request-diffs="mergeRequestDiffs"
:merge-request-diff="mergeRequestDiff"
:target-branch="targetBranch"
+ :is-limited-container="isLimitedContainer"
/>
<hidden-files-warning
@@ -243,7 +302,12 @@ export default {
/>
<tree-list :hide-file-stats="hideFileStats" />
</div>
- <div class="diff-files-holder">
+ <div
+ class="diff-files-holder"
+ :class="{
+ [CENTERED_LIMITED_CONTAINER_CLASSES]: isLimitedContainer,
+ }"
+ >
<commit-widget v-if="commit" :commit="commit" />
<template v-if="renderDiffFiles">
<diff-file
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index c02a8740a42..bd7259ce3ee 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -69,7 +69,7 @@ export default {
:link-href="authorUrl"
:img-src="authorAvatar"
:img-alt="authorName"
- :img-size="36"
+ :img-size="40"
class="avatar-cell d-none d-sm-block"
/>
<div class="commit-detail flex-list">
@@ -113,9 +113,10 @@ export default {
<commit-pipeline-status
v-if="commit.pipeline_status_path"
:endpoint="commit.pipeline_status_path"
+ class="d-inline-flex"
/>
<div class="commit-sha-group">
- <div class="label label-monospace" v-text="commit.short_id"></div>
+ <div class="label label-monospace monospace" v-text="commit.short_id"></div>
<clipboard-button
:text="commit.id"
:title="__('Copy commit SHA to clipboard')"
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index 0bf2dde8b96..363ebad1594 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -7,6 +7,7 @@ import Icon from '~/vue_shared/components/icon.vue';
import CompareVersionsDropdown from './compare_versions_dropdown.vue';
import SettingsDropdown from './settings_dropdown.vue';
import DiffStats from './diff_stats.vue';
+import { CENTERED_LIMITED_CONTAINER_CLASSES } from '../constants';
export default {
components: {
@@ -35,6 +36,11 @@ export default {
required: false,
default: null,
},
+ isLimitedContainer: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
...mapGetters('diffs', ['hasCollapsedFile', 'diffFilesLength']),
@@ -62,6 +68,9 @@ export default {
return this.mergeRequestDiff.base_version_path;
},
},
+ created() {
+ this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES;
+ },
mounted() {
polyfillSticky(this.$el);
},
@@ -77,8 +86,13 @@ export default {
</script>
<template>
- <div class="mr-version-controls" :class="{ 'is-fileTreeOpen': showTreeList }">
- <div class="mr-version-menus-container content-block">
+ <div class="mr-version-controls border-top border-bottom">
+ <div
+ class="mr-version-menus-container content-block"
+ :class="{
+ [CENTERED_LIMITED_CONTAINER_CLASSES]: isLimitedContainer,
+ }"
+ >
<button
v-gl-tooltip.hover
type="button"
@@ -125,9 +139,9 @@ export default {
>
{{ __('Show latest version') }}
</gl-button>
- <a v-show="hasCollapsedFile" class="btn btn-default append-right-8" @click="expandAllFiles">
+ <gl-button v-show="hasCollapsedFile" class="append-right-8" @click="expandAllFiles">
{{ __('Expand all') }}
- </a>
+ </gl-button>
<settings-dropdown />
</div>
</div>
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index cb92093db32..d59b1136677 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -1,10 +1,14 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
+import { GlLoadingIcon } from '@gitlab/ui';
+import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form';
+import draftCommentsMixin from 'ee_else_ce/diffs/mixins/draft_comments';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import NotDiffableViewer from '~/vue_shared/components/diff_viewer/viewers/not_diffable.vue';
import NoPreviewViewer from '~/vue_shared/components/diff_viewer/viewers/no_preview.vue';
import InlineDiffView from './inline_diff_view.vue';
import ParallelDiffView from './parallel_diff_view.vue';
+import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import NoteForm from '../../notes/components/note_form.vue';
import ImageDiffOverlay from './image_diff_overlay.vue';
import DiffDiscussions from './diff_discussions.vue';
@@ -14,6 +18,7 @@ import { diffViewerModes } from '~/ide/constants';
export default {
components: {
+ GlLoadingIcon,
InlineDiffView,
ParallelDiffView,
DiffViewer,
@@ -22,7 +27,10 @@ export default {
ImageDiffOverlay,
NotDiffableViewer,
NoPreviewViewer,
+ userAvatarLink,
+ DiffFileDrafts: () => import('ee_component/batch_comments/components/diff_file_drafts.vue'),
},
+ mixins: [diffLineNoteFormMixin, draftCommentsMixin],
props: {
diffFile: {
type: Object,
@@ -41,7 +49,7 @@ export default {
}),
...mapGetters('diffs', ['isInlineView', 'isParallelView']),
...mapGetters('diffs', ['getCommentFormForDiffFile']),
- ...mapGetters(['getNoteableData', 'noteableType']),
+ ...mapGetters(['getNoteableData', 'noteableType', 'getUserData']),
diffMode() {
return getDiffMode(this.diffFile);
},
@@ -58,10 +66,16 @@ export default {
return this.diffViewerMode === diffViewerModes.not_diffable;
},
diffFileCommentForm() {
- return this.getCommentFormForDiffFile(this.diffFile.file_hash);
+ return this.getCommentFormForDiffFile(this.diffFileHash);
},
showNotesContainer() {
- return this.diffFile.discussions.length || this.diffFileCommentForm;
+ return this.imageDiscussions.length || this.diffFileCommentForm;
+ },
+ diffFileHash() {
+ return this.diffFile.file_hash;
+ },
+ author() {
+ return this.getUserData;
},
},
methods: {
@@ -101,6 +115,7 @@ export default {
:diff-lines="diffFile.parallel_diff_lines || []"
:help-page-path="helpPagePath"
/>
+ <gl-loading-icon v-if="diffFile.renderingLines" size="md" class="mt-3" />
</template>
<not-diffable-viewer v-else-if="notDiffable" />
<no-preview-viewer v-else-if="noPreview" />
@@ -112,18 +127,26 @@ export default {
:new-sha="diffFile.diff_refs.head_sha"
:old-path="diffFile.old_path"
:old-sha="diffFile.diff_refs.base_sha"
- :file-hash="diffFile.file_hash"
+ :file-hash="diffFileHash"
:project-path="projectPath"
:a-mode="diffFile.a_mode"
:b-mode="diffFile.b_mode"
>
<image-diff-overlay
slot="image-overlay"
- :discussions="diffFile.discussions"
- :file-hash="diffFile.file_hash"
+ :discussions="imageDiscussions"
+ :file-hash="diffFileHash"
:can-comment="getNoteableData.current_user.can_create_note"
/>
<div v-if="showNotesContainer" class="note-container">
+ <user-avatar-link
+ v-if="diffFileCommentForm && author"
+ :link-href="author.path"
+ :img-src="author.avatar_url"
+ :img-alt="author.name"
+ :img-size="40"
+ class="d-none d-sm-block new-comment"
+ />
<diff-discussions
v-if="diffFile.discussions.length"
class="diff-file-discussions"
@@ -131,14 +154,16 @@ export default {
:should-collapse-discussions="true"
:render-avatar-badge="true"
/>
+ <diff-file-drafts :file-hash="diffFileHash" class="diff-file-discussions" />
<note-form
v-if="diffFileCommentForm"
ref="noteForm"
:is-editing="false"
:save-button-title="__('Comment')"
class="diff-comment-form new-note discussion-form discussion-form-container"
+ @handleFormUpdateAddToReview="addToReview"
@handleFormUpdate="handleSaveNote"
- @cancelForm="closeDiffFileCommentForm(diffFile.file_hash)"
+ @cancelForm="closeDiffFileCommentForm(diffFileHash)"
/>
</div>
</diff-viewer>
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 1141a197c6a..f5876a73eff 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -73,13 +73,23 @@ export default {
if (!newVal && oldVal && !this.hasDiffLines) {
this.handleLoadCollapsedDiff();
}
+
+ this.setFileCollapsed({ filePath: this.file.file_path, collapsed: newVal });
+ },
+ 'file.viewer.collapsed': function setIsCollapsed(newVal) {
+ this.isCollapsed = newVal;
},
},
created() {
eventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.handleLoadCollapsedDiff);
},
methods: {
- ...mapActions('diffs', ['loadCollapsedDiff', 'assignDiscussionsToDiff', 'setRenderIt']),
+ ...mapActions('diffs', [
+ 'loadCollapsedDiff',
+ 'assignDiscussionsToDiff',
+ 'setRenderIt',
+ 'setFileCollapsed',
+ ]),
handleToggle() {
if (!this.hasDiffLines) {
this.handleLoadCollapsedDiff();
@@ -160,26 +170,24 @@ export default {
</div>
<gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" />
<template v-else>
- <div v-if="errorMessage" class="diff-viewer">
- <div class="nothing-here-block" v-html="errorMessage"></div>
+ <div :id="`diff-content-${file.file_hash}`">
+ <div v-if="errorMessage" class="diff-viewer">
+ <div class="nothing-here-block" v-html="errorMessage"></div>
+ </div>
+ <div v-else-if="isCollapsed" class="nothing-here-block diff-collapsed">
+ {{ __('This diff is collapsed.') }}
+ <a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle">{{
+ __('Click to expand it.')
+ }}</a>
+ </div>
+ <diff-content
+ v-else
+ :class="{ hidden: isCollapsed || isFileTooLarge }"
+ :diff-file="file"
+ :help-page-path="helpPagePath"
+ />
</div>
- <div v-else-if="isCollapsed" class="nothing-here-block diff-collapsed">
- {{ __('This diff is collapsed.') }}
- <a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle">{{
- __('Click to expand it.')
- }}</a>
- </div>
- <diff-content
- v-else
- :class="{ hidden: isCollapsed || isFileTooLarge }"
- :diff-file="file"
- :help-page-path="helpPagePath"
- />
</template>
- <div v-if="isFileTooLarge" class="nothing-here-block diff-collapsed js-too-large-diff">
- {{ __('This source diff could not be displayed because it is too large.') }}
- <span v-html="viewBlobLink"></span>
- </div>
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 2b801898345..eb9f1465945 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -1,19 +1,23 @@
<script>
import _ from 'underscore';
import { mapActions, mapGetters } from 'vuex';
-import { polyfillSticky } from '~/lib/utils/sticky';
+import { polyfillSticky, stickyMonitor } 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 { GlTooltipDirective } from '@gitlab/ui';
+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';
export default {
components: {
+ GlTooltip,
+ GlLoadingIcon,
+ GlButton,
ClipboardButton,
EditButton,
Icon,
@@ -63,6 +67,9 @@ export default {
hasExpandedDiscussions() {
return this.diffHasExpandedDiscussions(this.diffFile);
},
+ diffContentIDSelector() {
+ return `#diff-content-${this.diffFile.file_hash}`;
+ },
icon() {
if (this.diffFile.submodule) {
return 'archive';
@@ -74,6 +81,11 @@ export default {
if (this.diffFile.submodule) {
return this.diffFile.submodule_tree_url || this.diffFile.submodule_link;
}
+
+ if (!this.discussionPath) {
+ return this.diffContentIDSelector;
+ }
+
return this.discussionPath;
},
filePath() {
@@ -100,9 +112,7 @@ export default {
const truncatedContentSha = _.escape(truncateSha(this.diffFile.content_sha));
return sprintf(
s__('MergeRequests|View file @ %{commitId}'),
- {
- commitId: `<span class="commit-sha">${truncatedContentSha}</span>`,
- },
+ { commitId: truncatedContentSha },
false,
);
},
@@ -125,12 +135,23 @@ export default {
isModeChanged() {
return this.diffFile.viewer.name === diffViewerModes.mode_changed;
},
+ showExpandDiffToFullFileEnabled() {
+ return gon.features.expandDiffFullFile && !this.diffFile.is_fully_expanded;
+ },
+ expandDiffToFullFileTitle() {
+ if (this.diffFile.isShowingFullFile) {
+ return s__('MRDiff|Show changes only');
+ }
+ return s__('MRDiff|Show full file');
+ },
},
mounted() {
polyfillSticky(this.$refs.header);
+ const fileHeaderHeight = this.$refs.header.clientHeight;
+ stickyMonitor(this.$refs.header, contentTop() - fileHeaderHeight - 1, false);
},
methods: {
- ...mapActions('diffs', ['toggleFileDiscussions']),
+ ...mapActions('diffs', ['toggleFileDiscussions', 'toggleFullDiff']),
handleToggleFile(e, checkTarget) {
if (
!checkTarget ||
@@ -146,6 +167,18 @@ export default {
handleToggleDiscussions() {
this.toggleFileDiscussions(this.diffFile);
},
+ handleFileNameClick(e) {
+ const isLinkToOtherPage =
+ this.diffFile.submodule_tree_url || this.diffFile.submodule_link || this.discussionPath;
+
+ if (!isLinkToOtherPage) {
+ e.preventDefault();
+ const selector = this.diffContentIDSelector;
+
+ scrollToElement(document.querySelector(selector));
+ window.location.hash = selector;
+ }
+ },
},
};
</script>
@@ -165,7 +198,14 @@ export default {
class="diff-toggle-caret append-right-5"
@click.stop="handleToggle"
/>
- <a v-once ref="titleWrapper" :href="titleLink" class="append-right-4 js-title-wrapper">
+ <a
+ v-once
+ id="diffFile.file_path"
+ ref="titleWrapper"
+ class="append-right-4 js-title-wrapper"
+ :href="titleLink"
+ @click="handleFileNameClick"
+ >
<file-icon
:file-name="filePath"
:size="18"
@@ -200,7 +240,7 @@ export default {
css-class="btn-default btn-transparent btn-clipboard"
/>
- <small v-if="isModeChanged" ref="fileMode">
+ <small v-if="isModeChanged" ref="fileMode" class="mr-1">
{{ diffFile.a_mode }} → {{ diffFile.b_mode }}
</small>
@@ -212,48 +252,71 @@ export default {
class="file-actions d-none d-sm-block"
>
<diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" />
- <template v-if="diffFile.blob && diffFile.blob.readable_text">
- <button
- :disabled="!diffHasDiscussions(diffFile)"
- :class="{ active: hasExpandedDiscussions }"
- :title="s__('MergeRequests|Toggle comments for this file')"
- class="js-btn-vue-toggle-comments btn"
- type="button"
- @click="handleToggleDiscussions"
- >
- <icon name="comment" />
- </button>
+ <div class="btn-group" role="group">
+ <template v-if="diffFile.blob && diffFile.blob.readable_text">
+ <span v-gl-tooltip.hover :title="s__('MergeRequests|Toggle comments for this file')">
+ <gl-button
+ :disabled="!diffHasDiscussions(diffFile)"
+ :class="{ active: hasExpandedDiscussions }"
+ class="js-btn-vue-toggle-comments btn"
+ type="button"
+ @click="handleToggleDiscussions"
+ >
+ <icon name="comment" />
+ </gl-button>
+ </span>
- <edit-button
- v-if="!diffFile.deleted_file"
- :can-current-user-fork="canCurrentUserFork"
- :edit-path="diffFile.edit_path"
- :can-modify-blob="diffFile.can_modify_blob"
- @showForkMessage="showForkMessage"
- />
- </template>
+ <edit-button
+ v-if="!diffFile.deleted_file"
+ :can-current-user-fork="canCurrentUserFork"
+ :edit-path="diffFile.edit_path"
+ :can-modify-blob="diffFile.can_modify_blob"
+ @showForkMessage="showForkMessage"
+ />
+ </template>
- <a
- v-if="diffFile.replaced_view_path"
- :href="diffFile.replaced_view_path"
- class="btn view-file js-view-file"
- v-html="viewReplacedFileButtonText"
- >
- </a>
- <a :href="diffFile.view_path" class="btn view-file js-view-file" v-html="viewFileButtonText">
- </a>
+ <a
+ v-if="diffFile.replaced_view_path"
+ :href="diffFile.replaced_view_path"
+ class="btn view-file js-view-replaced-file"
+ v-html="viewReplacedFileButtonText"
+ >
+ </a>
+ <gl-button
+ v-if="!diffFile.is_fully_expanded"
+ ref="expandDiffToFullFileButton"
+ v-gl-tooltip.hover
+ :title="expandDiffToFullFileTitle"
+ class="expand-file js-expand-file"
+ @click="toggleFullDiff(diffFile.file_path)"
+ >
+ <gl-loading-icon v-if="diffFile.isLoadingFullFile" color="dark" inline />
+ <icon v-else-if="diffFile.isShowingFullFile" name="doc-changes" />
+ <icon v-else name="doc-expand" />
+ </gl-button>
+ <gl-button
+ ref="viewButton"
+ v-gl-tooltip.hover
+ :href="diffFile.view_path"
+ target="blank"
+ class="view-file js-view-file-button"
+ :title="viewFileButtonText"
+ >
+ <icon name="doc-text" />
+ </gl-button>
- <a
- v-if="diffFile.external_url"
- v-gl-tooltip.hover
- :href="diffFile.external_url"
- :title="`View on ${diffFile.formatted_external_url}`"
- target="_blank"
- rel="noopener noreferrer"
- class="btn btn-file-option"
- >
- <icon name="external-link" />
- </a>
+ <a
+ v-if="diffFile.external_url"
+ v-gl-tooltip.hover
+ :href="diffFile.external_url"
+ :title="`View on ${diffFile.formatted_external_url}`"
+ target="_blank"
+ rel="noopener noreferrer"
+ class="btn btn-file-option js-external-url"
+ >
+ <icon name="external-link" />
+ </a>
+ </div>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
index 0c0a0faa59d..7cf3d90d468 100644
--- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
+++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
@@ -86,7 +86,6 @@ export default {
:key="note.id"
:img-src="note.author.avatar_url"
:tooltip-text="getTooltipText(note)"
- :size="19"
class="diff-comment-avatar js-diff-comment-avatar"
@click.native="toggleDiscussions"
/>
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 6709df48637..1281f9b17ef 100644
--- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
@@ -84,8 +84,6 @@ export default {
},
shouldShowCommentButton() {
return (
- this.isLoggedIn &&
- this.showCommentButton &&
this.isHover &&
!this.isMatchLine &&
!this.isContextLine &&
@@ -102,6 +100,9 @@ export default {
}
return this.showCommentButton && this.hasDiscussions;
},
+ shouldRenderCommentButton() {
+ return this.isLoggedIn && this.showCommentButton;
+ },
},
methods: {
...mapActions('diffs', ['loadMoreLines', 'showCommentForm', 'setHighlightedRow']),
@@ -167,6 +168,7 @@ export default {
>
<template v-else>
<button
+ v-if="shouldRenderCommentButton"
v-show="shouldShowCommentButton"
type="button"
class="add-diff-note js-add-diff-note-button qa-diff-comment"
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 18edbe286ba..c209b857652 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -1,15 +1,18 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import { s__ } from '~/locale';
+import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form';
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';
import { DIFF_NOTE_TYPE } from '../constants';
export default {
components: {
noteForm,
+ userAvatarLink,
},
- mixins: [autosave],
+ mixins: [autosave, diffLineNoteFormMixin],
props: {
diffFileHash: {
type: String,
@@ -40,17 +43,29 @@ export default {
diffViewType: state => state.diffs.diffViewType,
}),
...mapGetters('diffs', ['getDiffFileByHash']),
- ...mapGetters(['isLoggedIn', 'noteableType', 'getNoteableData', 'getNotesDataByProp']),
+ ...mapGetters([
+ 'isLoggedIn',
+ 'noteableType',
+ 'getNoteableData',
+ 'getNotesDataByProp',
+ 'getUserData',
+ ]),
+ author() {
+ return this.getUserData;
+ },
formData() {
return {
noteableData: this.noteableData,
noteableType: this.noteableType,
noteTargetLine: this.noteTargetLine,
diffViewType: this.diffViewType,
- diffFile: this.getDiffFileByHash(this.diffFileHash),
+ diffFile: this.diffFile,
linePosition: this.linePosition,
};
},
+ diffFile() {
+ return this.getDiffFileByHash(this.diffFileHash);
+ },
},
mounted() {
if (this.isLoggedIn) {
@@ -95,14 +110,24 @@ export default {
<template>
<div class="content discussion-form discussion-form-container discussion-notes">
+ <user-avatar-link
+ v-if="author"
+ :link-href="author.path"
+ :img-src="author.avatar_url"
+ :img-alt="author.name"
+ :img-size="40"
+ class="d-none d-sm-block"
+ />
<note-form
ref="noteForm"
:is-editing="true"
:line-code="line.line_code"
:line="line"
:help-page-path="helpPagePath"
+ :diff-file="diffFile"
save-button-title="Comment"
class="diff-comment-form"
+ @handleFormUpdateAddToReview="addToReview"
@cancelForm="handleCancelCommentForm"
@handleFormUpdate="handleSaveNote"
/>
diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue
index d174b13e133..0f3e9208d21 100644
--- a/app/assets/javascripts/diffs/components/diff_table_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue
@@ -89,17 +89,19 @@ export default {
classNameMap() {
const { type } = this.line;
- return {
- hll: this.isHighlighted,
- [type]: type,
- [LINE_UNFOLD_CLASS_NAME]: this.isMatchLine,
- [LINE_HOVER_CLASS_NAME]:
- this.isLoggedIn &&
- this.isHover &&
- !this.isMatchLine &&
- !this.isContextLine &&
- !this.isMetaLine,
- };
+ return [
+ type,
+ {
+ hll: this.isHighlighted,
+ [LINE_UNFOLD_CLASS_NAME]: this.isMatchLine,
+ [LINE_HOVER_CLASS_NAME]:
+ this.isLoggedIn &&
+ this.isHover &&
+ !this.isMatchLine &&
+ !this.isContextLine &&
+ !this.isMetaLine,
+ },
+ ];
},
lineNumber() {
return this.lineType === OLD_LINE_TYPE ? this.line.old_line : this.line.new_line;
diff --git a/app/assets/javascripts/diffs/components/edit_button.vue b/app/assets/javascripts/diffs/components/edit_button.vue
index 5d38d545ce8..dcb79cd5e16 100644
--- a/app/assets/javascripts/diffs/components/edit_button.vue
+++ b/app/assets/javascripts/diffs/components/edit_button.vue
@@ -1,5 +1,15 @@
<script>
+import { GlTooltipDirective, GlButton } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+
export default {
+ components: {
+ GlButton,
+ Icon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
editPath: {
type: String,
@@ -17,12 +27,7 @@ export default {
},
methods: {
handleEditClick(evt) {
- if (!this.canCurrentUserFork || this.canModifyBlob) {
- // if we can Edit, do default Edit button behavior
- return;
- }
-
- if (this.canCurrentUserFork) {
+ if (this.canCurrentUserFork && !this.canModifyBlob) {
evt.preventDefault();
this.$emit('showForkMessage');
}
@@ -32,5 +37,13 @@ export default {
</script>
<template>
- <a :href="editPath" class="btn btn-default js-edit-blob" @click="handleEditClick"> Edit </a>
+ <gl-button
+ v-gl-tooltip.top
+ :href="editPath"
+ :title="__('Edit file')"
+ class="js-edit-blob"
+ @click.native="handleEditClick"
+ >
+ <icon name="pencil" />
+ </gl-button>
</template>
diff --git a/app/assets/javascripts/diffs/components/image_diff_overlay.vue b/app/assets/javascripts/diffs/components/image_diff_overlay.vue
index 4a83c5a72a5..703a281308e 100644
--- a/app/assets/javascripts/diffs/components/image_diff_overlay.vue
+++ b/app/assets/javascripts/diffs/components/image_diff_overlay.vue
@@ -1,6 +1,7 @@
<script>
import { mapActions, mapGetters } from 'vuex';
import _ from 'underscore';
+import imageDiffMixin from 'ee_else_ce/diffs/mixins/image_diff';
import Icon from '~/vue_shared/components/icon.vue';
export default {
@@ -8,6 +9,7 @@ export default {
components: {
Icon,
},
+ mixins: [imageDiffMixin],
props: {
discussions: {
type: [Array, Object],
@@ -48,7 +50,6 @@ export default {
},
},
methods: {
- ...mapActions(['toggleDiscussion']),
...mapActions('diffs', ['openDiffFileCommentForm']),
getImageDimensions() {
return {
@@ -105,15 +106,15 @@ export default {
v-for="(discussion, index) in allDiscussions"
:key="discussion.id"
:style="getPosition(discussion)"
- :class="badgeClass"
+ :class="[badgeClass, { 'is-draft': discussion.isDraft }]"
:disabled="!shouldToggleDiscussion"
class="js-image-badge"
type="button"
- @click="toggleDiscussion({ discussionId: discussion.id })"
+ @click="clickedToggle(discussion)"
>
<icon v-if="showCommentIcon" name="image-comment-dark" />
<template v-else>
- {{ index + 1 }}
+ {{ toggleText(discussion, index) }}
</template>
</button>
<button
diff --git a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
index 69146f1f6fd..1faa0493e79 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
@@ -41,7 +41,7 @@ export default {
<template>
<tr v-if="shouldRender" :class="className" class="notes_holder">
- <td class="notes_content" colspan="3">
+ <td class="notes-content" colspan="3">
<div class="content">
<diff-discussions
v-if="line.discussions.length"
diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
index c764cbeb8e0..2d5262baeec 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
@@ -1,12 +1,11 @@
<script>
-import { mapGetters, mapActions, mapState } from 'vuex';
+import { mapActions, mapState } from 'vuex';
import DiffTableCell from './diff_table_cell.vue';
import {
NEW_LINE_TYPE,
OLD_LINE_TYPE,
CONTEXT_LINE_TYPE,
CONTEXT_LINE_CLASS_NAME,
- PARALLEL_DIFF_VIEW_TYPE,
LINE_POSITION_LEFT,
LINE_POSITION_RIGHT,
} from '../constants';
@@ -45,16 +44,16 @@ export default {
return this.line.line_code !== null && this.line.line_code === state.diffs.highlightedRow;
},
}),
- ...mapGetters('diffs', ['isInlineView']),
isContextLine() {
return this.line.type === CONTEXT_LINE_TYPE;
},
classNameMap() {
- return {
- [this.line.type]: this.line.type,
- [CONTEXT_LINE_CLASS_NAME]: this.isContextLine,
- [PARALLEL_DIFF_VIEW_TYPE]: this.isParallelView,
- };
+ return [
+ this.line.type,
+ {
+ [CONTEXT_LINE_CLASS_NAME]: this.isContextLine,
+ },
+ ];
},
inlineRowId() {
return this.line.line_code || `${this.fileHash}_${this.line.old_line}_${this.line.new_line}`;
diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue
index e781397214d..8c76a555b62 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue
@@ -1,5 +1,6 @@
<script>
import { mapGetters } from 'vuex';
+import draftCommentsMixin from 'ee_else_ce/diffs/mixins/draft_comments';
import inlineDiffTableRow from './inline_diff_table_row.vue';
import inlineDiffCommentRow from './inline_diff_comment_row.vue';
@@ -7,7 +8,10 @@ export default {
components: {
inlineDiffCommentRow,
inlineDiffTableRow,
+ InlineDraftCommentRow: () =>
+ import('ee_component/batch_comments/components/inline_draft_comment_row.vue'),
},
+ mixins: [draftCommentsMixin],
props: {
diffFile: {
type: Object,
@@ -54,6 +58,11 @@ export default {
:line="line"
:help-page-path="helpPagePath"
/>
+ <inline-draft-comment-row
+ v-if="shouldRenderDraftRow(diffFile.file_hash, line)"
+ :key="`draft_${index}`"
+ :draft="draftForLine(diffFile.file_hash, line)"
+ />
</template>
</tbody>
</table>
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
index 370cb6e339a..d2e54edca85 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
@@ -87,7 +87,7 @@ export default {
<template>
<tr v-if="shouldRender" :class="className" class="notes_holder">
- <td class="notes_content parallel old" colspan="2">
+ <td class="notes-content parallel old" colspan="2">
<div v-if="shouldRenderDiscussionsOnLeft" class="content">
<diff-discussions
v-if="line.left.discussions.length"
@@ -105,7 +105,7 @@ export default {
line-position="left"
/>
</td>
- <td class="notes_content parallel new" colspan="2">
+ <td class="notes-content parallel new" colspan="2">
<div v-if="shouldRenderDiscussionsOnRight" class="content">
<diff-discussions
v-if="line.right.discussions.length"
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
index caf0df8a4e3..c60246bf8ef 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
@@ -140,7 +140,7 @@ export default {
:id="line.left.line_code"
:class="parallelViewLeftLineType"
class="line_content parallel left-side"
- @mousedown.native="handleParallelLineMouseDown"
+ @mousedown="handleParallelLineMouseDown"
v-html="line.left.rich_text"
></td>
</template>
@@ -171,7 +171,7 @@ export default {
},
]"
class="line_content parallel right-side"
- @mousedown.native="handleParallelLineMouseDown"
+ @mousedown="handleParallelLineMouseDown"
v-html="line.right.rich_text"
></td>
</template>
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
index 1bf693380db..41a80d99850 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
@@ -1,5 +1,6 @@
<script>
import { mapGetters } from 'vuex';
+import draftCommentsMixin from 'ee_else_ce/diffs/mixins/draft_comments';
import parallelDiffTableRow from './parallel_diff_table_row.vue';
import parallelDiffCommentRow from './parallel_diff_comment_row.vue';
@@ -7,7 +8,10 @@ export default {
components: {
parallelDiffTableRow,
parallelDiffCommentRow,
+ ParallelDraftCommentRow: () =>
+ import('ee_component/batch_comments/components/parallel_draft_comment_row.vue'),
},
+ mixins: [draftCommentsMixin],
props: {
diffFile: {
type: Object,
@@ -34,30 +38,34 @@ export default {
</script>
<template>
- <div
+ <table
:class="$options.userColorScheme"
:data-commit-id="commitId"
class="code diff-wrap-lines js-syntax-highlight text-file"
>
- <table>
- <tbody>
- <template v-for="(line, index) in diffLines">
- <parallel-diff-table-row
- :key="line.line_code"
- :file-hash="diffFile.file_hash"
- :context-lines-path="diffFile.context_lines_path"
- :line="line"
- :is-bottom="index + 1 === diffLinesLength"
- />
- <parallel-diff-comment-row
- :key="`dcr-${line.line_code || index}`"
- :line="line"
- :diff-file-hash="diffFile.file_hash"
- :line-index="index"
- :help-page-path="helpPagePath"
- />
- </template>
- </tbody>
- </table>
- </div>
+ <tbody>
+ <template v-for="(line, index) in diffLines">
+ <parallel-diff-table-row
+ :key="line.line_code"
+ :file-hash="diffFile.file_hash"
+ :context-lines-path="diffFile.context_lines_path"
+ :line="line"
+ :is-bottom="index + 1 === diffLinesLength"
+ />
+ <parallel-diff-comment-row
+ :key="`dcr-${line.line_code || index}`"
+ :line="line"
+ :diff-file-hash="diffFile.file_hash"
+ :line-index="index"
+ :help-page-path="helpPagePath"
+ />
+ <parallel-draft-comment-row
+ v-if="shouldRenderParallelDraftRow(diffFile.file_hash, line)"
+ :key="`drafts-${index}`"
+ :line="line"
+ :diff-file-content-sha="diffFile.file_hash"
+ />
+ </template>
+ </tbody>
+ </table>
</template>
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index 8fc3af15bea..30be2e68e76 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -1,6 +1,7 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlTooltipDirective } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import FileRow from '~/vue_shared/components/file_row.vue';
import FileRowStats from './file_row_stats.vue';
@@ -30,8 +31,9 @@ export default {
filteredTreeList() {
const search = this.search.toLowerCase().trim();
- if (search === '' || this.$options.fuzzyFileFinderEnabled)
+ if (search === '') {
return this.renderTreeList ? this.tree : this.allBlobs;
+ }
return this.allBlobs.reduce((acc, folder) => {
const tree = folder.tree.filter(f => f.path.toLowerCase().indexOf(search) >= 0);
@@ -51,13 +53,14 @@ export default {
},
},
methods: {
- ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile', 'toggleFileFinder']),
+ ...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']),
clearSearch() {
this.search = '';
},
},
- shortcutKeyCharacter: `${/Mac/i.test(navigator.userAgent) ? '&#8984;' : 'Ctrl'}+P`,
- diffTreeFiltering: gon.features && gon.features.diffTreeFiltering,
+ searchPlaceholder: sprintf(s__('MergeRequest|Filter files or search with %{modifier_key}+p'), {
+ modifier_key: /Mac/i.test(navigator.userAgent) ? 'cmd' : 'ctrl',
+ }),
};
</script>
@@ -66,36 +69,24 @@ export default {
<div class="append-bottom-8 position-relative tree-list-search d-flex">
<div class="flex-fill d-flex">
<icon name="search" class="position-absolute tree-list-icon" />
- <template v-if="$options.diffTreeFiltering">
- <input
- v-model="search"
- :placeholder="s__('MergeRequest|Filter files')"
- type="search"
- class="form-control"
- />
- <button
- v-show="search"
- :aria-label="__('Clear search')"
- type="button"
- class="position-absolute bg-transparent tree-list-icon tree-list-clear-icon border-0 p-0"
- @click="clearSearch"
- >
- <icon name="close" />
- </button>
- </template>
- <template v-else>
- <button
- type="button"
- class="form-control text-left text-secondary"
- @click="toggleFileFinder(true)"
- >
- {{ s__('MergeRequest|Search files') }}
- </button>
- <span
- class="position-absolute text-secondary diff-tree-search-shortcut"
- v-html="$options.shortcutKeyCharacter"
- ></span>
- </template>
+ <label for="diff-tree-search" class="sr-only">{{ $options.searchPlaceholder }}</label>
+ <input
+ id="diff-tree-search"
+ v-model="search"
+ :placeholder="$options.searchPlaceholder"
+ type="search"
+ name="diff-tree-search"
+ class="form-control"
+ />
+ <button
+ v-show="search"
+ :aria-label="__('Clear search')"
+ type="button"
+ class="position-absolute bg-transparent tree-list-icon tree-list-clear-icon border-0 p-0"
+ @click="clearSearch"
+ >
+ <icon name="close" />
+ </button>
</div>
</div>
<div :class="{ 'pt-0 tree-list-blobs': !renderTreeList }" class="tree-list-scroll">
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index 7002655ea49..d84e1af11f3 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -42,3 +42,18 @@ export const INITIAL_TREE_WIDTH = 320;
export const MIN_TREE_WIDTH = 240;
export const MAX_TREE_WIDTH = 400;
export const TREE_HIDE_STATS_WIDTH = 260;
+
+export const OLD_LINE_KEY = 'old_line';
+export const NEW_LINE_KEY = 'new_line';
+export const TYPE_KEY = 'type';
+export const LEFT_LINE_KEY = 'left';
+
+export const CENTERED_LIMITED_CONTAINER_CLASSES =
+ 'container-limited limit-container-width mx-lg-auto px-3';
+
+export const MAX_RENDERING_DIFF_LINES = 500;
+export const MAX_RENDERING_BULK_ROWS = 30;
+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';
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 63954d9d412..1d897bca1dd 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -71,6 +71,7 @@ export default function initDiffsApp(store) {
helpPagePath: dataset.helpPagePath,
currentUser: JSON.parse(dataset.currentUserData) || {},
changesEmptyStateIllustration: dataset.changesEmptyStateIllustration,
+ isFluidLayout: parseBoolean(dataset.isFluidLayout),
};
},
computed: {
@@ -97,6 +98,7 @@ export default function initDiffsApp(store) {
helpPagePath: this.helpPagePath,
shouldShow: this.activeTab === 'diffs',
changesEmptyStateIllustration: this.changesEmptyStateIllustration,
+ isFluidLayout: this.isFluidLayout,
},
});
},
diff --git a/app/assets/javascripts/diffs/mixins/draft_comments.js b/app/assets/javascripts/diffs/mixins/draft_comments.js
new file mode 100644
index 00000000000..dfb71bf38ce
--- /dev/null
+++ b/app/assets/javascripts/diffs/mixins/draft_comments.js
@@ -0,0 +1,10 @@
+export default {
+ computed: {
+ shouldRenderDraftRow: () => () => false,
+ shouldRenderParallelDraftRow: () => () => false,
+ draftForLine: () => () => ({}),
+ imageDiscussions() {
+ return this.diffFile.discussions;
+ },
+ },
+};
diff --git a/app/assets/javascripts/diffs/mixins/image_diff.js b/app/assets/javascripts/diffs/mixins/image_diff.js
new file mode 100644
index 00000000000..9067ea6f8b3
--- /dev/null
+++ b/app/assets/javascripts/diffs/mixins/image_diff.js
@@ -0,0 +1,13 @@
+import { mapActions } from 'vuex';
+
+export default {
+ methods: {
+ ...mapActions(['toggleDiscussion']),
+ clickedToggle(discussion) {
+ this.toggleDiscussion({ discussionId: discussion.id });
+ },
+ toggleText(discussion, index) {
+ return index + 1;
+ },
+ },
+};
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 82ff2e3be76..479afc50113 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -7,7 +7,12 @@ import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/uti
import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility';
import TreeWorker from '../workers/tree_worker';
import eventHub from '../../notes/event_hub';
-import { getDiffPositionByLineCode, getNoteFormData } from './utils';
+import {
+ getDiffPositionByLineCode,
+ getNoteFormData,
+ convertExpandLines,
+ idleCallback,
+} from './utils';
import * as types from './mutation_types';
import {
PARALLEL_DIFF_VIEW_TYPE,
@@ -17,6 +22,16 @@ import {
TREE_LIST_STORAGE_KEY,
WHITESPACE_STORAGE_KEY,
TREE_LIST_WIDTH_STORAGE_KEY,
+ OLD_LINE_KEY,
+ NEW_LINE_KEY,
+ TYPE_KEY,
+ LEFT_LINE_KEY,
+ MAX_RENDERING_DIFF_LINES,
+ MAX_RENDERING_BULK_ROWS,
+ MIN_RENDERING_MS,
+ START_RENDERING_INDEX,
+ INLINE_DIFF_LINES_KEY,
+ PARALLEL_DIFF_LINES_KEY,
} from '../constants';
import { diffViewerModes } from '~/ide/constants';
@@ -37,7 +52,7 @@ export const fetchDiffFiles = ({ state, commit }) => {
});
return axios
- .get(state.endpoint, { params: { w: state.showWhitespace ? null : '1' } })
+ .get(mergeUrlParams({ w: state.showWhitespace ? '0' : '1' }, state.endpoint))
.then(res => {
commit(types.SET_LOADING, false);
commit(types.SET_MERGE_REQUEST_DIFFS, res.data.merge_request_diffs || []);
@@ -52,7 +67,9 @@ export const fetchDiffFiles = ({ state, commit }) => {
};
export const setHighlightedRow = ({ commit }, lineCode) => {
+ const fileHash = lineCode.split('_')[0];
commit(types.SET_HIGHLIGHTED_ROW, lineCode);
+ commit(types.UPDATE_CURRENT_DIFF_FILE_ID, fileHash);
};
// This is adding line discussions to the actual lines in the diff tree
@@ -108,7 +125,8 @@ export const startRenderDiffsQueue = ({ state, commit }) => {
new Promise(resolve => {
const nextFile = state.diffFiles.find(
file =>
- !file.renderIt && (!file.viewer.collapsed || !file.viewer.name === diffViewerModes.text),
+ !file.renderIt &&
+ (file.viewer && (!file.viewer.collapsed || !file.viewer.name === diffViewerModes.text)),
);
if (nextFile) {
@@ -193,11 +211,12 @@ export const scrollToLineIfNeededParallel = (_, line) => {
}
};
-export const loadCollapsedDiff = ({ commit, getters }, file) =>
+export const loadCollapsedDiff = ({ commit, getters, state }, file) =>
axios
.get(file.load_collapsed_diff_url, {
params: {
commit_id: getters.commitId,
+ w: state.showWhitespace ? '0' : '1',
},
})
.then(res => {
@@ -262,13 +281,14 @@ export const scrollToFile = ({ state, commit }, path) => {
document.location.hash = fileHash;
commit(types.UPDATE_CURRENT_DIFF_FILE_ID, fileHash);
-
- setTimeout(() => commit(types.UPDATE_CURRENT_DIFF_FILE_ID, ''), 1000);
};
-export const toggleShowTreeList = ({ commit, state }) => {
+export const toggleShowTreeList = ({ commit, state }, saving = true) => {
commit(types.TOGGLE_SHOW_TREE_LIST);
- localStorage.setItem(MR_TREE_SHOW_KEY, state.showTreeList);
+
+ if (saving) {
+ localStorage.setItem(MR_TREE_SHOW_KEY, state.showTreeList);
+ }
};
export const openDiffFileCommentForm = ({ commit, getters }, formData) => {
@@ -297,8 +317,10 @@ export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = fals
localStorage.setItem(WHITESPACE_STORAGE_KEY, showWhitespace);
if (pushState) {
- historyPushState(showWhitespace ? '?w=0' : '?w=1');
+ historyPushState(mergeUrlParams({ w: showWhitespace ? '0' : '1' }, window.location.href));
}
+
+ eventHub.$emit('refetchDiffData');
};
export const toggleFileFinder = ({ commit }, visible) => {
@@ -309,5 +331,129 @@ export const cacheTreeListWidth = (_, size) => {
localStorage.setItem(TREE_LIST_WIDTH_STORAGE_KEY, size);
};
+export const requestFullDiff = ({ commit }, filePath) => commit(types.REQUEST_FULL_DIFF, filePath);
+export const receiveFullDiffSucess = ({ commit }, { filePath }) =>
+ commit(types.RECEIVE_FULL_DIFF_SUCCESS, { filePath });
+export const receiveFullDiffError = ({ commit }, filePath) => {
+ commit(types.RECEIVE_FULL_DIFF_ERROR, filePath);
+ createFlash(s__('MergeRequest|Error loading full diff. Please try again.'));
+};
+
+export const setExpandedDiffLines = ({ commit, state }, { file, data }) => {
+ const expandedDiffLines = {
+ highlighted_diff_lines: convertExpandLines({
+ diffLines: file.highlighted_diff_lines,
+ typeKey: TYPE_KEY,
+ oldLineKey: OLD_LINE_KEY,
+ newLineKey: NEW_LINE_KEY,
+ data,
+ mapLine: ({ line, oldLine, newLine }) =>
+ Object.assign(line, {
+ old_line: oldLine,
+ new_line: newLine,
+ line_code: `${file.file_hash}_${oldLine}_${newLine}`,
+ }),
+ }),
+ parallel_diff_lines: convertExpandLines({
+ diffLines: file.parallel_diff_lines,
+ typeKey: [LEFT_LINE_KEY, TYPE_KEY],
+ oldLineKey: [LEFT_LINE_KEY, OLD_LINE_KEY],
+ newLineKey: [LEFT_LINE_KEY, NEW_LINE_KEY],
+ data,
+ mapLine: ({ line, oldLine, newLine }) => ({
+ left: {
+ ...line,
+ old_line: oldLine,
+ line_code: `${file.file_hash}_${oldLine}_${newLine}`,
+ },
+ right: {
+ ...line,
+ new_line: newLine,
+ line_code: `${file.file_hash}_${newLine}_${oldLine}`,
+ },
+ }),
+ }),
+ };
+ const currentDiffLinesKey =
+ state.diffViewType === INLINE_DIFF_VIEW_TYPE ? INLINE_DIFF_LINES_KEY : PARALLEL_DIFF_LINES_KEY;
+ const hiddenDiffLinesKey =
+ state.diffViewType === INLINE_DIFF_VIEW_TYPE ? PARALLEL_DIFF_LINES_KEY : INLINE_DIFF_LINES_KEY;
+
+ commit(types.SET_HIDDEN_VIEW_DIFF_FILE_LINES, {
+ filePath: file.file_path,
+ lines: expandedDiffLines[hiddenDiffLinesKey],
+ });
+
+ if (expandedDiffLines[currentDiffLinesKey].length > MAX_RENDERING_DIFF_LINES) {
+ let index = START_RENDERING_INDEX;
+ commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, {
+ filePath: file.file_path,
+ lines: expandedDiffLines[currentDiffLinesKey].slice(0, index),
+ });
+ commit(types.TOGGLE_DIFF_FILE_RENDERING_MORE, file.file_path);
+
+ const idleCb = t => {
+ const startIndex = index;
+
+ while (
+ t.timeRemaining() >= MIN_RENDERING_MS &&
+ index !== expandedDiffLines[currentDiffLinesKey].length &&
+ index - startIndex !== MAX_RENDERING_BULK_ROWS
+ ) {
+ const line = expandedDiffLines[currentDiffLinesKey][index];
+
+ if (line) {
+ commit(types.ADD_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: file.file_path, line });
+ index += 1;
+ }
+ }
+
+ if (index !== expandedDiffLines[currentDiffLinesKey].length) {
+ idleCallback(idleCb);
+ } else {
+ commit(types.TOGGLE_DIFF_FILE_RENDERING_MORE, file.file_path);
+ }
+ };
+
+ idleCallback(idleCb);
+ } else {
+ commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, {
+ filePath: file.file_path,
+ lines: expandedDiffLines[currentDiffLinesKey],
+ });
+ }
+};
+
+export const fetchFullDiff = ({ dispatch }, file) =>
+ axios
+ .get(file.context_lines_path, {
+ params: {
+ full: true,
+ from_merge_request: true,
+ },
+ })
+ .then(({ data }) => {
+ dispatch('receiveFullDiffSucess', { filePath: file.file_path });
+ dispatch('setExpandedDiffLines', { file, data });
+ })
+ .catch(() => dispatch('receiveFullDiffError', file.file_path));
+
+export const toggleFullDiff = ({ dispatch, getters, state }, filePath) => {
+ const file = state.diffFiles.find(f => f.file_path === filePath);
+
+ dispatch('requestFullDiff', filePath);
+
+ if (file.isShowingFullFile) {
+ dispatch('loadCollapsedDiff', file)
+ .then(() => dispatch('assignDiscussionsToDiff', getters.getDiffFileDiscussions(file)))
+ .catch(() => dispatch('receiveFullDiffError', filePath));
+ } else {
+ dispatch('fetchFullDiff', file);
+ }
+};
+
+export const setFileCollapsed = ({ commit }, { filePath, collapsed }) =>
+ commit(types.SET_FILE_COLLAPSED, { filePath, collapsed });
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index 4e7e5306995..bc27e263bff 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -100,5 +100,12 @@ export const diffFilesLength = state => state.diffFiles.length;
export const getCommentFormForDiffFile = state => fileHash =>
state.commentForms.find(form => form.fileHash === fileHash);
+/**
+ * Returns index of a currently selected diff in diffFiles
+ * @returns {number}
+ */
+export const currentDiffIndex = state =>
+ Math.max(0, state.diffFiles.findIndex(diff => diff.file_hash === state.currentDiffFileId));
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js
index 47f78a5db54..cf4dd93dbfb 100644
--- a/app/assets/javascripts/diffs/store/modules/diff_state.js
+++ b/app/assets/javascripts/diffs/store/modules/diff_state.js
@@ -1,13 +1,10 @@
import Cookies from 'js-cookie';
import { getParameterValues } from '~/lib/utils/url_utility';
-import bp from '~/breakpoints';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME, MR_TREE_SHOW_KEY } from '../../constants';
+import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants';
const viewTypeFromQueryString = getParameterValues('view')[0];
const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME);
const defaultViewType = INLINE_DIFF_VIEW_TYPE;
-const storedTreeShow = localStorage.getItem(MR_TREE_SHOW_KEY);
export default () => ({
isLoading: true,
@@ -23,8 +20,7 @@ export default () => ({
diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType,
tree: [],
treeEntries: {},
- showTreeList:
- storedTreeShow === null ? bp.getBreakpointSize() !== 'xs' : parseBoolean(storedTreeShow),
+ showTreeList: true,
currentDiffFileId: '',
projectPath: '',
commentForms: [],
diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js
index 71ad108ce88..6bb24c97139 100644
--- a/app/assets/javascripts/diffs/store/mutation_types.js
+++ b/app/assets/javascripts/diffs/store/mutation_types.js
@@ -23,3 +23,13 @@ export const SET_TREE_DATA = 'SET_TREE_DATA';
export const SET_RENDER_TREE_LIST = 'SET_RENDER_TREE_LIST';
export const SET_SHOW_WHITESPACE = 'SET_SHOW_WHITESPACE';
export const TOGGLE_FILE_FINDER_VISIBLE = 'TOGGLE_FILE_FINDER_VISIBLE';
+
+export const REQUEST_FULL_DIFF = 'REQUEST_FULL_DIFF';
+export const RECEIVE_FULL_DIFF_SUCCESS = 'RECEIVE_FULL_DIFF_SUCCESS';
+export const RECEIVE_FULL_DIFF_ERROR = 'RECEIVE_FULL_DIFF_ERROR';
+export const SET_FILE_COLLAPSED = 'SET_FILE_COLLAPSED';
+
+export const SET_HIDDEN_VIEW_DIFF_FILE_LINES = 'SET_HIDDEN_VIEW_DIFF_FILE_LINES';
+export const SET_CURRENT_VIEW_DIFF_FILE_LINES = 'SET_CURRENT_VIEW_DIFF_FILE_LINES';
+export const ADD_CURRENT_VIEW_DIFF_FILE_LINES = 'ADD_CURRENT_VIEW_DIFF_FILE_LINES';
+export const TOGGLE_DIFF_FILE_RENDERING_MORE = 'TOGGLE_DIFF_FILE_RENDERING_MORE';
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 5a27388863c..67bc1724738 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -102,7 +102,10 @@ export default {
[types.EXPAND_ALL_FILES](state) {
state.diffFiles = state.diffFiles.map(file => ({
...file,
- collapsed: false,
+ viewer: {
+ ...file.viewer,
+ collapsed: false,
+ },
}));
},
@@ -155,7 +158,9 @@ export default {
}
if (!file.parallel_diff_lines || !file.highlighted_diff_lines) {
- file.discussions = (file.discussions || []).concat(discussion);
+ file.discussions = (file.discussions || [])
+ .filter(d => d.id !== discussion.id)
+ .concat(discussion);
}
return file;
@@ -248,4 +253,53 @@ export default {
[types.TOGGLE_FILE_FINDER_VISIBLE](state, visible) {
state.fileFinderVisible = visible;
},
+ [types.REQUEST_FULL_DIFF](state, filePath) {
+ const file = findDiffFile(state.diffFiles, filePath, 'file_path');
+
+ file.isLoadingFullFile = true;
+ },
+ [types.RECEIVE_FULL_DIFF_ERROR](state, filePath) {
+ const file = findDiffFile(state.diffFiles, filePath, 'file_path');
+
+ file.isLoadingFullFile = false;
+ },
+ [types.RECEIVE_FULL_DIFF_SUCCESS](state, { filePath }) {
+ const file = findDiffFile(state.diffFiles, filePath, 'file_path');
+
+ file.isShowingFullFile = true;
+ file.isLoadingFullFile = false;
+ },
+ [types.SET_FILE_COLLAPSED](state, { filePath, collapsed }) {
+ const file = state.diffFiles.find(f => f.file_path === filePath);
+
+ if (file && file.viewer) {
+ file.viewer.collapsed = collapsed;
+ }
+ },
+ [types.SET_HIDDEN_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) {
+ const file = state.diffFiles.find(f => f.file_path === filePath);
+ const hiddenDiffLinesKey =
+ state.diffViewType === 'inline' ? 'parallel_diff_lines' : 'highlighted_diff_lines';
+
+ file[hiddenDiffLinesKey] = lines;
+ },
+ [types.SET_CURRENT_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) {
+ const file = state.diffFiles.find(f => f.file_path === filePath);
+ const currentDiffLinesKey =
+ state.diffViewType === 'inline' ? 'highlighted_diff_lines' : 'parallel_diff_lines';
+
+ file[currentDiffLinesKey] = lines;
+ },
+ [types.ADD_CURRENT_VIEW_DIFF_FILE_LINES](state, { filePath, line }) {
+ const file = state.diffFiles.find(f => f.file_path === filePath);
+ const currentDiffLinesKey =
+ state.diffViewType === 'inline' ? 'highlighted_diff_lines' : 'parallel_diff_lines';
+
+ file[currentDiffLinesKey].push(line);
+ },
+ [types.TOGGLE_DIFF_FILE_RENDERING_MORE](state, filePath) {
+ const file = state.diffFiles.find(f => f.file_path === filePath);
+
+ file.renderingLines = !file.renderingLines;
+ },
};
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 247d1e65fea..71956255eef 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -15,8 +15,8 @@ import {
TREE_TYPE,
} from '../constants';
-export function findDiffFile(files, hash) {
- return files.filter(file => file.file_hash === hash)[0];
+export function findDiffFile(files, match, matchKey = 'file_hash') {
+ return files.find(file => file[matchKey] === match);
}
export const getReversePosition = linePosition => {
@@ -250,7 +250,10 @@ export function prepareDiffData(diffData) {
renderIt: showingLines < LINES_TO_BE_RENDERED_DIRECTLY,
collapsed:
file.viewer.name === diffViewerModes.text && showingLines > MAX_LINES_TO_BE_RENDERED,
+ isShowingFullFile: false,
+ isLoadingFullFile: false,
discussions: [],
+ renderingLines: false,
});
}
}
@@ -411,3 +414,43 @@ export const getDiffMode = diffFile => {
diffModes.replaced
);
};
+
+export const convertExpandLines = ({
+ diffLines,
+ data,
+ typeKey,
+ oldLineKey,
+ newLineKey,
+ mapLine,
+}) => {
+ const dataLength = data.length;
+ const lines = [];
+
+ for (let i = 0, diffLinesLength = diffLines.length; i < diffLinesLength; i += 1) {
+ const line = diffLines[i];
+
+ if (_.property(typeKey)(line) === 'match') {
+ const beforeLine = diffLines[i - 1];
+ const afterLine = diffLines[i + 1];
+ const newLineProperty = _.property(newLineKey);
+ const beforeLineIndex = newLineProperty(beforeLine) || 0;
+ const afterLineIndex = newLineProperty(afterLine) - 1 || dataLength;
+
+ lines.push(
+ ...data.slice(beforeLineIndex, afterLineIndex).map((l, index) =>
+ mapLine({
+ line: Object.assign(l, { hasForm: false, discussions: [] }),
+ oldLine: (_.property(oldLineKey)(beforeLine) || 0) + index + 1,
+ newLine: (newLineProperty(beforeLine) || 0) + index + 1,
+ }),
+ ),
+ );
+ } else {
+ lines.push(line);
+ }
+ }
+
+ return lines;
+};
+
+export const idleCallback = cb => requestIdleCallback(cb);
diff --git a/app/assets/javascripts/diffs/workers/tree_worker.js b/app/assets/javascripts/diffs/workers/tree_worker.js
index 534d737c77e..415c463fd19 100644
--- a/app/assets/javascripts/diffs/workers/tree_worker.js
+++ b/app/assets/javascripts/diffs/workers/tree_worker.js
@@ -4,6 +4,11 @@ import { generateTreeList } from '../store/utils';
// eslint-disable-next-line no-restricted-globals
self.addEventListener('message', e => {
const { data } = e;
+
+ if (data === undefined) {
+ return;
+ }
+
const { treeEntries, tree } = generateTreeList(data);
// eslint-disable-next-line no-restricted-globals
diff --git a/app/assets/javascripts/dirty_submit/dirty_submit_form.js b/app/assets/javascripts/dirty_submit/dirty_submit_form.js
index 00e41dd0301..0fcaec9531c 100644
--- a/app/assets/javascripts/dirty_submit/dirty_submit_form.js
+++ b/app/assets/javascripts/dirty_submit/dirty_submit_form.js
@@ -1,4 +1,5 @@
import _ from 'underscore';
+import $ from 'jquery';
class DirtySubmitForm {
constructor(form) {
@@ -20,12 +21,18 @@ class DirtySubmitForm {
}
registerListeners() {
- const throttledUpdateDirtyInput = _.throttle(
- event => this.updateDirtyInput(event),
- DirtySubmitForm.THROTTLE_DURATION,
+ const getThrottledHandlerForInput = _.memoize(() =>
+ _.throttle(event => this.updateDirtyInput(event), DirtySubmitForm.THROTTLE_DURATION),
);
+
+ const throttledUpdateDirtyInput = event => {
+ const throttledHandler = getThrottledHandlerForInput(event.target.name);
+ throttledHandler(event);
+ };
+
this.form.addEventListener('input', throttledUpdateDirtyInput);
this.form.addEventListener('change', throttledUpdateDirtyInput);
+ $(this.form).on('change.select2', throttledUpdateDirtyInput);
this.form.addEventListener('submit', event => this.formSubmit(event));
}
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index 9987fbcb6a7..0ff26445a6a 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -4,6 +4,7 @@ import _ from 'underscore';
import './behaviors/preview_markdown';
import csrf from './lib/utils/csrf';
import axios from './lib/utils/axios_utils';
+import { n__, __ } from '~/locale';
Dropzone.autoDiscover = false;
@@ -90,7 +91,7 @@ export default function dropzoneInput(form) {
if (!processingFileCount) $attachButton.removeClass('hide');
addFileToForm(response.link.url);
},
- error: (file, errorMessage = 'Attaching the file failed.', xhr) => {
+ error: (file, errorMessage = __('Attaching the file failed.'), xhr) => {
// If 'error' event is fired by dropzone, the second parameter is error message.
// If the 'errorMessage' parameter is empty, the default error message is set.
// If the 'error' event is fired by backend (xhr) error response, the third parameter is
@@ -273,19 +274,11 @@ export default function dropzoneInput(form) {
};
updateAttachingMessage = (files, messageContainer) => {
- let attachingMessage;
const filesCount = files.filter(file => file.status === 'uploading' || file.status === 'queued')
.length;
+ const attachingMessage = n__('Attaching a file', 'Attaching %d files', filesCount);
- // Dinamycally change uploading files text depending on files number in
- // dropzone files queue.
- if (filesCount > 1) {
- attachingMessage = `Attaching ${filesCount} files -`;
- } else {
- attachingMessage = 'Attaching a file -';
- }
-
- messageContainer.text(attachingMessage);
+ messageContainer.text(`${attachingMessage} -`);
};
form.find('.markdown-selector').click(function onMarkdownClick(e) {
diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js
index cb1b1173190..3c650397a19 100644
--- a/app/assets/javascripts/due_date_select.js
+++ b/app/assets/javascripts/due_date_select.js
@@ -104,7 +104,7 @@ class DueDateSelect {
const dateObj = new Date(dateArray[0], dateArray[1] - 1, dateArray[2]);
this.displayedDate = dateFormat(dateObj, 'mmm d, yyyy');
} else {
- this.displayedDate = 'No due date';
+ this.displayedDate = __('None');
}
}
@@ -132,7 +132,7 @@ class DueDateSelect {
submitSelectedDate(isDropdown) {
const selectedDateValue = this.datePayload[this.abilityName].due_date;
- const hasDueDate = this.displayedDate !== 'No due date';
+ const hasDueDate = this.displayedDate !== __('None');
const displayedDateStyle = hasDueDate ? 'bold' : 'no-value';
this.$loading.removeClass('hidden').fadeIn();
diff --git a/app/assets/javascripts/emoji/no_emoji_validator.js b/app/assets/javascripts/emoji/no_emoji_validator.js
index 0fd4dd74953..384d62a133a 100644
--- a/app/assets/javascripts/emoji/no_emoji_validator.js
+++ b/app/assets/javascripts/emoji/no_emoji_validator.js
@@ -1,10 +1,11 @@
import { __ } from '~/locale';
import emojiRegex from 'emoji-regex';
+import InputValidator from '../validators/input_validator';
-const invalidInputClass = 'gl-field-error-outline';
-
-export default class NoEmojiValidator {
+export default class NoEmojiValidator extends InputValidator {
constructor(opts = {}) {
+ super();
+
const container = opts.container || '';
this.noEmojiEmelents = document.querySelectorAll(`${container} .js-block-emoji`);
@@ -19,45 +20,14 @@ export default class NoEmojiValidator {
const { value } = this.inputDomElement;
+ this.errorMessage = __('Invalid input, please avoid emojis');
+
this.validatePattern(value);
this.setValidationStateAndMessage();
}
validatePattern(value) {
const pattern = emojiRegex();
- this.hasEmojis = new RegExp(pattern).test(value);
-
- if (this.hasEmojis) {
- this.inputDomElement.setCustomValidity(__('Invalid input, please avoid emojis'));
- } else {
- this.inputDomElement.setCustomValidity('');
- }
- }
-
- setValidationStateAndMessage() {
- if (!this.inputDomElement.checkValidity()) {
- this.setInvalidState();
- } else {
- this.clearFieldValidationState();
- }
- }
-
- clearFieldValidationState() {
- this.inputDomElement.classList.remove(invalidInputClass);
- this.inputErrorMessage.classList.add('hide');
- }
-
- setInvalidState() {
- this.inputDomElement.classList.add(invalidInputClass);
- this.setErrorMessage();
- }
-
- setErrorMessage() {
- if (this.hasEmojis) {
- this.inputErrorMessage.innerHTML = this.inputDomElement.validationMessage;
- } else {
- this.inputErrorMessage.innerHTML = this.inputDomElement.title;
- }
- this.inputErrorMessage.classList.remove('hide');
+ this.invalidInput = new RegExp(pattern).test(value);
}
}
diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
new file mode 100644
index 00000000000..70b5c6b0094
--- /dev/null
+++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
@@ -0,0 +1,108 @@
+<script>
+/**
+ * Render modal to confirm rollback/redeploy.
+ */
+
+import _ from 'underscore';
+import { GlModal } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+
+import eventHub from '../event_hub';
+
+export default {
+ name: 'ConfirmRollbackModal',
+
+ components: {
+ GlModal,
+ },
+
+ props: {
+ environment: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ computed: {
+ modalTitle() {
+ const title = this.environment.isLastDeployment
+ ? s__('Environments|Re-deploy environment %{name}?')
+ : s__('Environments|Rollback environment %{name}?');
+
+ return sprintf(title, {
+ name: _.escape(this.environment.name),
+ });
+ },
+
+ commitShortSha() {
+ const { last_deployment } = this.environment;
+ return this.commitData(last_deployment, 'short_id');
+ },
+
+ commitUrl() {
+ const { last_deployment } = this.environment;
+ return this.commitData(last_deployment, 'commit_path');
+ },
+
+ commitTitle() {
+ const { last_deployment } = this.environment;
+ return this.commitData(last_deployment, 'title');
+ },
+
+ modalText() {
+ const linkStart = `<a class="commit-sha mr-0" href="${_.escape(this.commitUrl)}">`;
+ const commitId = _.escape(this.commitShortSha);
+ const linkEnd = '</a>';
+ const name = _.escape(this.name);
+ const body = this.environment.isLastDeployment
+ ? s__(
+ 'Environments|This action will relaunch the job for commit %{linkStart}%{commitId}%{linkEnd}, putting the environment in a previous version. Are you sure you want to continue?',
+ )
+ : s__(
+ 'Environments|This action will run the job defined by %{name} for commit %{linkStart}%{commitId}%{linkEnd} putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?',
+ );
+ return sprintf(
+ body,
+ {
+ commitId,
+ linkStart,
+ linkEnd,
+ name,
+ },
+ false,
+ );
+ },
+
+ modalActionText() {
+ return this.environment.isLastDeployment
+ ? s__('Environments|Re-deploy')
+ : s__('Environments|Rollback');
+ },
+ },
+
+ methods: {
+ onOk() {
+ eventHub.$emit('rollbackEnvironment', this.environment);
+ },
+
+ commitData(lastDeployment, key) {
+ if (lastDeployment && lastDeployment.commit) {
+ return lastDeployment.commit[key];
+ }
+
+ return '';
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ :title="modalTitle"
+ modal-id="confirm-rollback-modal"
+ :ok-title="modalActionText"
+ ok-variant="danger"
+ @ok="onOk"
+ >
+ <p v-html="modalText"></p>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue
index 6ece8b92a30..be80661223c 100644
--- a/app/assets/javascripts/environments/components/container.vue
+++ b/app/assets/javascripts/environments/components/container.vue
@@ -1,14 +1,16 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
-import tablePagination from '../../vue_shared/components/table_pagination.vue';
-import environmentTable from '../components/environments_table.vue';
+import TablePagination from '~/vue_shared/components/table_pagination.vue';
+import containerMixin from 'ee_else_ce/environments/mixins/container_mixin';
+import EnvironmentTable from '../components/environments_table.vue';
export default {
components: {
- environmentTable,
- tablePagination,
+ EnvironmentTable,
+ TablePagination,
GlLoadingIcon,
},
+ mixins: [containerMixin],
props: {
isLoading: {
type: Boolean,
@@ -47,7 +49,15 @@ export default {
<slot name="emptyState"></slot>
<div v-if="!isLoading && environments.length > 0" class="table-holder">
- <environment-table :environments="environments" :can-read-environment="canReadEnvironment" />
+ <environment-table
+ :environments="environments"
+ :can-read-environment="canReadEnvironment"
+ :canary-deployment-feature-id="canaryDeploymentFeatureId"
+ :show-canary-deployment-callout="showCanaryDeploymentCallout"
+ :user-callouts-path="userCalloutsPath"
+ :lock-promotion-svg-path="lockPromotionSvgPath"
+ :help-canary-deployments-path="helpCanaryDeploymentsPath"
+ />
<table-pagination
v-if="pagination && pagination.totalPages > 1"
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 503c1b38f71..f0e80cba753 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -3,8 +3,8 @@ import Timeago from 'timeago.js';
import _ from 'underscore';
import { GlTooltipDirective } from '@gitlab/ui';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
-import { humanize } from '~/lib/utils/text_utility';
import Icon from '~/vue_shared/components/icon.vue';
+import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin';
import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue';
import StopComponent from './environment_stop.vue';
@@ -35,10 +35,10 @@ export default {
TerminalButtonComponent,
MonitoringButtonComponent,
},
-
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [environmentItemMixin],
props: {
model: {
@@ -156,7 +156,7 @@ export default {
const combinedActions = (manualActions || []).concat(scheduledActions || []);
return combinedActions.map(action => ({
...action,
- name: humanize(action.name),
+ name: action.name,
}));
},
@@ -459,19 +459,37 @@ export default {
class="gl-responsive-table-row"
role="row"
>
- <div
- v-gl-tooltip
- :title="model.name"
- class="table-section section-wrap section-15 text-truncate"
- role="gridcell"
- >
+ <div class="table-section section-wrap section-15 text-truncate" role="gridcell">
<div v-if="!model.isFolder" class="table-mobile-header" role="rowheader">
{{ s__('Environments|Environment') }}
</div>
- <span v-if="!model.isFolder" class="environment-name table-mobile-content">
- <a class="qa-environment-link" :href="environmentPath"> {{ model.name }} </a>
+
+ <span v-if="shouldRenderDeployBoard" class="deploy-board-icon" @click="toggleDeployBoard">
+ <icon :name="deployIconName" />
+ </span>
+
+ <span
+ v-if="!model.isFolder"
+ v-gl-tooltip
+ :title="model.name"
+ class="environment-name table-mobile-content"
+ >
+ <a class="qa-environment-link" :href="environmentPath">
+ <span v-if="model.size === 1">{{ model.name }}</span>
+ <span v-else>{{ model.name_without_type }}</span>
+ </a>
+ <span v-if="isProtected" class="badge badge-success">
+ {{ s__('Environments|protected') }}
+ </span>
</span>
- <span v-else class="folder-name" role="button" @click="onClickFolder">
+ <span
+ v-else
+ v-gl-tooltip
+ :title="model.folderName"
+ class="folder-name"
+ role="button"
+ @click="onClickFolder"
+ >
<icon :name="folderIconName" class="folder-icon" />
<icon name="folder" class="folder-icon" />
@@ -486,22 +504,28 @@ export default {
class="table-section section-10 deployment-column d-none d-sm-none d-md-block"
role="gridcell"
>
- <span v-if="shouldRenderDeploymentID"> {{ deploymentInternalId }} </span>
+ <span v-if="shouldRenderDeploymentID" class="text-break-word">
+ {{ deploymentInternalId }}
+ </span>
- <span v-if="!model.isFolder && deploymentHasUser">
+ <span v-if="!model.isFolder && deploymentHasUser" class="text-break-word">
by
<user-avatar-link
:link-href="deploymentUser.web_url"
:img-src="deploymentUser.avatar_url"
:img-alt="userImageAltDescription"
:tooltip-text="deploymentUser.username"
- class="js-deploy-user-container"
+ class="js-deploy-user-container float-none"
/>
</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 flex-truncate-parent">
+ <a
+ v-if="shouldRenderBuildName"
+ :href="buildPath"
+ class="build-link cgray flex-truncate-parent"
+ >
<span class="flex-truncate-child">{{ buildName }}</span>
</a>
</div>
@@ -556,6 +580,7 @@ export default {
<rollback-component
v-if="canRetry"
+ :environment="model"
:is-last-deployment="isLastDeployment"
:retry-url="retryUrl"
/>
diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue
index 50c86af057c..bafbc00597e 100644
--- a/app/assets/javascripts/environments/components/environment_rollback.vue
+++ b/app/assets/javascripts/environments/components/environment_rollback.vue
@@ -5,29 +5,38 @@
*
* Makes a post request when the button is clicked.
*/
+import { GlTooltipDirective, GlLoadingIcon, GlModalDirective, GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
-import { GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
+import ConfirmRollbackModal from './confirm_rollback_modal.vue';
import eventHub from '../event_hub';
export default {
components: {
Icon,
GlLoadingIcon,
+ GlButton,
+ ConfirmRollbackModal,
},
directives: {
GlTooltip: GlTooltipDirective,
+ GlModal: GlModalDirective,
},
props: {
- retryUrl: {
- type: String,
- default: '',
- },
-
isLastDeployment: {
type: Boolean,
default: true,
},
+
+ environment: {
+ type: Object,
+ required: true,
+ },
+
+ retryUrl: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -45,23 +54,30 @@ export default {
methods: {
onClick() {
- this.isLoading = true;
-
- eventHub.$emit('postAction', { endpoint: this.retryUrl });
+ eventHub.$emit('requestRollbackEnvironment', {
+ ...this.environment,
+ retryUrl: this.retryUrl,
+ isLastDeployment: this.isLastDeployment,
+ });
+ eventHub.$on('rollbackEnvironment', environment => {
+ if (environment.id === this.environment.id) {
+ this.isLoading = true;
+ }
+ });
},
},
};
</script>
<template>
- <button
+ <gl-button
v-gl-tooltip
+ v-gl-modal.confirm-rollback-modal
:disabled="isLoading"
:title="title"
- type="button"
- class="btn d-none d-sm-none d-md-block"
+ class="d-none d-md-block text-secondary"
@click="onClick"
>
<icon v-if="isLastDeployment" name="repeat" /> <icon v-else name="redo" />
<gl-loading-icon v-if="isLoading" />
- </button>
+ </gl-button>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue
index 6d74d136a94..13195d32cc4 100644
--- a/app/assets/javascripts/environments/components/environment_terminal_button.vue
+++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue
@@ -39,7 +39,7 @@ export default {
:aria-label="title"
:href="terminalPath"
:class="{ disabled: disabled }"
- class="btn terminal-button d-none d-sm-none d-md-block"
+ class="btn terminal-button d-none d-sm-none d-md-block text-secondary"
>
<icon name="terminal" />
</a>
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index aa2417d3194..ec78240217b 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -1,4 +1,5 @@
<script>
+import envrionmentsAppMixin from 'ee_else_ce/environments/mixins/environments_app_mixin';
import Flash from '../../flash';
import { s__ } from '../../locale';
import emptyState from './empty_state.vue';
@@ -6,14 +7,16 @@ import eventHub from '../event_hub';
import environmentsMixin from '../mixins/environments_mixin';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
import StopEnvironmentModal from './stop_environment_modal.vue';
+import ConfirmRollbackModal from './confirm_rollback_modal.vue';
export default {
components: {
emptyState,
StopEnvironmentModal,
+ ConfirmRollbackModal,
},
- mixins: [CIPaginationMixin, environmentsMixin],
+ mixins: [CIPaginationMixin, environmentsMixin, envrionmentsAppMixin],
props: {
endpoint: {
@@ -87,14 +90,15 @@ export default {
<template>
<div :class="cssContainerClass">
<stop-environment-modal :environment="environmentInStopModal" />
+ <confirm-rollback-modal :environment="environmentInRollbackModal" />
<div class="top-area">
<tabs :tabs="tabs" scope="environments" @onChangeTab="onChangeTab" />
<div v-if="canCreateEnvironment && !isLoading" class="nav-controls">
- <a :href="newEnvironmentPath" class="btn btn-success">{{
- s__('Environments|New environment')
- }}</a>
+ <a :href="newEnvironmentPath" class="btn btn-success">
+ {{ s__('Environments|New environment') }}
+ </a>
</div>
</div>
@@ -103,6 +107,11 @@ export default {
:environments="state.environments"
:pagination="state.paginationInformation"
:can-read-environment="canReadEnvironment"
+ :canary-deployment-feature-id="canaryDeploymentFeatureId"
+ :show-canary-deployment-callout="showCanaryDeploymentCallout"
+ :user-callouts-path="userCalloutsPath"
+ :lock-promotion-svg-path="lockPromotionSvgPath"
+ :help-canary-deployments-path="helpCanaryDeploymentsPath"
@onChangePage="onChangePage"
>
<empty-state
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index e2c304de00a..55613d815ce 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -3,27 +3,40 @@
* Render environments table.
*/
import { GlLoadingIcon } from '@gitlab/ui';
-import environmentItem from './environment_item.vue';
+import _ from 'underscore';
+import environmentTableMixin from 'ee_else_ce/environments/mixins/environments_table_mixin';
+import EnvironmentItem from './environment_item.vue';
export default {
components: {
- environmentItem,
+ EnvironmentItem,
GlLoadingIcon,
+ DeployBoard: () => import('ee_component/environments/components/deploy_board_component.vue'),
+ CanaryDeploymentCallout: () =>
+ import('ee_component/environments/components/canary_deployment_callout.vue'),
},
-
+ mixins: [environmentTableMixin],
props: {
environments: {
type: Array,
required: true,
default: () => [],
},
-
canReadEnvironment: {
type: Boolean,
required: false,
default: false,
},
},
+ computed: {
+ sortedEnvironments() {
+ return this.sortEnvironments(this.environments).map(env =>
+ this.shouldRenderFolderContent(env)
+ ? { ...env, children: this.sortEnvironments(env.children) }
+ : env,
+ );
+ },
+ },
methods: {
folderUrl(model) {
return `${window.location.pathname}/folders/${model.folderName}`;
@@ -31,6 +44,30 @@ export default {
shouldRenderFolderContent(env) {
return env.isFolder && env.isOpen && env.children && env.children.length > 0;
},
+ sortEnvironments(environments) {
+ /*
+ * The sorting algorithm should sort in the following priorities:
+ *
+ * 1. folders first,
+ * 2. last updated descending,
+ * 3. by name ascending,
+ *
+ * the sorting algorithm must:
+ *
+ * 1. Sort by name ascending,
+ * 2. Reverse (sort by name descending),
+ * 3. Sort by last deployment ascending,
+ * 4. Reverse (last deployment descending, name ascending),
+ * 5. Put folders first.
+ */
+ return _.chain(environments)
+ .sortBy(env => (env.isFolder ? env.folderName : env.name))
+ .reverse()
+ .sortBy(env => (env.last_deployment ? env.last_deployment.created_at : '0000'))
+ .reverse()
+ .sortBy(env => (env.isFolder ? -1 : 1))
+ .value();
+ },
},
};
</script>
@@ -53,7 +90,7 @@ export default {
{{ s__('Environments|Updated') }}
</div>
</div>
- <template v-for="(model, i) in environments" :model="model">
+ <template v-for="(model, i) in sortedEnvironments" :model="model">
<div
is="environment-item"
:key="`environment-item-${i}`"
@@ -61,6 +98,21 @@ export default {
:can-read-environment="canReadEnvironment"
/>
+ <div
+ v-if="shouldRenderDeployBoard(model)"
+ :key="`deploy-board-row-${i}`"
+ class="js-deploy-board-row"
+ >
+ <div class="deploy-board-container">
+ <deploy-board
+ :deploy-board-data="model.deployBoardData"
+ :is-loading="model.isLoadingDeployBoard"
+ :is-empty="model.isEmptyDeployBoard"
+ :logs-path="model.logs_path"
+ />
+ </div>
+ </div>
+
<template v-if="shouldRenderFolderContent(model)">
<div v-if="model.isLoadingFolderContent" :key="`loading-item-${i}`">
<gl-loading-icon :size="2" class="prepend-top-16" />
@@ -77,13 +129,24 @@ export default {
<div :key="`sub-div-${i}`">
<div class="text-center prepend-top-10">
- <a :href="folderUrl(model)" class="btn btn-default">{{
- s__('Environments|Show all')
- }}</a>
+ <a :href="folderUrl(model)" class="btn btn-default">
+ {{ s__('Environments|Show all') }}
+ </a>
</div>
</div>
</template>
</template>
+
+ <template v-if="shouldShowCanaryCallout(model)">
+ <canary-deployment-callout
+ :key="`canary-promo-${i}`"
+ :canary-deployment-feature-id="canaryDeploymentFeatureId"
+ :user-callouts-path="userCalloutsPath"
+ :lock-promotion-svg-path="lockPromotionSvgPath"
+ :help-canary-deployments-path="helpCanaryDeploymentsPath"
+ :data-js-canary-promo-key="i"
+ />
+ </template>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
index 56e7f69cad6..c1bfe8d05fe 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js
+++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import canaryCalloutMixin from 'ee_else_ce/environments/mixins/canary_callout_mixin';
import environmentsFolderApp from './environments_folder_view.vue';
import { parseBoolean } from '../../lib/utils/common_utils';
import Translate from '../../vue_shared/translate';
@@ -11,6 +12,7 @@ export default () =>
components: {
environmentsFolderApp,
},
+ mixins: [canaryCalloutMixin],
data() {
const environmentsData = document.querySelector(this.$options.el).dataset;
@@ -28,6 +30,7 @@ export default () =>
folderName: this.folderName,
cssContainerClass: this.cssContainerClass,
canReadEnvironment: this.canReadEnvironment,
+ ...this.canaryCalloutProps,
},
});
},
diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue
index 80f0e00400b..6fd0561f682 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_view.vue
+++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue
@@ -1,4 +1,5 @@
<script>
+import folderMixin from 'ee_else_ce/environments/mixins/environments_folder_view_mixin';
import environmentsMixin from '../mixins/environments_mixin';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
import StopEnvironmentModal from '../components/stop_environment_modal.vue';
@@ -8,7 +9,7 @@ export default {
StopEnvironmentModal,
},
- mixins: [environmentsMixin, CIPaginationMixin],
+ mixins: [environmentsMixin, CIPaginationMixin, folderMixin],
props: {
endpoint: {
@@ -41,7 +42,8 @@ export default {
<div v-if="!isLoading" class="top-area">
<h4 class="js-folder-name environments-folder-name">
- {{ s__('Environments|Environments') }} / <b>{{ folderName }}</b>
+ {{ s__('Environments|Environments') }} /
+ <b>{{ folderName }}</b>
</h4>
<tabs :tabs="tabs" scope="environments" @onChangeTab="onChangeTab" />
@@ -52,6 +54,11 @@ export default {
:environments="state.environments"
:pagination="state.paginationInformation"
:can-read-environment="canReadEnvironment"
+ :canary-deployment-feature-id="canaryDeploymentFeatureId"
+ :show-canary-deployment-callout="showCanaryDeploymentCallout"
+ :user-callouts-path="userCalloutsPath"
+ :lock-promotion-svg-path="lockPromotionSvgPath"
+ :help-canary-deployments-path="helpCanaryDeploymentsPath"
@onChangePage="onChangePage"
/>
</div>
diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js
index 6af66d0f86e..b53d42f202b 100644
--- a/app/assets/javascripts/environments/index.js
+++ b/app/assets/javascripts/environments/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import canaryCalloutMixin from 'ee_else_ce/environments/mixins/canary_callout_mixin';
import environmentsComponent from './components/environments_app.vue';
import { parseBoolean } from '../lib/utils/common_utils';
import Translate from '../vue_shared/translate';
@@ -11,6 +12,7 @@ export default () =>
components: {
environmentsComponent,
},
+ mixins: [canaryCalloutMixin],
data() {
const environmentsData = document.querySelector(this.$options.el).dataset;
@@ -32,6 +34,7 @@ export default () =>
cssContainerClass: this.cssContainerClass,
canCreateEnvironment: this.canCreateEnvironment,
canReadEnvironment: this.canReadEnvironment,
+ ...this.canaryCalloutProps,
},
});
},
diff --git a/app/assets/javascripts/environments/mixins/canary_callout_mixin.js b/app/assets/javascripts/environments/mixins/canary_callout_mixin.js
new file mode 100644
index 00000000000..f6d3d67b777
--- /dev/null
+++ b/app/assets/javascripts/environments/mixins/canary_callout_mixin.js
@@ -0,0 +1,5 @@
+export default {
+ computed: {
+ canaryCalloutProps() {},
+ },
+};
diff --git a/app/assets/javascripts/environments/mixins/container_mixin.js b/app/assets/javascripts/environments/mixins/container_mixin.js
new file mode 100644
index 00000000000..f2907c120f8
--- /dev/null
+++ b/app/assets/javascripts/environments/mixins/container_mixin.js
@@ -0,0 +1,29 @@
+export default {
+ props: {
+ canaryDeploymentFeatureId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ showCanaryDeploymentCallout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ userCalloutsPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ lockPromotionSvgPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ helpCanaryDeploymentsPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+};
diff --git a/app/assets/javascripts/environments/mixins/environment_item_mixin.js b/app/assets/javascripts/environments/mixins/environment_item_mixin.js
new file mode 100644
index 00000000000..2dfed36ec99
--- /dev/null
+++ b/app/assets/javascripts/environments/mixins/environment_item_mixin.js
@@ -0,0 +1,13 @@
+export default {
+ computed: {
+ deployIconName() {
+ return '';
+ },
+ shouldRenderDeployBoard() {
+ return false;
+ },
+ },
+ methods: {
+ toggleDeployBoard() {},
+ },
+};
diff --git a/app/assets/javascripts/environments/mixins/environments_app_mixin.js b/app/assets/javascripts/environments/mixins/environments_app_mixin.js
new file mode 100644
index 00000000000..fc805b9235a
--- /dev/null
+++ b/app/assets/javascripts/environments/mixins/environments_app_mixin.js
@@ -0,0 +1,32 @@
+export default {
+ props: {
+ canaryDeploymentFeatureId: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showCanaryDeploymentCallout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ userCalloutsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ lockPromotionSvgPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ helpCanaryDeploymentsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ metods: {
+ toggleDeployBoard() {},
+ },
+};
diff --git a/app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js b/app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js
new file mode 100644
index 00000000000..e793a7cadf2
--- /dev/null
+++ b/app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js
@@ -0,0 +1,29 @@
+export default {
+ props: {
+ canaryDeploymentFeatureId: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showCanaryDeploymentCallout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ userCalloutsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ lockPromotionSvgPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ helpCanaryDeploymentsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js
index e81a1525df0..a5812b173dc 100644
--- a/app/assets/javascripts/environments/mixins/environments_mixin.js
+++ b/app/assets/javascripts/environments/mixins/environments_mixin.js
@@ -3,13 +3,13 @@
*/
import _ from 'underscore';
import Visibility from 'visibilityjs';
+import EnvironmentsStore from 'ee_else_ce/environments/stores/environments_store';
import Poll from '../../lib/utils/poll';
import { getParameterByName } from '../../lib/utils/common_utils';
import { s__ } from '../../locale';
import Flash from '../../flash';
import eventHub from '../event_hub';
-import EnvironmentsStore from '../stores/environments_store';
import EnvironmentsService from '../services/environments_service';
import tablePagination from '../../vue_shared/components/table_pagination.vue';
import environmentTable from '../components/environments_table.vue';
@@ -36,6 +36,7 @@ export default {
page: getParameterByName('page') || '1',
requestData: {},
environmentInStopModal: {},
+ environmentInRollbackModal: {},
};
},
@@ -43,7 +44,11 @@ export default {
saveData(resp) {
this.isLoading = false;
- if (_.isEqual(resp.config.params, this.requestData)) {
+ // Prevent the absence of the nested flag from causing mismatches
+ const response = this.filterNilValues(resp.config.params);
+ const request = this.filterNilValues(this.requestData);
+
+ if (_.isEqual(response, request)) {
this.store.storeAvailableCount(resp.data.available_count);
this.store.storeStoppedCount(resp.data.stopped_count);
this.store.storeEnvironments(resp.data.environments);
@@ -51,6 +56,10 @@ export default {
}
},
+ filterNilValues(obj) {
+ return _.omit(obj, value => _.isUndefined(value) || _.isNull(value));
+ },
+
/**
* Handles URL and query parameter changes.
* When the user uses the pagination or the tabs,
@@ -64,10 +73,9 @@ export default {
// fetch new data
return this.service
.fetchEnvironments(this.requestData)
- .then(response => this.successCallback(response))
- .then(() => {
- // restart polling
- this.poll.restart({ data: this.requestData });
+ .then(response => {
+ this.successCallback(response);
+ this.poll.enable({ data: this.requestData, response });
})
.catch(() => {
this.errorCallback();
@@ -109,6 +117,10 @@ export default {
this.environmentInStopModal = environment;
},
+ updateRollbackModal(environment) {
+ this.environmentInRollbackModal = environment;
+ },
+
stopEnvironment(environment) {
const endpoint = environment.stop_path;
const errorMessage = s__(
@@ -116,6 +128,16 @@ export default {
);
this.postAction({ endpoint, errorMessage });
},
+
+ rollbackEnvironment(environment) {
+ const { retryUrl, isLastDeployment } = environment;
+ const errorMessage = isLastDeployment
+ ? s__('Environments|An error occurred while re-deploying the environment, please try again')
+ : s__(
+ 'Environments|An error occurred while rolling back the environment, please try again',
+ );
+ this.postAction({ endpoint: retryUrl, errorMessage });
+ },
},
computed: {
@@ -174,11 +196,17 @@ export default {
eventHub.$on('postAction', this.postAction);
eventHub.$on('requestStopEnvironment', this.updateStopModal);
eventHub.$on('stopEnvironment', this.stopEnvironment);
+
+ eventHub.$on('requestRollbackEnvironment', this.updateRollbackModal);
+ eventHub.$on('rollbackEnvironment', this.rollbackEnvironment);
},
beforeDestroy() {
eventHub.$off('postAction', this.postAction);
eventHub.$off('requestStopEnvironment', this.updateStopModal);
eventHub.$off('stopEnvironment', this.stopEnvironment);
+
+ eventHub.$off('requestRollbackEnvironment', this.updateRollbackModal);
+ eventHub.$off('rollbackEnvironment', this.rollbackEnvironment);
},
};
diff --git a/app/assets/javascripts/environments/mixins/environments_table_mixin.js b/app/assets/javascripts/environments/mixins/environments_table_mixin.js
new file mode 100644
index 00000000000..208f1a7373d
--- /dev/null
+++ b/app/assets/javascripts/environments/mixins/environments_table_mixin.js
@@ -0,0 +1,10 @@
+export default {
+ methods: {
+ shouldShowCanaryCallout() {
+ return false;
+ },
+ shouldRenderDeployBoard() {
+ return false;
+ },
+ },
+};
diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js
index ac9a31c202c..5fb420e9da5 100644
--- a/app/assets/javascripts/environments/stores/environments_store.js
+++ b/app/assets/javascripts/environments/stores/environments_store.js
@@ -1,4 +1,6 @@
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import { setDeployBoard } from 'ee_else_ce/environments/stores/helpers';
+
/**
* Environments Store.
*
@@ -31,6 +33,14 @@ export default class EnvironmentsStore {
* If the `size` is bigger than 1, it means it should be rendered as a folder.
* In those cases we add `isFolder` key in order to render it properly.
*
+ * Top level environments - when the size is 1 - with `rollout_status`
+ * can render a deploy board. We add `isDeployBoardVisible` and `deployBoardData`
+ * keys to those environments.
+ * The first key will let's us know if we should or not render the deploy board.
+ * It will be toggled when the user clicks to seee the deploy board.
+ *
+ * The second key will allow us to update the environment with the received deploy board data.
+ *
* @param {Array} environments
* @returns {Array}
*/
@@ -63,6 +73,7 @@ export default class EnvironmentsStore {
filtered = Object.assign(filtered, env);
}
+ filtered = setDeployBoard(oldEnvironmentState, filtered);
return filtered;
});
@@ -71,6 +82,20 @@ export default class EnvironmentsStore {
return filteredEnvironments;
}
+ /**
+ * Stores the pagination information needed to render the pagination for the
+ * table.
+ *
+ * Normalizes the headers to uppercase since they can be provided either
+ * in uppercase or lowercase.
+ *
+ * Parses to an integer the normalized ones needed for the pagination component.
+ *
+ * Stores the normalized and parsed information.
+ *
+ * @param {Object} pagination = {}
+ * @return {Object}
+ */
setPagination(pagination = {}) {
const normalizedHeaders = normalizeHeaders(pagination);
const paginationInformation = parseIntPagination(normalizedHeaders);
diff --git a/app/assets/javascripts/environments/stores/helpers.js b/app/assets/javascripts/environments/stores/helpers.js
new file mode 100644
index 00000000000..8eba6c00601
--- /dev/null
+++ b/app/assets/javascripts/environments/stores/helpers.js
@@ -0,0 +1,8 @@
+/**
+ * Deploy boards are EE only.
+ *
+ * @param {Object} environment
+ * @returns {Object}
+ */
+// eslint-disable-next-line import/prefer-default-export
+export const setDeployBoard = (oldEnvironmentState, environment) => environment;
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 6981afe1ead..43ae54133af 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -48,7 +48,7 @@ export default {
}
},
methods: {
- ...mapActions(['startPolling']),
+ ...mapActions(['startPolling', 'restartPolling']),
},
};
</script>
@@ -56,19 +56,17 @@ export default {
<template>
<div>
<div v-if="errorTrackingEnabled">
- <div v-if="loading" class="py-3"><gl-loading-icon :size="3" /></div>
+ <div v-if="loading" class="py-3">
+ <gl-loading-icon :size="3" />
+ </div>
<div v-else>
<div class="d-flex justify-content-end">
- <gl-button class="my-3 ml-auto" variant="primary" :href="externalUrl" target="_blank"
- >View in Sentry <icon name="external-link" />
+ <gl-button class="my-3 ml-auto" variant="primary" :href="externalUrl" target="_blank">
+ {{ __('View in Sentry') }}
+ <icon name="external-link" />
</gl-button>
</div>
- <gl-table
- :items="errors"
- :fields="$options.fields"
- :show-empty="true"
- :empty-text="__('No errors to display')"
- >
+ <gl-table :items="errors" :fields="$options.fields" :show-empty="true">
<template slot="HEAD_events" slot-scope="data">
<div class="text-right">{{ data.label }}</div>
</template>
@@ -102,6 +100,14 @@ export default {
<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>
</div>
diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/actions.js
index 11aec312368..1e754a4f54f 100644
--- a/app/assets/javascripts/error_tracking/store/actions.js
+++ b/app/assets/javascripts/error_tracking/store/actions.js
@@ -6,7 +6,7 @@ import { __, sprintf } from '~/locale';
let eTagPoll;
-export function startPolling({ commit }, endpoint) {
+export function startPolling({ commit, dispatch }, endpoint) {
eTagPoll = new Poll({
resource: Service,
method: 'getErrorList',
@@ -18,8 +18,9 @@ export function startPolling({ commit }, endpoint) {
commit(types.SET_ERRORS, data.errors);
commit(types.SET_EXTERNAL_URL, data.external_url);
commit(types.SET_LOADING, false);
+ dispatch('stopPolling');
},
- errorCallback: response => {
+ errorCallback: ({ response }) => {
let errorMessage = '';
if (response && response.data && response.data.message) {
errorMessage = response.data.message;
@@ -36,4 +37,16 @@ export function startPolling({ commit }, endpoint) {
eTagPoll.makeRequest();
}
+export const stopPolling = () => {
+ if (eTagPoll) eTagPoll.stop();
+};
+
+export function restartPolling({ commit }) {
+ commit(types.SET_ERRORS, []);
+ commit(types.SET_EXTERNAL_URL, '');
+ commit(types.SET_LOADING, true);
+
+ if (eTagPoll) eTagPoll.restart();
+}
+
export default () => {};
diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue
new file mode 100644
index 00000000000..50eb3e63b7c
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/components/app.vue
@@ -0,0 +1,129 @@
+<script>
+import { mapActions, mapGetters, mapState } from 'vuex';
+import { GlButton } from '@gitlab/ui';
+import ProjectDropdown from './project_dropdown.vue';
+import ErrorTrackingForm from './error_tracking_form.vue';
+
+export default {
+ components: { ProjectDropdown, ErrorTrackingForm, GlButton },
+ props: {
+ initialApiHost: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ initialEnabled: {
+ type: String,
+ required: true,
+ },
+ initialProject: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ initialToken: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ listProjectsEndpoint: {
+ type: String,
+ required: true,
+ },
+ operationsSettingsEndpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapGetters([
+ 'dropdownLabel',
+ 'hasProjects',
+ 'invalidProjectLabel',
+ 'isProjectInvalid',
+ 'projectSelectionLabel',
+ ]),
+ ...mapState([
+ 'apiHost',
+ 'connectError',
+ 'connectSuccessful',
+ 'enabled',
+ 'projects',
+ 'selectedProject',
+ 'settingsLoading',
+ 'token',
+ ]),
+ },
+ created() {
+ this.setInitialState({
+ apiHost: this.initialApiHost,
+ enabled: this.initialEnabled,
+ project: this.initialProject,
+ token: this.initialToken,
+ listProjectsEndpoint: this.listProjectsEndpoint,
+ operationsSettingsEndpoint: this.operationsSettingsEndpoint,
+ });
+ },
+ methods: {
+ ...mapActions([
+ 'fetchProjects',
+ 'setInitialState',
+ 'updateApiHost',
+ 'updateEnabled',
+ 'updateSelectedProject',
+ 'updateSettings',
+ 'updateToken',
+ ]),
+ handleSubmit() {
+ this.updateSettings();
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="form-check form-group">
+ <input
+ id="error-tracking-enabled"
+ :checked="enabled"
+ class="form-check-input"
+ type="checkbox"
+ @change="updateEnabled($event.target.checked)"
+ />
+ <label class="form-check-label" for="error-tracking-enabled">{{
+ s__('ErrorTracking|Active')
+ }}</label>
+ </div>
+ <error-tracking-form
+ :api-host="apiHost"
+ :connect-error="connectError"
+ :connect-successful="connectSuccessful"
+ :token="token"
+ @handle-connect="fetchProjects"
+ @update-api-host="updateApiHost"
+ @update-token="updateToken"
+ />
+ <div class="form-group">
+ <project-dropdown
+ :has-projects="hasProjects"
+ :invalid-project-label="invalidProjectLabel"
+ :is-project-invalid="isProjectInvalid"
+ :dropdown-label="dropdownLabel"
+ :project-selection-label="projectSelectionLabel"
+ :projects="projects"
+ :selected-project="selectedProject"
+ :token="token"
+ @select-project="updateSelectedProject"
+ />
+ </div>
+ <gl-button
+ :disabled="settingsLoading"
+ class="js-error-tracking-button"
+ variant="success"
+ @click="handleSubmit"
+ >
+ {{ __('Save changes') }}
+ </gl-button>
+ </div>
+</template>
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
new file mode 100644
index 00000000000..060d8e25227
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
@@ -0,0 +1,91 @@
+<script>
+import { GlButton, GlFormInput } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: { GlButton, GlFormInput, Icon },
+ props: {
+ apiHost: {
+ type: String,
+ required: true,
+ },
+ connectError: {
+ type: Boolean,
+ required: true,
+ },
+ connectSuccessful: {
+ type: Boolean,
+ required: true,
+ },
+ token: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ tokenInputState() {
+ return this.connectError ? false : null;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="form-group">
+ <label class="label-bold" for="error-tracking-api-host">{{ __('Sentry API URL') }}</label>
+ <div class="row">
+ <div class="col-8 col-md-9 gl-pr-0">
+ <gl-form-input
+ id="error-tracking-api-host"
+ :value="apiHost"
+ placeholder="https://mysentryserver.com"
+ @input="$emit('update-api-host', $event)"
+ />
+ </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">{{
+ s__('ErrorTracking|Auth Token')
+ }}</label>
+ <div class="row">
+ <div class="col-8 col-md-9 gl-pr-0">
+ <gl-form-input
+ id="error-tracking-token"
+ :value="token"
+ :state="tokenInputState"
+ @input="$emit('update-token', $event)"
+ />
+ </div>
+ <div class="col-4 col-md-3 gl-pl-0">
+ <gl-button
+ class="js-error-tracking-connect prepend-left-5"
+ @click="$emit('handle-connect')"
+ >
+ {{ __('Connect') }}
+ </gl-button>
+ <icon
+ v-show="connectSuccessful"
+ class="js-error-tracking-connect-success prepend-left-5 text-success align-middle"
+ :aria-label="__('Projects Successfully Retrieved')"
+ name="check-circle"
+ />
+ </div>
+ </div>
+ <p v-if="connectError" class="gl-field-error">
+ {{ s__('ErrorTracking|Connection has failed. Re-check Auth Token and try again.') }}
+ </p>
+ <p v-else class="form-text text-muted">
+ {{
+ s__(
+ "ErrorTracking|After adding your Auth Token, use the 'Connect' button to load projects",
+ )
+ }}
+ </p>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue
new file mode 100644
index 00000000000..82df02afafd
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue
@@ -0,0 +1,82 @@
+<script>
+import { GlDropdown, GlDropdownHeader, GlDropdownItem } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+import { getDisplayName } from '../utils';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownHeader,
+ GlDropdownItem,
+ Icon,
+ },
+ props: {
+ dropdownLabel: {
+ type: String,
+ required: true,
+ },
+ hasProjects: {
+ type: Boolean,
+ required: true,
+ },
+ invalidProjectLabel: {
+ type: String,
+ required: true,
+ },
+ isProjectInvalid: {
+ type: Boolean,
+ required: true,
+ },
+ projects: {
+ type: Array,
+ required: true,
+ },
+ selectedProject: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ projectSelectionLabel: {
+ type: String,
+ required: true,
+ },
+ token: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ getDisplayName,
+ },
+};
+</script>
+
+<template>
+ <div :class="{ 'gl-show-field-errors': isProjectInvalid }">
+ <label class="label-bold" for="project-dropdown">{{ __('Project') }}</label>
+ <div class="row">
+ <gl-dropdown
+ id="project-dropdown"
+ class="col-8 col-md-9 gl-pr-0"
+ :disabled="!hasProjects"
+ menu-class="w-100 mw-100"
+ toggle-class="dropdown-menu-toggle w-100 gl-field-error-outline"
+ :text="dropdownLabel"
+ >
+ <gl-dropdown-item
+ v-for="project in projects"
+ :key="`${project.organizationSlug}.${project.slug}`"
+ class="w-100"
+ @click="$emit('select-project', project)"
+ >{{ getDisplayName(project) }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ </div>
+ <p v-if="isProjectInvalid" class="js-project-dropdown-error gl-field-error">
+ {{ invalidProjectLabel }}
+ </p>
+ <p v-else-if="!hasProjects" class="js-project-dropdown-label form-text text-muted">
+ {{ projectSelectionLabel }}
+ </p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/error_tracking_settings/index.js b/app/assets/javascripts/error_tracking_settings/index.js
new file mode 100644
index 00000000000..ce315963723
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/index.js
@@ -0,0 +1,27 @@
+import Vue from 'vue';
+import ErrorTrackingSettings from './components/app.vue';
+import createStore from './store';
+
+export default () => {
+ const formContainerEl = document.querySelector('.js-error-tracking-form');
+ const {
+ dataset: { apiHost, enabled, project, token, listProjectsEndpoint, operationsSettingsEndpoint },
+ } = formContainerEl;
+
+ return new Vue({
+ el: formContainerEl,
+ store: createStore(),
+ render(createElement) {
+ return createElement(ErrorTrackingSettings, {
+ props: {
+ initialApiHost: apiHost,
+ initialEnabled: enabled,
+ initialProject: project,
+ initialToken: token,
+ listProjectsEndpoint,
+ operationsSettingsEndpoint,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/error_tracking_settings/store/actions.js b/app/assets/javascripts/error_tracking_settings/store/actions.js
new file mode 100644
index 00000000000..95105797807
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/store/actions.js
@@ -0,0 +1,91 @@
+import { __ } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+import { refreshCurrentPage } from '~/lib/utils/url_utility';
+import createFlash from '~/flash';
+import { transformFrontendSettings } from '../utils';
+import * as types from './mutation_types';
+
+export const requestProjects = ({ commit }) => {
+ commit(types.RESET_CONNECT);
+};
+
+export const receiveProjectsSuccess = ({ commit }, projects) => {
+ commit(types.UPDATE_CONNECT_SUCCESS);
+ commit(types.RECEIVE_PROJECTS, projects);
+};
+
+export const receiveProjectsError = ({ commit }) => {
+ commit(types.UPDATE_CONNECT_ERROR);
+ commit(types.CLEAR_PROJECTS);
+};
+
+export const fetchProjects = ({ dispatch, state }) => {
+ dispatch('requestProjects');
+ return axios
+ .post(state.listProjectsEndpoint, {
+ error_tracking_setting: {
+ api_host: state.apiHost,
+ token: state.token,
+ },
+ })
+ .then(({ data: { projects } }) => {
+ dispatch('receiveProjectsSuccess', projects);
+ })
+ .catch(() => {
+ dispatch('receiveProjectsError');
+ });
+};
+
+export const requestSettings = ({ commit }) => {
+ commit(types.UPDATE_SETTINGS_LOADING, true);
+};
+
+export const receiveSettingsError = ({ commit }, { response = {} }) => {
+ const message = response.data && response.data.message ? response.data.message : '';
+
+ createFlash(`${__('There was an error saving your changes.')} ${message}`, 'alert');
+ commit(types.UPDATE_SETTINGS_LOADING, false);
+};
+
+export const updateSettings = ({ dispatch, state }) => {
+ dispatch('requestSettings');
+ return axios
+ .patch(state.operationsSettingsEndpoint, {
+ project: {
+ error_tracking_setting_attributes: {
+ ...transformFrontendSettings(state),
+ },
+ },
+ })
+ .then(() => {
+ refreshCurrentPage();
+ })
+ .catch(err => {
+ dispatch('receiveSettingsError', err);
+ });
+};
+
+export const updateApiHost = ({ commit }, apiHost) => {
+ commit(types.UPDATE_API_HOST, apiHost);
+ commit(types.RESET_CONNECT);
+};
+
+export const updateEnabled = ({ commit }, enabled) => {
+ commit(types.UPDATE_ENABLED, enabled);
+};
+
+export const updateToken = ({ commit }, token) => {
+ commit(types.UPDATE_TOKEN, token);
+ commit(types.RESET_CONNECT);
+};
+
+export const updateSelectedProject = ({ commit }, selectedProject) => {
+ commit(types.UPDATE_SELECTED_PROJECT, selectedProject);
+};
+
+export const setInitialState = ({ commit }, data) => {
+ commit(types.SET_INITIAL_STATE, data);
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/error_tracking_settings/store/getters.js b/app/assets/javascripts/error_tracking_settings/store/getters.js
new file mode 100644
index 00000000000..d77e5f15469
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/store/getters.js
@@ -0,0 +1,44 @@
+import _ from 'underscore';
+import { __, s__, sprintf } from '~/locale';
+import { getDisplayName } from '../utils';
+
+export const hasProjects = state => Boolean(state.projects) && state.projects.length > 0;
+
+export const isProjectInvalid = (state, getters) =>
+ Boolean(state.selectedProject) &&
+ getters.hasProjects &&
+ !state.projects.some(project => _.isMatch(state.selectedProject, project));
+
+export const dropdownLabel = (state, getters) => {
+ if (state.selectedProject !== null) {
+ return getDisplayName(state.selectedProject);
+ }
+ if (!getters.hasProjects) {
+ return s__('ErrorTracking|No projects available');
+ }
+ return s__('ErrorTracking|Select project');
+};
+
+export const invalidProjectLabel = state => {
+ if (state.selectedProject) {
+ return sprintf(
+ __('Project "%{name}" is no longer available. Select another project to continue.'),
+ {
+ name: state.selectedProject.name,
+ },
+ );
+ }
+ return '';
+};
+
+export const projectSelectionLabel = state => {
+ if (state.token) {
+ return s__(
+ "ErrorTracking|Click 'Connect' to re-establish the connection to Sentry and activate the dropdown.",
+ );
+ }
+ return s__('ErrorTracking|To enable project selection, enter a valid Auth Token');
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/error_tracking_settings/store/index.js b/app/assets/javascripts/error_tracking_settings/store/index.js
new file mode 100644
index 00000000000..560f265a2ea
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/store/index.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import createState from './state';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+export default () =>
+ new Vuex.Store({
+ state: createState(),
+ actions,
+ getters,
+ mutations,
+ });
diff --git a/app/assets/javascripts/error_tracking_settings/store/mutation_types.js b/app/assets/javascripts/error_tracking_settings/store/mutation_types.js
new file mode 100644
index 00000000000..b4f8a237947
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/store/mutation_types.js
@@ -0,0 +1,11 @@
+export const CLEAR_PROJECTS = 'CLEAR_PROJECTS';
+export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
+export const RECEIVE_PROJECTS = 'RECEIVE_PROJECTS';
+export const RESET_CONNECT = 'RESET_CONNECT';
+export const UPDATE_API_HOST = 'UPDATE_API_HOST';
+export const UPDATE_CONNECT_ERROR = 'UPDATE_CONNECT_ERROR';
+export const UPDATE_CONNECT_SUCCESS = 'UPDATE_CONNECT_SUCCESS';
+export const UPDATE_ENABLED = 'UPDATE_ENABLED';
+export const UPDATE_SELECTED_PROJECT = 'UPDATE_SELECTED_PROJECT';
+export const UPDATE_SETTINGS_LOADING = 'UPDATE_SETTINGS_LOADING';
+export const UPDATE_TOKEN = 'UPDATE_TOKEN';
diff --git a/app/assets/javascripts/error_tracking_settings/store/mutations.js b/app/assets/javascripts/error_tracking_settings/store/mutations.js
new file mode 100644
index 00000000000..4089d1ee94e
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/store/mutations.js
@@ -0,0 +1,61 @@
+import _ from 'underscore';
+import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
+import * as types from './mutation_types';
+import { projectKeys } from '../utils';
+
+export default {
+ [types.CLEAR_PROJECTS](state) {
+ state.projects = [];
+ },
+ [types.RECEIVE_PROJECTS](state, projects) {
+ state.projects = projects
+ .map(convertObjectPropsToCamelCase)
+ // The `pick` strips out extra properties returned from Sentry.
+ // Such properties could be problematic later, e.g. when checking whether `projects` contains `selectedProject`
+ .map(project => _.pick(project, projectKeys));
+ },
+ [types.RESET_CONNECT](state) {
+ state.connectSuccessful = false;
+ state.connectError = false;
+ },
+ [types.SET_INITIAL_STATE](
+ state,
+ { apiHost, enabled, project, token, listProjectsEndpoint, operationsSettingsEndpoint },
+ ) {
+ state.enabled = parseBoolean(enabled);
+ state.apiHost = apiHost;
+ state.token = token;
+ state.listProjectsEndpoint = listProjectsEndpoint;
+ state.operationsSettingsEndpoint = operationsSettingsEndpoint;
+
+ if (project) {
+ state.selectedProject = _.pick(
+ convertObjectPropsToCamelCase(JSON.parse(project)),
+ projectKeys,
+ );
+ }
+ },
+ [types.UPDATE_API_HOST](state, apiHost) {
+ state.apiHost = apiHost;
+ },
+ [types.UPDATE_ENABLED](state, enabled) {
+ state.enabled = enabled;
+ },
+ [types.UPDATE_TOKEN](state, token) {
+ state.token = token;
+ },
+ [types.UPDATE_SELECTED_PROJECT](state, selectedProject) {
+ state.selectedProject = selectedProject;
+ },
+ [types.UPDATE_SETTINGS_LOADING](state, settingsLoading) {
+ state.settingsLoading = settingsLoading;
+ },
+ [types.UPDATE_CONNECT_SUCCESS](state) {
+ state.connectSuccessful = true;
+ state.connectError = false;
+ },
+ [types.UPDATE_CONNECT_ERROR](state) {
+ state.connectSuccessful = false;
+ state.connectError = true;
+ },
+};
diff --git a/app/assets/javascripts/error_tracking_settings/store/state.js b/app/assets/javascripts/error_tracking_settings/store/state.js
new file mode 100644
index 00000000000..98219d33f4d
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/store/state.js
@@ -0,0 +1,12 @@
+export default () => ({
+ apiHost: '',
+ enabled: false,
+ token: '',
+ projects: [],
+ selectedProject: null,
+ settingsLoading: false,
+ connectSuccessful: false,
+ connectError: false,
+ listProjectsEndpoint: '',
+ operationsSettingsEndpoint: '',
+});
diff --git a/app/assets/javascripts/error_tracking_settings/utils.js b/app/assets/javascripts/error_tracking_settings/utils.js
new file mode 100644
index 00000000000..6613e04ee0e
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/utils.js
@@ -0,0 +1,18 @@
+export const projectKeys = ['name', 'organizationName', 'organizationSlug', 'slug'];
+
+export const transformFrontendSettings = ({ apiHost, enabled, token, selectedProject }) => {
+ const project = selectedProject
+ ? {
+ slug: selectedProject.slug,
+ name: selectedProject.name,
+ organization_name: selectedProject.organizationName,
+ organization_slug: selectedProject.organizationSlug,
+ }
+ : null;
+
+ return { api_host: apiHost || null, enabled, token: token || null, project };
+};
+
+export const getDisplayName = project => `${project.organizationName} | ${project.name}`;
+
+export default () => {};
diff --git a/app/assets/javascripts/event_tracking/notes.js b/app/assets/javascripts/event_tracking/notes.js
new file mode 100644
index 00000000000..2d1ec238274
--- /dev/null
+++ b/app/assets/javascripts/event_tracking/notes.js
@@ -0,0 +1 @@
+export default () => {};
diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
new file mode 100644
index 00000000000..e020628a473
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
@@ -0,0 +1,30 @@
+import { __ } from '~/locale';
+
+export default IssuableTokenKeys => {
+ const wipToken = {
+ key: 'wip',
+ type: 'string',
+ param: '',
+ symbol: '',
+ icon: 'admin',
+ tag: __('Yes or No'),
+ lowercaseValueOnSubmit: true,
+ uppercaseTokenName: true,
+ capitalizeTokenValue: true,
+ };
+
+ IssuableTokenKeys.tokenKeys.push(wipToken);
+ IssuableTokenKeys.tokenKeysWithAlternative.push(wipToken);
+
+ const targetBranchToken = {
+ key: 'target-branch',
+ type: 'string',
+ param: '',
+ symbol: '',
+ icon: 'arrow-right',
+ tag: 'branch',
+ };
+
+ IssuableTokenKeys.tokenKeys.push(targetBranchToken);
+ IssuableTokenKeys.tokenKeysWithAlternative.push(targetBranchToken);
+};
diff --git a/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js
index 934375023ba..691d165c585 100644
--- a/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js
@@ -17,6 +17,14 @@ const tokenKeys = [
icon: 'cube',
tag: 'type',
},
+ {
+ key: 'tag',
+ type: 'array',
+ param: 'name[]',
+ symbol: '~',
+ icon: 'tag',
+ tag: '~tag',
+ },
];
const AdminRunnersFilteredSearchTokenKeys = new FilteredSearchTokenKeys(tokenKeys);
diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
new file mode 100644
index 00000000000..be867a3838d
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
@@ -0,0 +1,164 @@
+import DropdownHint from './dropdown_hint';
+import DropdownUser from './dropdown_user';
+import DropdownNonUser from './dropdown_non_user';
+import DropdownEmoji from './dropdown_emoji';
+import NullDropdown from './null_dropdown';
+import DropdownAjaxFilter from './dropdown_ajax_filter';
+import DropdownUtils from './dropdown_utils';
+import { mergeUrlParams } from '../lib/utils/url_utility';
+
+export default class AvailableDropdownMappings {
+ constructor(container, baseEndpoint, groupsOnly, includeAncestorGroups, includeDescendantGroups) {
+ this.container = container;
+ this.baseEndpoint = baseEndpoint;
+ this.groupsOnly = groupsOnly;
+ this.includeAncestorGroups = includeAncestorGroups;
+ this.includeDescendantGroups = includeDescendantGroups;
+ this.filteredSearchInput = this.container.querySelector('.filtered-search');
+ }
+
+ getAllowedMappings(supportedTokens) {
+ return this.buildMappings(supportedTokens, this.getMappings());
+ }
+
+ buildMappings(supportedTokens, availableMappings) {
+ const allowedMappings = {
+ hint: {
+ reference: null,
+ gl: DropdownHint,
+ element: this.container.querySelector('#js-dropdown-hint'),
+ },
+ };
+
+ supportedTokens.forEach(type => {
+ if (availableMappings[type]) {
+ allowedMappings[type] = availableMappings[type];
+ }
+ });
+
+ return allowedMappings;
+ }
+
+ getMappings() {
+ return {
+ author: {
+ reference: null,
+ gl: DropdownUser,
+ element: this.container.querySelector('#js-dropdown-author'),
+ },
+ assignee: {
+ reference: null,
+ gl: DropdownUser,
+ element: this.container.querySelector('#js-dropdown-assignee'),
+ },
+ milestone: {
+ reference: null,
+ gl: DropdownNonUser,
+ extraArguments: {
+ endpoint: this.getMilestoneEndpoint(),
+ symbol: '%',
+ },
+ element: this.container.querySelector('#js-dropdown-milestone'),
+ },
+ label: {
+ reference: null,
+ gl: DropdownNonUser,
+ extraArguments: {
+ endpoint: this.getLabelsEndpoint(),
+ symbol: '~',
+ preprocessing: DropdownUtils.duplicateLabelPreprocessing,
+ },
+ element: this.container.querySelector('#js-dropdown-label'),
+ },
+ 'my-reaction': {
+ reference: null,
+ gl: DropdownEmoji,
+ element: this.container.querySelector('#js-dropdown-my-reaction'),
+ },
+ wip: {
+ reference: null,
+ gl: DropdownNonUser,
+ element: this.container.querySelector('#js-dropdown-wip'),
+ },
+ confidential: {
+ reference: null,
+ gl: DropdownNonUser,
+ element: this.container.querySelector('#js-dropdown-confidential'),
+ },
+ status: {
+ reference: null,
+ gl: NullDropdown,
+ element: this.container.querySelector('#js-dropdown-admin-runner-status'),
+ },
+ type: {
+ reference: null,
+ gl: NullDropdown,
+ element: this.container.querySelector('#js-dropdown-admin-runner-type'),
+ },
+ tag: {
+ reference: null,
+ gl: DropdownAjaxFilter,
+ extraArguments: {
+ endpoint: this.getRunnerTagsEndpoint(),
+ symbol: '~',
+ },
+ element: this.container.querySelector('#js-dropdown-runner-tag'),
+ },
+ 'target-branch': {
+ reference: null,
+ gl: DropdownNonUser,
+ extraArguments: {
+ endpoint: this.getMergeRequestTargetBranchesEndpoint(),
+ symbol: '',
+ },
+ element: this.container.querySelector('#js-dropdown-target-branch'),
+ },
+ };
+ }
+
+ getMilestoneEndpoint() {
+ return `${this.baseEndpoint}/milestones.json`;
+ }
+
+ getLabelsEndpoint() {
+ let endpoint = `${this.baseEndpoint}/labels.json?`;
+
+ if (this.groupsOnly) {
+ endpoint = `${endpoint}only_group_labels=true&`;
+ }
+
+ if (this.includeAncestorGroups) {
+ endpoint = `${endpoint}include_ancestor_groups=true&`;
+ }
+
+ if (this.includeDescendantGroups) {
+ endpoint = `${endpoint}include_descendant_groups=true`;
+ }
+
+ return endpoint;
+ }
+
+ getRunnerTagsEndpoint() {
+ return `${this.baseEndpoint}/admin/runners/tag_list.json`;
+ }
+
+ getMergeRequestTargetBranchesEndpoint() {
+ const endpoint = `${gon.relative_url_root ||
+ ''}/autocomplete/merge_request_target_branches.json`;
+
+ const params = {
+ group_id: this.getGroupId(),
+ project_id: this.getProjectId(),
+ };
+
+ return mergeUrlParams(params, endpoint);
+ }
+
+ getGroupId() {
+ return this.filteredSearchInput.getAttribute('data-group-id') || '';
+ }
+
+ getProjectId() {
+ return this.filteredSearchInput.getAttribute('data-project-id') || '';
+ }
+}
diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
new file mode 100644
index 00000000000..b27bb63c220
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
@@ -0,0 +1,68 @@
+import createFlash from '../flash';
+import AjaxFilter from '../droplab/plugins/ajax_filter';
+import FilteredSearchDropdown from './filtered_search_dropdown';
+import DropdownUtils from './dropdown_utils';
+import FilteredSearchTokenizer from './filtered_search_tokenizer';
+import { __ } from '~/locale';
+
+export default class DropdownAjaxFilter extends FilteredSearchDropdown {
+ constructor(options = {}) {
+ const { tokenKeys, endpoint, symbol } = options;
+
+ super(options);
+
+ this.tokenKeys = tokenKeys;
+ this.endpoint = endpoint;
+ this.symbol = symbol;
+
+ this.config = {
+ AjaxFilter: this.ajaxFilterConfig(),
+ };
+ }
+
+ ajaxFilterConfig() {
+ return {
+ endpoint: `${gon.relative_url_root || ''}${this.endpoint}`,
+ searchKey: 'search',
+ searchValueFunction: this.getSearchInput.bind(this),
+ loadingTemplate: this.loadingTemplate,
+ onError() {
+ createFlash(__('An error occurred fetching the dropdown data.'));
+ },
+ };
+ }
+
+ itemClicked(e) {
+ super.itemClicked(e, selected =>
+ selected.querySelector('.dropdown-light-content').innerText.trim(),
+ );
+ }
+
+ renderContent(forceShowList = false) {
+ this.droplab.changeHookList(this.hookId, this.dropdown, [AjaxFilter], this.config);
+ super.renderContent(forceShowList);
+ }
+
+ getSearchInput() {
+ const query = DropdownUtils.getSearchInput(this.input);
+ const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get());
+
+ let value = lastToken || '';
+
+ if (value[0] === this.symbol) {
+ value = value.slice(1);
+ }
+
+ // Removes the first character if it is a quotation so that we can search
+ // with multiple words
+ if (value[0] === '"' || value[0] === "'") {
+ value = value.slice(1);
+ }
+
+ return value;
+ }
+
+ init() {
+ this.droplab.addHook(this.input, this.dropdown, [AjaxFilter], this.config).init();
+ }
+}
diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js
index d9a4d06b549..dad188f6f98 100644
--- a/app/assets/javascripts/filtered_search/dropdown_emoji.js
+++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js
@@ -3,6 +3,7 @@ import Ajax from '../droplab/plugins/ajax';
import Filter from '../droplab/plugins/filter';
import FilteredSearchDropdown from './filtered_search_dropdown';
import DropdownUtils from './dropdown_utils';
+import { __ } from '~/locale';
export default class DropdownEmoji extends FilteredSearchDropdown {
constructor(options = {}) {
@@ -14,7 +15,7 @@ export default class DropdownEmoji extends FilteredSearchDropdown {
loadingTemplate: this.loadingTemplate,
onError() {
/* eslint-disable no-new */
- new Flash('An error occurred fetching the dropdown data.');
+ new Flash(__('An error occurred fetching the dropdown data.'));
/* eslint-enable no-new */
},
},
diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js
index 0264f934914..a2312de289d 100644
--- a/app/assets/javascripts/filtered_search/dropdown_non_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js
@@ -3,6 +3,7 @@ import Ajax from '../droplab/plugins/ajax';
import Filter from '../droplab/plugins/filter';
import FilteredSearchDropdown from './filtered_search_dropdown';
import DropdownUtils from './dropdown_utils';
+import { __ } from '~/locale';
export default class DropdownNonUser extends FilteredSearchDropdown {
constructor(options = {}) {
@@ -17,7 +18,7 @@ export default class DropdownNonUser extends FilteredSearchDropdown {
preprocessing,
onError() {
/* eslint-disable no-new */
- new Flash('An error occurred fetching the dropdown data.');
+ new Flash(__('An error occurred fetching the dropdown data.'));
/* eslint-enable no-new */
},
},
diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js
index d5027590bb7..a65c0012b4d 100644
--- a/app/assets/javascripts/filtered_search/dropdown_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_user.js
@@ -1,54 +1,35 @@
-import Flash from '../flash';
-import AjaxFilter from '../droplab/plugins/ajax_filter';
-import FilteredSearchDropdown from './filtered_search_dropdown';
import { addClassIfElementExists } from '../lib/utils/dom_utils';
-import DropdownUtils from './dropdown_utils';
-import FilteredSearchTokenizer from './filtered_search_tokenizer';
+import DropdownAjaxFilter from './dropdown_ajax_filter';
-export default class DropdownUser extends FilteredSearchDropdown {
+export default class DropdownUser extends DropdownAjaxFilter {
constructor(options = {}) {
- const { tokenKeys } = options;
- super(options);
- this.config = {
- AjaxFilter: {
- endpoint: `${gon.relative_url_root || ''}/autocomplete/users.json`,
- searchKey: 'search',
- params: {
- active: true,
- group_id: this.getGroupId(),
- project_id: this.getProjectId(),
- current_user: true,
- },
- searchValueFunction: this.getSearchInput.bind(this),
- loadingTemplate: this.loadingTemplate,
- onLoadingFinished: () => {
- this.hideCurrentUser();
- },
- onError() {
- /* eslint-disable no-new */
- new Flash('An error occurred fetching the dropdown data.');
- /* eslint-enable no-new */
- },
+ super({
+ ...options,
+ endpoint: '/autocomplete/users.json',
+ symbol: '@',
+ });
+ }
+
+ ajaxFilterConfig() {
+ return {
+ ...super.ajaxFilterConfig(),
+ params: {
+ active: true,
+ group_id: this.getGroupId(),
+ project_id: this.getProjectId(),
+ current_user: true,
+ ...this.projectOrGroupId(),
+ },
+ onLoadingFinished: () => {
+ this.hideCurrentUser();
},
};
- this.tokenKeys = tokenKeys;
}
hideCurrentUser() {
addClassIfElementExists(this.dropdown.querySelector('.js-current-user'), 'hidden');
}
- itemClicked(e) {
- super.itemClicked(e, selected =>
- selected.querySelector('.dropdown-light-content').innerText.trim(),
- );
- }
-
- renderContent(forceShowList = false) {
- this.droplab.changeHookList(this.hookId, this.dropdown, [AjaxFilter], this.config);
- super.renderContent(forceShowList);
- }
-
getGroupId() {
return this.input.getAttribute('data-group-id');
}
@@ -57,26 +38,16 @@ export default class DropdownUser extends FilteredSearchDropdown {
return this.input.getAttribute('data-project-id');
}
- getSearchInput() {
- const query = DropdownUtils.getSearchInput(this.input);
- const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get());
-
- let value = lastToken || '';
-
- if (value[0] === '@') {
- value = value.slice(1);
+ projectOrGroupId() {
+ const projectId = this.getProjectId();
+ const groupId = this.getGroupId();
+ if (groupId) {
+ return {
+ group_id: groupId,
+ };
}
-
- // Removes the first character if it is a quotation so that we can search
- // with multiple words
- if (value[0] === '"' || value[0] === "'") {
- value = value.slice(1);
- }
-
- return value;
- }
-
- init() {
- this.droplab.addHook(this.input, this.dropdown, [AjaxFilter], this.config).init();
+ return {
+ project_id: projectId,
+ };
}
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
index 4d05f46ed17..cb0a84b490b 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -1,13 +1,9 @@
+import AvailableDropdownMappings from 'ee_else_ce/filtered_search/available_dropdown_mappings';
import _ from 'underscore';
import DropLab from '~/droplab/drop_lab';
import FilteredSearchContainer from './container';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
import DropdownUtils from './dropdown_utils';
-import DropdownHint from './dropdown_hint';
-import DropdownEmoji from './dropdown_emoji';
-import DropdownNonUser from './dropdown_non_user';
-import DropdownUser from './dropdown_user';
-import NullDropdown from './null_dropdown';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
export default class FilteredSearchDropdownManager {
@@ -49,101 +45,15 @@ export default class FilteredSearchDropdownManager {
setupMapping() {
const supportedTokens = this.filteredSearchTokenKeys.getKeys();
- const allowedMappings = {
- hint: {
- reference: null,
- gl: DropdownHint,
- element: this.container.querySelector('#js-dropdown-hint'),
- },
- };
- const availableMappings = {
- author: {
- reference: null,
- gl: DropdownUser,
- element: this.container.querySelector('#js-dropdown-author'),
- },
- assignee: {
- reference: null,
- gl: DropdownUser,
- element: this.container.querySelector('#js-dropdown-assignee'),
- },
- milestone: {
- reference: null,
- gl: DropdownNonUser,
- extraArguments: {
- endpoint: this.getMilestoneEndpoint(),
- symbol: '%',
- },
- element: this.container.querySelector('#js-dropdown-milestone'),
- },
- label: {
- reference: null,
- gl: DropdownNonUser,
- extraArguments: {
- endpoint: this.getLabelsEndpoint(),
- symbol: '~',
- preprocessing: DropdownUtils.duplicateLabelPreprocessing,
- },
- element: this.container.querySelector('#js-dropdown-label'),
- },
- 'my-reaction': {
- reference: null,
- gl: DropdownEmoji,
- element: this.container.querySelector('#js-dropdown-my-reaction'),
- },
- wip: {
- reference: null,
- gl: DropdownNonUser,
- element: this.container.querySelector('#js-dropdown-wip'),
- },
- confidential: {
- reference: null,
- gl: DropdownNonUser,
- element: this.container.querySelector('#js-dropdown-confidential'),
- },
- status: {
- reference: null,
- gl: NullDropdown,
- element: this.container.querySelector('#js-dropdown-admin-runner-status'),
- },
- type: {
- reference: null,
- gl: NullDropdown,
- element: this.container.querySelector('#js-dropdown-admin-runner-type'),
- },
- };
-
- supportedTokens.forEach(type => {
- if (availableMappings[type]) {
- allowedMappings[type] = availableMappings[type];
- }
- });
-
- this.mapping = allowedMappings;
- }
-
- getMilestoneEndpoint() {
- const endpoint = `${this.baseEndpoint}/milestones.json`;
-
- return endpoint;
- }
-
- getLabelsEndpoint() {
- let endpoint = `${this.baseEndpoint}/labels.json?`;
-
- if (this.groupsOnly) {
- endpoint = `${endpoint}only_group_labels=true&`;
- }
-
- if (this.includeAncestorGroups) {
- endpoint = `${endpoint}include_ancestor_groups=true&`;
- }
-
- if (this.includeDescendantGroups) {
- endpoint = `${endpoint}include_descendant_groups=true`;
- }
+ const availableMappings = new AvailableDropdownMappings(
+ this.container,
+ this.baseEndpoint,
+ this.groupsOnly,
+ this.includeAncestorGroups,
+ this.includeDescendantGroups,
+ );
- return endpoint;
+ this.mapping = availableMappings.getAllowedMappings(supportedTokens);
}
static addWordToInput(tokenName, tokenValue = '', clicked = false, options = {}) {
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 33c82778c79..78fbb3696cc 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -1,6 +1,7 @@
import _ from 'underscore';
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';
@@ -13,6 +14,7 @@ import FilteredSearchTokenizer from './filtered_search_tokenizer';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
import DropdownUtils from './dropdown_utils';
+import { __ } from '~/locale';
export default class FilteredSearchManager {
constructor({
@@ -36,10 +38,11 @@ export default class FilteredSearchManager {
this.tokensContainer = this.container.querySelector('.tokens-container');
this.filteredSearchTokenKeys = filteredSearchTokenKeys;
this.stateFiltersSelector = stateFiltersSelector;
- this.recentsStorageKeyNames = {
- issues: 'issue-recent-searches',
- merge_requests: 'merge-request-recent-searches',
- };
+
+ const { multipleAssignees } = this.filteredSearchInput.dataset;
+ if (multipleAssignees && this.filteredSearchTokenKeys.enableMultipleAssignees) {
+ this.filteredSearchTokenKeys.enableMultipleAssignees();
+ }
this.recentSearchesStore = new RecentSearchesStore({
isLocalStorageAvailable: RecentSearchesService.isAvailable(),
@@ -51,7 +54,7 @@ export default class FilteredSearchManager {
const fullPath = this.searchHistoryDropdownElement
? this.searchHistoryDropdownElement.dataset.fullPath
: 'project';
- const recentSearchesKey = `${fullPath}-${this.recentsStorageKeyNames[this.page]}`;
+ const recentSearchesKey = `${fullPath}-${recentSearchesStorageKeys[this.page]}`;
this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
}
@@ -62,7 +65,7 @@ export default class FilteredSearchManager {
.catch(error => {
if (error.name === 'RecentSearchesServiceError') return undefined;
// eslint-disable-next-line no-new
- new Flash('An error occurred while parsing recent searches');
+ new Flash(__('An error occurred while parsing recent searches'));
// Gracefully fail to empty array
return [];
})
@@ -338,7 +341,7 @@ export default class FilteredSearchManager {
handleInputPlaceholder() {
const query = DropdownUtils.getSearchQuery();
- const placeholder = 'Search or filter results...';
+ const placeholder = __('Search or filter results...');
const currentPlaceholder = this.filteredSearchInput.placeholder;
if (query.length === 0 && currentPlaceholder !== placeholder) {
@@ -504,14 +507,7 @@ export default class FilteredSearchManager {
const match = this.filteredSearchTokenKeys.searchByKeyParam(keyParam);
if (match) {
- // Use lastIndexOf because the token key is allowed to contain underscore
- // e.g. 'my_reaction' is the token key of 'my_reaction_emoji'
- const lastIndexOf = keyParam.lastIndexOf('_');
- let sanitizedKey = lastIndexOf !== -1 ? keyParam.slice(0, lastIndexOf) : keyParam;
- // Replace underscore with hyphen in the sanitizedkey.
- // e.g. 'my_reaction' => 'my-reaction'
- sanitizedKey = sanitizedKey.replace('_', '-');
- const { symbol } = match;
+ const { key, symbol } = match;
let quotationsToUse = '';
if (sanitizedValue.indexOf(' ') !== -1) {
@@ -520,10 +516,10 @@ export default class FilteredSearchManager {
}
hasFilteredSearch = true;
- const canEdit = this.canEdit && this.canEdit(sanitizedKey, sanitizedValue);
+ const canEdit = this.canEdit && this.canEdit(key, sanitizedValue);
const { uppercaseTokenName, capitalizeTokenValue } = match;
FilteredSearchVisualTokens.addFilterVisualToken(
- sanitizedKey,
+ key,
`${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`,
{
canEdit,
diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
index 48534bdf815..0a9579bf491 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export default class FilteredSearchTokenKeys {
constructor(tokenKeys = [], alternativeTokenKeys = [], conditions = []) {
this.tokenKeys = tokenKeys;
@@ -79,7 +81,7 @@ export default class FilteredSearchTokenKeys {
param: '',
symbol: '',
icon: 'eye-slash',
- tag: 'Yes or No',
+ tag: __('Yes or No'),
lowercaseValueOnSubmit: true,
uppercaseTokenName: false,
capitalizeTokenValue: true,
@@ -88,21 +90,4 @@ export default class FilteredSearchTokenKeys {
this.tokenKeys.push(confidentialToken);
this.tokenKeysWithAlternative.push(confidentialToken);
}
-
- addExtraTokensForMergeRequests() {
- const wipToken = {
- key: 'wip',
- type: 'string',
- param: '',
- symbol: '',
- icon: 'admin',
- tag: 'Yes or No',
- lowercaseValueOnSubmit: true,
- uppercaseTokenName: true,
- capitalizeTokenValue: true,
- };
-
- this.tokenKeys.push(wipToken);
- this.tokenKeysWithAlternative.push(wipToken);
- }
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
index 5090b0bdc3c..315cd6f64da 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
@@ -1,10 +1,6 @@
-import _ from 'underscore';
-import AjaxCache from '~/lib/utils/ajax_cache';
+import VisualTokenValue from 'ee_else_ce/filtered_search/visual_token_value';
import { objectToQueryString } from '~/lib/utils/common_utils';
-import Flash from '../flash';
import FilteredSearchContainer from './container';
-import UsersCache from '../lib/utils/users_cache';
-import DropdownUtils from './dropdown_utils';
export default class FilteredSearchVisualTokens {
static getLastVisualTokenBeforeInput() {
@@ -20,21 +16,6 @@ export default class FilteredSearchVisualTokens {
};
}
- /**
- * Returns a computed API endpoint
- * and query string composed of values from endpointQueryParams
- * @param {String} endpoint
- * @param {String} endpointQueryParams
- */
- static getEndpointWithQueryParams(endpoint, endpointQueryParams) {
- if (!endpointQueryParams) {
- return endpoint;
- }
-
- const queryString = objectToQueryString(JSON.parse(endpointQueryParams));
- return `${endpoint}?${queryString}`;
- }
-
static unselectTokens() {
const otherTokens = FilteredSearchContainer.container.querySelectorAll(
'.js-visual-token .selectable.selected',
@@ -76,122 +57,33 @@ export default class FilteredSearchVisualTokens {
`;
}
- static setTokenStyle(tokenContainer, backgroundColor, textColor) {
- const token = tokenContainer;
-
- token.style.backgroundColor = backgroundColor;
- token.style.color = textColor;
-
- if (textColor === '#FFFFFF') {
- const removeToken = token.querySelector('.remove-token');
- removeToken.classList.add('inverted');
- }
-
- return token;
- }
-
- static updateLabelTokenColor(tokenValueContainer, tokenValue) {
- const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search');
- const { baseEndpoint } = filteredSearchInput.dataset;
- const labelsEndpoint = FilteredSearchVisualTokens.getEndpointWithQueryParams(
- `${baseEndpoint}/labels.json`,
- filteredSearchInput.dataset.endpointQueryParams,
- );
-
- return AjaxCache.retrieve(labelsEndpoint)
- .then(labels => {
- const matchingLabel = (labels || []).find(
- label => `~${DropdownUtils.getEscapedText(label.title)}` === tokenValue,
- );
-
- if (!matchingLabel) {
- return;
- }
-
- FilteredSearchVisualTokens.setTokenStyle(
- tokenValueContainer,
- matchingLabel.color,
- matchingLabel.text_color,
- );
- })
- .catch(() => new Flash('An error occurred while fetching label colors.'));
- }
-
- static updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) {
- const username = tokenValue.replace(/^@/, '');
- return (
- UsersCache.retrieve(username)
- .then(user => {
- if (!user) {
- return;
- }
-
- /* eslint-disable no-param-reassign */
- tokenValueContainer.dataset.originalValue = tokenValue;
- tokenValueElement.innerHTML = `
- <img class="avatar s20" src="${user.avatar_url}" alt="">
- ${_.escape(user.name)}
- `;
- /* eslint-enable no-param-reassign */
- })
- // ignore error and leave username in the search bar
- .catch(() => {})
- );
- }
-
- static updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) {
- const container = tokenValueContainer;
- const element = tokenValueElement;
-
- return (
- import(/* webpackChunkName: 'emoji' */ '../emoji')
- .then(Emoji => {
- if (!Emoji.isEmojiNameValid(tokenValue)) {
- return;
- }
-
- container.dataset.originalValue = tokenValue;
- element.innerHTML = Emoji.glEmojiTag(tokenValue);
- })
- // ignore error and leave emoji name in the search bar
- .catch(() => {})
- );
- }
-
static renderVisualTokenValue(parentElement, tokenName, tokenValue) {
+ const tokenType = tokenName.toLowerCase();
const tokenValueContainer = parentElement.querySelector('.value-container');
const tokenValueElement = tokenValueContainer.querySelector('.value');
tokenValueElement.innerText = tokenValue;
- if (['none', 'any'].includes(tokenValue.toLowerCase())) {
- return;
- }
-
- const tokenType = tokenName.toLowerCase();
+ const visualTokenValue = new VisualTokenValue(tokenValue, tokenType);
- if (tokenType === 'label') {
- FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue);
- } else if (tokenType === 'author' || tokenType === 'assignee') {
- FilteredSearchVisualTokens.updateUserTokenAppearance(
- tokenValueContainer,
- tokenValueElement,
- tokenValue,
- );
- } else if (tokenType === 'my-reaction') {
- FilteredSearchVisualTokens.updateEmojiTokenAppearance(
- tokenValueContainer,
- tokenValueElement,
- tokenValue,
- );
- }
+ visualTokenValue.render(tokenValueContainer, tokenValueElement);
}
static addVisualTokenElement(name, value, options = {}) {
- const { isSearchTerm = false, canEdit, uppercaseTokenName, capitalizeTokenValue } = options;
+ const {
+ isSearchTerm = false,
+ canEdit,
+ uppercaseTokenName,
+ capitalizeTokenValue,
+ tokenClass = `search-token-${name.toLowerCase()}`,
+ } = options;
const li = document.createElement('li');
li.classList.add('js-visual-token');
li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token');
+ if (!isSearchTerm) {
+ li.classList.add(tokenClass);
+ }
+
if (value) {
li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({
canEdit,
@@ -318,6 +210,21 @@ export default class FilteredSearchVisualTokens {
}
}
+ /**
+ * Returns a computed API endpoint
+ * and query string composed of values from endpointQueryParams
+ * @param {String} endpoint
+ * @param {String} endpointQueryParams
+ */
+ static getEndpointWithQueryParams(endpoint, endpointQueryParams) {
+ if (!endpointQueryParams) {
+ return endpoint;
+ }
+
+ const queryString = objectToQueryString(JSON.parse(endpointQueryParams));
+ return `${endpoint}?${queryString}`;
+ }
+
static editToken(token) {
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
index fd61030eb13..6c3d9e33420 100644
--- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
@@ -1,4 +1,5 @@
import FilteredSearchTokenKeys from './filtered_search_token_keys';
+import { __ } from '~/locale';
export const tokenKeys = [
{
@@ -60,52 +61,52 @@ export const conditions = [
{
url: 'assignee_id=None',
tokenKey: 'assignee',
- value: 'None',
+ value: __('None'),
},
{
url: 'assignee_id=Any',
tokenKey: 'assignee',
- value: 'Any',
+ value: __('Any'),
},
{
url: 'milestone_title=None',
tokenKey: 'milestone',
- value: 'None',
+ value: __('None'),
},
{
url: 'milestone_title=Any',
tokenKey: 'milestone',
- value: 'Any',
+ value: __('Any'),
},
{
url: 'milestone_title=%23upcoming',
tokenKey: 'milestone',
- value: 'Upcoming',
+ value: __('Upcoming'),
},
{
url: 'milestone_title=%23started',
tokenKey: 'milestone',
- value: 'Started',
+ value: __('Started'),
},
{
url: 'label_name[]=None',
tokenKey: 'label',
- value: 'None',
+ value: __('None'),
},
{
url: 'label_name[]=Any',
tokenKey: 'label',
- value: 'Any',
+ value: __('Any'),
},
{
url: 'my_reaction_emoji=None',
tokenKey: 'my-reaction',
- value: 'None',
+ value: __('None'),
},
{
url: 'my_reaction_emoji=Any',
tokenKey: 'my-reaction',
- value: 'Any',
+ value: __('Any'),
},
];
diff --git a/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js b/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js
new file mode 100644
index 00000000000..7e9b809e9b2
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js
@@ -0,0 +1,4 @@
+export default {
+ issues: 'issue-recent-searches',
+ merge_requests: 'merge-request-recent-searches',
+};
diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js
index 5917b223d63..011b37e218d 100644
--- a/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js
+++ b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js
@@ -1,7 +1,9 @@
+import { __ } from '~/locale';
+
class RecentSearchesServiceError {
constructor(message) {
this.name = 'RecentSearchesServiceError';
- this.message = message || 'Recent Searches Service is unavailable';
+ this.message = message || __('Recent Searches Service is unavailable');
}
}
diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js
new file mode 100644
index 00000000000..38327472cb3
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/visual_token_value.js
@@ -0,0 +1,117 @@
+import _ from 'underscore';
+import FilteredSearchContainer from '~/filtered_search/container';
+import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens';
+import AjaxCache from '~/lib/utils/ajax_cache';
+import DropdownUtils from '~/filtered_search/dropdown_utils';
+import Flash from '~/flash';
+import UsersCache from '~/lib/utils/users_cache';
+import { __ } from '~/locale';
+
+export default class VisualTokenValue {
+ constructor(tokenValue, tokenType) {
+ this.tokenValue = tokenValue;
+ this.tokenType = tokenType;
+ }
+
+ render(tokenValueContainer, tokenValueElement) {
+ const { tokenType, tokenValue } = this;
+
+ if (['none', 'any'].includes(tokenValue.toLowerCase())) {
+ return;
+ }
+
+ if (tokenType === 'label') {
+ this.updateLabelTokenColor(tokenValueContainer);
+ } else if (tokenType === 'author' || tokenType === 'assignee') {
+ this.updateUserTokenAppearance(tokenValueContainer, tokenValueElement);
+ } else if (tokenType === 'my-reaction') {
+ this.updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement);
+ }
+ }
+
+ updateUserTokenAppearance(tokenValueContainer, tokenValueElement) {
+ const { tokenValue } = this;
+ const username = this.tokenValue.replace(/^@/, '');
+
+ return (
+ UsersCache.retrieve(username)
+ .then(user => {
+ if (!user) {
+ return;
+ }
+
+ /* eslint-disable no-param-reassign */
+ tokenValueContainer.dataset.originalValue = tokenValue;
+ tokenValueElement.innerHTML = `
+ <img class="avatar s20" src="${user.avatar_url}" alt="">
+ ${_.escape(user.name)}
+ `;
+ /* eslint-enable no-param-reassign */
+ })
+ // ignore error and leave username in the search bar
+ .catch(() => {})
+ );
+ }
+
+ updateLabelTokenColor(tokenValueContainer) {
+ const { tokenValue } = this;
+ const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search');
+ const { baseEndpoint } = filteredSearchInput.dataset;
+ const labelsEndpoint = FilteredSearchVisualTokens.getEndpointWithQueryParams(
+ `${baseEndpoint}/labels.json`,
+ filteredSearchInput.dataset.endpointQueryParams,
+ );
+
+ return AjaxCache.retrieve(labelsEndpoint)
+ .then(labels => {
+ const matchingLabel = (labels || []).find(
+ label => `~${DropdownUtils.getEscapedText(label.title)}` === tokenValue,
+ );
+
+ if (!matchingLabel) {
+ return;
+ }
+
+ VisualTokenValue.setTokenStyle(
+ tokenValueContainer,
+ matchingLabel.color,
+ matchingLabel.text_color,
+ );
+ })
+ .catch(() => new Flash(__('An error occurred while fetching label colors.')));
+ }
+
+ static setTokenStyle(tokenValueContainer, backgroundColor, textColor) {
+ const token = tokenValueContainer;
+
+ token.style.backgroundColor = backgroundColor;
+ token.style.color = textColor;
+
+ if (textColor === '#FFFFFF') {
+ const removeToken = token.querySelector('.remove-token');
+ removeToken.classList.add('inverted');
+ }
+
+ return token;
+ }
+
+ updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement) {
+ const container = tokenValueContainer;
+ const element = tokenValueElement;
+ const value = this.tokenValue;
+
+ return (
+ import(/* webpackChunkName: 'emoji' */ '../emoji')
+ .then(Emoji => {
+ if (!Emoji.isEmojiNameValid(value)) {
+ return;
+ }
+
+ container.dataset.originalValue = value;
+ element.innerHTML = Emoji.glEmojiTag(value);
+ })
+ // ignore error and leave emoji name in the search bar
+ .catch(() => {})
+ );
+ }
+}
diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js
index 2b6af9060d1..2566ed6b47c 100644
--- a/app/assets/javascripts/fly_out_nav.js
+++ b/app/assets/javascripts/fly_out_nav.js
@@ -1,4 +1,5 @@
import bp from './breakpoints';
+import { SIDEBAR_COLLAPSED_CLASS } from './contextual_sidebar';
const HIDE_INTERVAL_TIMEOUT = 300;
const IS_OVER_CLASS = 'is-over';
@@ -29,7 +30,7 @@ const setHeaderHeight = () => {
};
export const isSidebarCollapsed = () =>
- sidebar && sidebar.classList.contains('sidebar-collapsed-desktop');
+ sidebar && sidebar.classList.contains(SIDEBAR_COLLAPSED_CLASS);
export const canShowActiveSubItems = el => {
if (el.classList.contains('active') && !isSidebarCollapsed()) {
diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
index 42d14b65b3a..92c3bcb5012 100644
--- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
+++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
@@ -1,6 +1,9 @@
<script>
/* eslint-disable vue/require-default-prop */
-import Identicon from '../../vue_shared/components/identicon.vue';
+import _ from 'underscore';
+import Identicon from '~/vue_shared/components/identicon.vue';
+import highlight from '~/lib/utils/highlight';
+import { truncateNamespace } from '~/lib/utils/text_utility';
export default {
components: {
@@ -36,43 +39,13 @@ export default {
},
computed: {
hasAvatar() {
- return this.avatarUrl !== null;
+ return _.isString(this.avatarUrl) && !_.isEmpty(this.avatarUrl);
},
- highlightedItemName() {
- if (this.matcher) {
- const matcherRegEx = new RegExp(this.matcher, 'gi');
- const matches = this.itemName.match(matcherRegEx);
-
- if (matches && matches.length > 0) {
- return this.itemName.replace(matches[0], `<b>${matches[0]}</b>`);
- }
- }
- return this.itemName;
- },
- /**
- * Smartly truncates item namespace by doing two things;
- * 1. Only include Group names in path by removing item name
- * 2. Only include first and last group names in the path
- * when namespace has more than 2 groups present
- *
- * First part (removal of item name from namespace) can be
- * done from backend but doing so involves migration of
- * existing item namespaces which is not wise thing to do.
- */
truncatedNamespace() {
- if (!this.namespace) {
- return null;
- }
- const namespaceArr = this.namespace.split(' / ');
-
- namespaceArr.splice(-1, 1);
- let namespace = namespaceArr.join(' / ');
-
- if (namespaceArr.length > 2) {
- namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`;
- }
-
- return namespace;
+ return truncateNamespace(this.namespace);
+ },
+ highlightedItemName() {
+ return highlight(this.itemName, this.matcher);
},
},
};
@@ -92,8 +65,16 @@ export default {
/>
</div>
<div class="frequent-items-item-metadata-container">
- <div :title="itemName" class="frequent-items-item-title" v-html="highlightedItemName"></div>
- <div v-if="truncatedNamespace" :title="namespace" class="frequent-items-item-namespace">
+ <div
+ :title="itemName"
+ class="frequent-items-item-title js-frequent-items-item-title"
+ v-html="highlightedItemName"
+ ></div>
+ <div
+ v-if="namespace"
+ :title="namespace"
+ class="frequent-items-item-namespace js-frequent-items-item-namespace"
+ >
{{ truncatedNamespace }}
</div>
</div>
diff --git a/app/assets/javascripts/frequent_items/store/actions.js b/app/assets/javascripts/frequent_items/store/actions.js
index 3dd89a82a42..ba62ab67e50 100644
--- a/app/assets/javascripts/frequent_items/store/actions.js
+++ b/app/assets/javascripts/frequent_items/store/actions.js
@@ -51,7 +51,7 @@ export const fetchSearchedItems = ({ state, dispatch }, searchQuery) => {
const params = {
simple: true,
per_page: 20,
- membership: !!gon.current_user_id,
+ membership: Boolean(gon.current_user_id),
};
if (state.namespace === 'projects') {
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index c81e754df4c..0af9aabd8cf 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import 'at.js';
import _ from 'underscore';
import glRegexp from './lib/utils/regexp';
import AjaxCache from './lib/utils/ajax_cache';
@@ -461,7 +462,10 @@ class GfmAutoComplete {
// We can ignore this for quick actions because they are processed
// before Markdown.
if (!this.setting.skipMarkdownCharacterTest) {
- withoutAt = withoutAt.replace(/([~\-_*`])/g, '\\$&');
+ withoutAt = withoutAt
+ .replace(/(~~|`|\*)/g, '\\$1')
+ .replace(/(\b)(_+)/g, '$1\\$2') // only escape underscores at the start
+ .replace(/(_+)(\b)/g, '\\$1$2'); // or end of words
}
return `${at}${withoutAt}`;
@@ -474,6 +478,16 @@ class GfmAutoComplete {
}
return null;
},
+ highlighter(li, query) {
+ // override default behaviour to escape dot character
+ // see https://github.com/ichord/At.js/pull/576
+ if (!query) {
+ return li;
+ }
+ const escapedQuery = query.replace(/[.+]/, '\\$&');
+ const regexp = new RegExp(`>\\s*([^<]*?)(${escapedQuery})([^<]*)\\s*<`, 'ig');
+ return li.replace(regexp, (str, $1, $2, $3) => `> ${$1}<strong>${$2}</strong>${$3} <`);
+ },
};
}
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index a8ac2f510a4..05f34391323 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -307,8 +307,8 @@ GitLabDropdown = (function() {
// Set Defaults
this.filterInput = this.options.filterInput || this.getElement(FILTER_INPUT);
this.noFilterInput = this.options.noFilterInput || this.getElement(NO_FILTER_INPUT);
- this.highlight = !!this.options.highlight;
- this.icon = !!this.options.icon;
+ 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
@@ -335,6 +335,10 @@ GitLabDropdown = (function() {
_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 &&
@@ -561,10 +565,14 @@ GitLabDropdown = (function() {
!$target.data('isLink')
) {
e.stopPropagation();
- return false;
- } else {
- return true;
+
+ // This prevents automatic scrolling to the top
+ if ($target.is('a')) {
+ return false;
+ }
}
+
+ return true;
}
};
@@ -656,23 +664,7 @@ GitLabDropdown = (function() {
if (this.options.renderMenu) {
return this.options.renderMenu(html);
} else {
- var ul = document.createElement('ul');
-
- for (var i = 0; i < html.length; i += 1) {
- var el = html[i];
-
- if (el instanceof $) {
- el = el.get(0);
- }
-
- if (typeof el === 'string') {
- ul.innerHTML += el;
- } else {
- ul.appendChild(el);
- }
- }
-
- return ul;
+ return $('<ul>').append(html);
}
};
@@ -719,6 +711,10 @@ GitLabDropdown = (function() {
}
html = document.createElement('li');
+ if (rowHidden) {
+ html.style.display = 'none';
+ }
+
if (data === 'divider' || data === 'separator') {
html.className = data;
return html;
diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js
index a5b8c357e8a..04301c9ce12 100644
--- a/app/assets/javascripts/gl_field_error.js
+++ b/app/assets/javascripts/gl_field_error.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { __ } from '~/locale';
/**
* This class overrides the browser's validation error bubbles, displaying custom
@@ -61,7 +62,7 @@ export default class GlFieldError {
this.inputElement = $(input);
this.inputDomElement = this.inputElement.get(0);
this.form = formErrors;
- this.errorMessage = this.inputElement.attr('title') || 'This field is required.';
+ this.errorMessage = this.inputElement.attr('title') || __('This field is required.');
this.fieldErrorElement = $(`<p class='${errorMessageClass} hidden'>${this.errorMessage}</p>`);
this.state = {
diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js
index d5d5954ce6a..c4fd719c8d0 100644
--- a/app/assets/javascripts/gl_field_errors.js
+++ b/app/assets/javascripts/gl_field_errors.js
@@ -15,7 +15,7 @@ export default class GlFieldErrors {
initValidators() {
// register selectors here as needed
- const validateSelectors = [':text', ':password', '[type=email]']
+ const validateSelectors = [':text', ':password', '[type=email]', '[type=url]', '[type=number]']
.map(selector => `input${selector}`)
.join(',');
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index f5e2e46237f..a66555838ba 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import autosize from 'autosize';
-import GfmAutoComplete, * as GFMConfig from './gfm_auto_complete';
+import GfmAutoComplete, { defaultAutocompleteConfig } from 'ee_else_ce/gfm_auto_complete';
import dropzoneInput from './dropzone_input';
import { addMarkdownListeners, removeMarkdownListeners } from './lib/utils/text_markdown';
@@ -8,12 +8,12 @@ export default class GLForm {
constructor(form, enableGFM = {}) {
this.form = form;
this.textarea = this.form.find('textarea.js-gfm-input');
- this.enableGFM = Object.assign({}, GFMConfig.defaultAutocompleteConfig, enableGFM);
+ this.enableGFM = Object.assign({}, defaultAutocompleteConfig, enableGFM);
// Disable autocomplete for keywords which do not have dataSources available
const dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {};
Object.keys(this.enableGFM).forEach(item => {
if (item !== 'emojis') {
- this.enableGFM[item] = !!dataSources[item];
+ this.enableGFM[item] = Boolean(dataSources[item]);
}
});
// Before we start, we should clean up any previous data for this form
diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js
index efba6fc1aff..96051b612b5 100644
--- a/app/assets/javascripts/gpg_badges.js
+++ b/app/assets/javascripts/gpg_badges.js
@@ -20,7 +20,7 @@ export default class GpgBadges {
const endpoint = tag.data('signaturesPath');
if (!endpoint) {
displayError();
- return Promise.reject(new Error('Missing commit signatures endpoint!'));
+ return Promise.reject(new Error(__('Missing commit signatures endpoint!')));
}
const params = parseQueryStringIntoObject(tag.serialize());
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index 29dc2d6a8a3..aa50fd8ff62 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -244,7 +244,7 @@ export default {
<gl-loading-icon
v-if="isLoading"
:label="s__('GroupsTree|Loading groups')"
- :size="2"
+ size="md"
class="loading-animation prepend-top-20"
/>
<groups-component
diff --git a/app/assets/javascripts/groups/transfer_dropdown.js b/app/assets/javascripts/groups/transfer_dropdown.js
index 26510fcdb2a..ce0c9256148 100644
--- a/app/assets/javascripts/groups/transfer_dropdown.js
+++ b/app/assets/javascripts/groups/transfer_dropdown.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { __ } from '~/locale';
export default class TransferDropdown {
constructor() {
@@ -13,7 +14,7 @@ export default class TransferDropdown {
}
buildDropdown() {
- const extraOptions = [{ id: '', text: 'No parent group' }, 'divider'];
+ const extraOptions = [{ id: '', text: __('No parent group') }, 'divider'];
this.groupDropdown.glDropdown({
selectable: true,
diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js
index bdadbb1bb2a..a1263d1cdab 100644
--- a/app/assets/javascripts/groups_select.js
+++ b/app/assets/javascripts/groups_select.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import axios from './lib/utils/axios_utils';
import Api from './api';
import { normalizeHeaders } from './lib/utils/common_utils';
+import { __ } from '~/locale';
export default function groupsSelect() {
import(/* webpackChunkName: 'select2' */ 'select2/select2')
@@ -18,7 +19,7 @@ export default function groupsSelect() {
: Api.groupsPath;
$select.select2({
- placeholder: 'Search for a group',
+ placeholder: __('Search for a group'),
allowClear: $select.hasClass('allowClear'),
multiple: $select.hasClass('multiselect'),
minimumInputLength: 0,
diff --git a/app/assets/javascripts/helpers/monitor_helper.js b/app/assets/javascripts/helpers/monitor_helper.js
new file mode 100644
index 00000000000..2c2a04d5b5e
--- /dev/null
+++ b/app/assets/javascripts/helpers/monitor_helper.js
@@ -0,0 +1,17 @@
+/* eslint-disable import/prefer-default-export */
+
+export const makeDataSeries = (queryResults, defaultConfig) =>
+ queryResults.reduce((acc, result) => {
+ const data = result.values.filter(([, value]) => !Number.isNaN(value));
+ if (!data.length) {
+ return acc;
+ }
+ const relevantMetric = defaultConfig.name.toLowerCase().replace(' ', '_');
+ const name = result.metric[relevantMetric];
+ const series = { data };
+ if (name) {
+ series.name = `${defaultConfig.name}: ${name}`;
+ }
+
+ return acc.concat({ ...defaultConfig, ...series });
+ }, []);
diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue
index 7c769ab7fa0..7b4e03be8eb 100644
--- a/app/assets/javascripts/ide/components/activity_bar.vue
+++ b/app/assets/javascripts/ide/components/activity_bar.vue
@@ -78,7 +78,7 @@ export default {
data-container="body"
data-placement="right"
type="button"
- class="ide-sidebar-link js-ide-commit-mode"
+ class="ide-sidebar-link js-ide-commit-mode qa-commit-mode-tab"
@click.prevent="changedActivityView($event, $options.activityBarViews.commit)"
>
<icon name="commit" />
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
index d360dc42cd3..685d8a6b245 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
@@ -1,17 +1,24 @@
<script>
import _ from 'underscore';
-import { mapActions, mapState, mapGetters } from 'vuex';
+import { mapState, mapGetters, createNamespacedHelpers } from 'vuex';
import { sprintf, __ } from '~/locale';
-import * as consts from '../../stores/modules/commit/constants';
+import consts from '../../stores/modules/commit/constants';
import RadioGroup from './radio_group.vue';
+import NewMergeRequestOption from './new_merge_request_option.vue';
+
+const { mapState: mapCommitState, mapActions: mapCommitActions } = createNamespacedHelpers(
+ 'commit',
+);
export default {
components: {
RadioGroup,
+ NewMergeRequestOption,
},
computed: {
...mapState(['currentBranchId', 'changedFiles', 'stagedFiles']),
- ...mapGetters(['currentProject', 'currentBranch']),
+ ...mapCommitState(['commitAction']),
+ ...mapGetters(['currentBranch']),
commitToCurrentBranchText() {
return sprintf(
__('Commit to %{branchName} branch'),
@@ -19,12 +26,12 @@ export default {
false,
);
},
- disableMergeRequestRadio() {
+ containsStagedChanges() {
return this.changedFiles.length > 0 && this.stagedFiles.length > 0;
},
},
watch: {
- disableMergeRequestRadio() {
+ containsStagedChanges() {
this.updateSelectedCommitAction();
},
},
@@ -32,18 +39,17 @@ export default {
this.updateSelectedCommitAction();
},
methods: {
- ...mapActions('commit', ['updateCommitAction']),
+ ...mapCommitActions(['updateCommitAction']),
updateSelectedCommitAction() {
if (this.currentBranch && !this.currentBranch.can_push) {
this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH);
- } else if (this.disableMergeRequestRadio) {
+ } else if (this.containsStagedChanges) {
this.updateCommitAction(consts.COMMIT_TO_CURRENT_BRANCH);
}
},
},
commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH,
commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH,
- commitToNewBranchMR: consts.COMMIT_TO_NEW_BRANCH_MR,
currentBranchPermissionsTooltip: __(
"This option is disabled as you don't have write permissions for the current branch",
),
@@ -51,7 +57,7 @@ export default {
</script>
<template>
- <div class="append-bottom-15 ide-commit-radios">
+ <div class="append-bottom-15 ide-commit-options">
<radio-group
:value="$options.commitToCurrentBranch"
:disabled="currentBranch && !currentBranch.can_push"
@@ -64,13 +70,6 @@ export default {
:label="__('Create a new branch')"
:show-input="true"
/>
- <radio-group
- v-if="currentProject.merge_requests_enabled"
- :value="$options.commitToNewBranchMR"
- :label="__('Create a new branch and merge request')"
- :title="__('This option is disabled while you still have unstaged changes')"
- :show-input="true"
- :disabled="disableMergeRequestRadio"
- />
+ <new-merge-request-option />
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
index 00b2d236da3..6b0aa5b2b2b 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
@@ -108,6 +108,7 @@ export default {
:placeholder="placeholder"
:value="text"
class="note-textarea ide-commit-message-textarea"
+ dir="auto"
name="commit-message"
@scroll="handleScroll"
@input="onInput"
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue
new file mode 100644
index 00000000000..b2e7b15089c
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue
@@ -0,0 +1,43 @@
+<script>
+import { mapGetters, createNamespacedHelpers } from 'vuex';
+
+const {
+ mapState: mapCommitState,
+ mapGetters: mapCommitGetters,
+ mapActions: mapCommitActions,
+} = createNamespacedHelpers('commit');
+
+export default {
+ computed: {
+ ...mapCommitState(['shouldCreateMR']),
+ ...mapCommitGetters(['isCommittingToCurrentBranch', 'isCommittingToDefaultBranch']),
+ ...mapGetters(['hasMergeRequest', 'isOnDefaultBranch']),
+ currentBranchHasMr() {
+ return this.hasMergeRequest && this.isCommittingToCurrentBranch;
+ },
+ showNewMrOption() {
+ return (
+ this.isCommittingToDefaultBranch || !this.currentBranchHasMr || this.isCommittingToNewBranch
+ );
+ },
+ },
+ mounted() {
+ this.setShouldCreateMR();
+ },
+ methods: {
+ ...mapCommitActions(['toggleShouldCreateMR', 'setShouldCreateMR']),
+ },
+};
+</script>
+
+<template>
+ <div v-if="showNewMrOption">
+ <hr class="my-2" />
+ <label class="mb-0">
+ <input :checked="shouldCreateMR" type="checkbox" @change="toggleShouldCreateMR" />
+ <span class="prepend-left-10">
+ {{ __('Start a new merge request') }}
+ </span>
+ </label>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
index 2b44438f849..9161eb3d9b1 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
@@ -38,8 +38,8 @@ export default {
},
},
computed: {
- ...mapState('commit', ['commitAction']),
- ...mapGetters('commit', ['newBranchName']),
+ ...mapState('commit', ['commitAction', 'newBranchName']),
+ ...mapGetters('commit', ['placeholderBranchName']),
tooltipTitle() {
return this.disabled ? this.title : '';
},
@@ -73,7 +73,8 @@ export default {
</label>
<div v-if="commitAction === value && showInput" class="ide-commit-new-branch">
<input
- :placeholder="newBranchName"
+ :placeholder="placeholderBranchName"
+ :value="newBranchName"
type="text"
class="form-control monospace"
@input="updateBranchName($event.target.value)"
diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue
index d6673cf0421..80a6ab9598a 100644
--- a/app/assets/javascripts/ide/components/file_row_extra.vue
+++ b/app/assets/javascripts/ide/components/file_row_extra.vue
@@ -23,7 +23,7 @@ export default {
type: Object,
required: true,
},
- mouseOver: {
+ dropdownOpen: {
type: Boolean,
required: true,
},
@@ -92,8 +92,9 @@ export default {
<new-dropdown
:type="file.type"
:path="file.path"
- :mouse-over="mouseOver"
+ :is-open="dropdownOpen"
class="prepend-left-8"
+ v-on="$listeners"
/>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index 9894ebb0624..e41b1530226 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -1,6 +1,7 @@
<script>
import Vue from 'vue';
import { mapActions, mapState, mapGetters } from 'vuex';
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import FindFile from '~/vue_shared/components/file_finder/index.vue';
import NewModal from './new_dropdown/modal.vue';
@@ -22,6 +23,8 @@ export default {
FindFile,
ErrorMessage,
CommitEditorHeader,
+ GlButton,
+ GlLoadingIcon,
},
props: {
rightPaneComponent: {
@@ -47,13 +50,15 @@ export default {
'someUncommittedChanges',
'isCommitModeActive',
'allBlobs',
+ 'emptyRepo',
+ 'currentTree',
]),
},
mounted() {
window.onbeforeunload = e => this.onBeforeUnload(e);
},
methods: {
- ...mapActions(['toggleFileFinder']),
+ ...mapActions(['toggleFileFinder', 'openNewEntryModal']),
onBeforeUnload(e = {}) {
const returnValue = __('Are you sure you want to lose unsaved changes?');
@@ -98,17 +103,40 @@ export default {
<repo-editor :file="activeFile" class="multi-file-edit-pane-content" />
</template>
<template v-else>
- <div v-once class="ide-empty-state">
+ <div class="ide-empty-state">
<div class="row js-empty-state">
<div class="col-12">
<div class="svg-content svg-250"><img :src="emptyStateSvgPath" /></div>
</div>
<div class="col-12">
<div class="text-content text-center">
- <h4>Welcome to the GitLab IDE</h4>
- <p>
- Select a file from the left sidebar to begin editing. Afterwards, you'll be able
- to commit your changes.
+ <h4>
+ {{ __('Make and review changes in the browser with the Web IDE') }}
+ </h4>
+ <template v-if="emptyRepo">
+ <p>
+ {{
+ __(
+ "Create a new file as there are no files yet. Afterwards, you'll be able to commit your changes.",
+ )
+ }}
+ </p>
+ <gl-button
+ variant="success"
+ :title="__('New file')"
+ :aria-label="__('New file')"
+ @click="openNewEntryModal({ type: 'blob' })"
+ >
+ {{ __('New file') }}
+ </gl-button>
+ </template>
+ <gl-loading-icon v-else-if="!currentTree || currentTree.loading" size="md" />
+ <p v-else>
+ {{
+ __(
+ "Select a file from the left sidebar to begin editing. Afterwards, you'll be able to commit your changes.",
+ )
+ }}
</p>
</div>
</div>
diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue
index 81374f26645..95782b2c88a 100644
--- a/app/assets/javascripts/ide/components/ide_tree_list.vue
+++ b/app/assets/javascripts/ide/components/ide_tree_list.vue
@@ -54,14 +54,17 @@ export default {
<slot name="header"></slot>
</header>
<div class="ide-tree-body h-100">
- <file-row
- v-for="file in currentTree.tree"
- :key="file.key"
- :file="file"
- :level="0"
- :extra-component="$options.FileRowExtra"
- @toggleTreeOpen="toggleTreeOpen"
- />
+ <template v-if="currentTree.tree.length">
+ <file-row
+ v-for="file in currentTree.tree"
+ :key="file.key"
+ :file="file"
+ :level="0"
+ :extra-component="$options.FileRowExtra"
+ @toggleTreeOpen="toggleTreeOpen"
+ />
+ </template>
+ <div v-else class="file-row">{{ __('No files') }}</div>
</div>
</template>
</div>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue
index d7a7b1b4d78..27d24fa5e1d 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/index.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue
@@ -1,7 +1,6 @@
<script>
import { mapActions } from 'vuex';
import icon from '~/vue_shared/components/icon.vue';
-import newModal from './modal.vue';
import upload from './upload.vue';
import ItemButton from './button.vue';
import { modalTypes } from '../../constants';
@@ -9,7 +8,6 @@ import { modalTypes } from '../../constants';
export default {
components: {
icon,
- newModal,
upload,
ItemButton,
},
@@ -23,38 +21,29 @@ export default {
required: false,
default: '',
},
- mouseOver: {
+ isOpen: {
type: Boolean,
- required: true,
+ required: false,
+ default: false,
},
},
- data() {
- return {
- dropdownOpen: false,
- };
- },
watch: {
- dropdownOpen() {
+ isOpen() {
this.$nextTick(() => {
this.$refs.dropdownMenu.scrollIntoView({
block: 'nearest',
});
});
},
- mouseOver() {
- if (!this.mouseOver) {
- this.dropdownOpen = false;
- }
- },
},
methods: {
...mapActions(['createTempEntry', 'openNewEntryModal', 'deleteEntry']),
createNewItem(type) {
this.openNewEntryModal({ type, path: this.path });
- this.dropdownOpen = false;
+ this.$emit('toggle', false);
},
openDropdown() {
- this.dropdownOpen = !this.dropdownOpen;
+ this.$emit('toggle', !this.isOpen);
},
},
modalTypes,
@@ -65,7 +54,7 @@ export default {
<div class="ide-new-btn">
<div
:class="{
- show: dropdownOpen,
+ show: isOpen,
}"
class="dropdown d-flex"
>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index c9c4e9e86f8..f67666f1fbf 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -1,6 +1,7 @@
<script>
import $ from 'jquery';
-import { __ } from '~/locale';
+import flash from '~/flash';
+import { __, sprintf, s__ } from '~/locale';
import { mapActions, mapState, mapGetters } from 'vuex';
import GlModal from '~/vue_shared/components/gl_modal.vue';
import { modalTypes } from '../../constants';
@@ -15,18 +16,20 @@ export default {
};
},
computed: {
- ...mapState(['entryModal']),
+ ...mapState(['entries', 'entryModal']),
...mapGetters('fileTemplates', ['templateTypes']),
entryName: {
get() {
+ const entryPath = this.entryModal.entry.path;
+
if (this.entryModal.type === modalTypes.rename) {
- return this.name || this.entryModal.entry.name;
+ return this.name || entryPath;
}
- return this.name || (this.entryModal.path !== '' ? `${this.entryModal.path}/` : '');
+ return this.name || (entryPath ? `${entryPath}/` : '');
},
set(val) {
- this.name = val;
+ this.name = val.trim();
},
},
modalTitle() {
@@ -62,10 +65,40 @@ export default {
...mapActions(['createTempEntry', 'renameEntry']),
submitForm() {
if (this.entryModal.type === modalTypes.rename) {
- this.renameEntry({
- path: this.entryModal.entry.path,
- name: this.entryName,
- });
+ if (this.entries[this.entryName] && !this.entries[this.entryName].deleted) {
+ flash(
+ sprintf(s__('The name %{entryName} is already taken in this directory.'), {
+ entryName: this.entryName,
+ }),
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
+ } else {
+ let parentPath = this.entryName.split('/');
+ const entryName = parentPath.pop();
+ parentPath = parentPath.join('/');
+
+ const createPromise =
+ parentPath && !this.entries[parentPath]
+ ? this.createTempEntry({ name: parentPath, type: 'tree' })
+ : Promise.resolve();
+
+ createPromise
+ .then(() =>
+ this.renameEntry({
+ path: this.entryModal.entry.path,
+ name: entryName,
+ entryPath: null,
+ parentPath,
+ }),
+ )
+ .catch(() =>
+ flash(__('Error creating a new path'), 'alert', document, null, false, true),
+ );
+ }
} else {
this.createTempEntry({
name: this.name,
@@ -82,7 +115,14 @@ export default {
$('#ide-new-entry').modal('toggle');
},
focusInput() {
+ const name = this.entries[this.entryName] ? this.entries[this.entryName].name : null;
+ const inputValue = this.$refs.fieldName.value;
+
this.$refs.fieldName.focus();
+
+ if (name) {
+ this.$refs.fieldName.setSelectionRange(inputValue.indexOf(name), inputValue.length);
+ }
},
closedModal() {
this.name = '';
@@ -94,6 +134,7 @@ export default {
<template>
<gl-modal
id="ide-new-entry"
+ class="qa-new-file-modal"
:header-title-text="modalTitle"
:footer-primary-button-text="buttonLabel"
footer-primary-button-variant="success"
diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
index ec759043efc..188518dd419 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
@@ -57,6 +57,8 @@ export default {
type: 'blob',
content: result,
base64: !isText,
+ binary: !isText,
+ rawPath: !isText ? target.result : '',
});
},
readFile(file) {
diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue
index 451c8030e16..5ae73b2fc9c 100644
--- a/app/assets/javascripts/ide/components/pipelines/list.vue
+++ b/app/assets/javascripts/ide/components/pipelines/list.vue
@@ -24,7 +24,13 @@ export default {
...mapState(['pipelinesEmptyStateSvgPath', 'links']),
...mapGetters(['currentProject']),
...mapGetters('pipelines', ['jobsCount', 'failedJobsCount', 'failedStages', 'pipelineFailed']),
- ...mapState('pipelines', ['isLoadingPipeline', 'latestPipeline', 'stages', 'isLoadingJobs']),
+ ...mapState('pipelines', [
+ 'isLoadingPipeline',
+ 'hasLoadedPipeline',
+ 'latestPipeline',
+ 'stages',
+ 'isLoadingJobs',
+ ]),
ciLintText() {
return sprintf(
__('You can test your .gitlab-ci.yml in %{linkStart}CI Lint%{linkEnd}.'),
@@ -36,7 +42,7 @@ export default {
);
},
showLoadingIcon() {
- return this.isLoadingPipeline && this.latestPipeline === null;
+ return this.isLoadingPipeline && !this.hasLoadedPipeline;
},
},
created() {
@@ -51,7 +57,7 @@ export default {
<template>
<div class="ide-pipeline">
<gl-loading-icon v-if="showLoadingIcon" :size="2" class="prepend-top-default" />
- <template v-else-if="latestPipeline !== null">
+ <template v-else-if="hasLoadedPipeline">
<header v-if="latestPipeline" class="ide-tree-header ide-pipeline-header">
<ci-icon :status="latestPipeline.details.status" :size="24" />
<span class="prepend-left-8">
@@ -62,7 +68,7 @@ export default {
</span>
</header>
<empty-state
- v-if="latestPipeline === false"
+ v-if="!latestPipeline"
:help-page-path="links.ciHelpPagePath"
:empty-state-svg-path="pipelinesEmptyStateSvgPath"
:can-set-ci="true"
diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue
index c98dda00817..6999746f115 100644
--- a/app/assets/javascripts/ide/components/preview/clientside.vue
+++ b/app/assets/javascripts/ide/components/preview/clientside.vue
@@ -105,7 +105,7 @@ export default {
.then(() => {
this.initManager('#ide-preview', this.sandboxOpts, {
fileResolver: {
- isFile: p => Promise.resolve(!!this.entries[createPathWithExt(p)]),
+ isFile: p => Promise.resolve(Boolean(this.entries[createPathWithExt(p)])),
readFile: p => this.loadFileContent(createPathWithExt(p)).then(content => content),
},
});
diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue
index 8dd88f187d4..5201c33b1b4 100644
--- a/app/assets/javascripts/ide/components/repo_commit_section.vue
+++ b/app/assets/javascripts/ide/components/repo_commit_section.vue
@@ -5,7 +5,7 @@ import Icon from '~/vue_shared/components/icon.vue';
import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import CommitFilesList from './commit_sidebar/list.vue';
import EmptyState from './commit_sidebar/empty_state.vue';
-import * as consts from '../stores/modules/commit/constants';
+import consts from '../stores/modules/commit/constants';
import { activityBarViews, stageKeys } from '../constants';
export default {
@@ -30,7 +30,7 @@ export default {
...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommittedChanges', 'activeFile']),
...mapGetters('commit', ['discardDraftButtonDisabled']),
showStageUnstageArea() {
- return !!(this.someUncommittedChanges || this.lastCommitMsg || !this.unusedSeal);
+ return Boolean(this.someUncommittedChanges || this.lastCommitMsg || !this.unusedSeal);
},
activeFileKey() {
return this.activeFile ? this.activeFile.key : null;
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 94a9e87369c..b0c4969c5e4 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -1,5 +1,6 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
+import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
import flash from '~/flash';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
@@ -35,7 +36,7 @@ export default {
]),
...mapGetters('fileTemplates', ['showFileTemplatesBar']),
shouldHideEditor() {
- return this.file && this.file.binary && !this.file.content;
+ return this.file && this.file.binary;
},
showContentViewer() {
return (
@@ -56,6 +57,10 @@ export default {
active: this.file.viewMode === 'preview',
};
},
+ fileType() {
+ const info = viewerInformationForPath(this.file.path);
+ return (info && info.id) || '';
+ },
},
watch: {
file(newVal, oldVal) {
@@ -120,6 +125,7 @@ export default {
'setFileEOL',
'updateViewer',
'removePendingTab',
+ 'triggerFilesChange',
]),
initEditor() {
if (this.shouldHideEditor) return;
@@ -251,6 +257,7 @@ export default {
'is-added': file.tempFile,
}"
class="multi-file-editor-holder"
+ @focusout="triggerFilesChange"
></div>
<content-viewer
v-if="showContentViewer"
@@ -258,6 +265,7 @@ export default {
:path="file.rawPath || file.path"
:file-size="file.size"
:project-path="file.projectId"
+ :type="fileType"
/>
<diff-viewer
v-if="showDiffViewer"
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index 7c560c89695..e30670e119f 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -72,4 +72,11 @@ export const modalTypes = {
tree: 'tree',
};
+export const commitActionTypes = {
+ move: 'move',
+ delete: 'delete',
+ create: 'create',
+ update: 'update',
+};
+
export const packageJsonPath = 'package.json';
diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js
index 229ef168926..8c84b98a108 100644
--- a/app/assets/javascripts/ide/ide_router.js
+++ b/app/assets/javascripts/ide/ide_router.js
@@ -1,8 +1,9 @@
import Vue from 'vue';
import VueRouter from 'vue-router';
-import { join as joinPath } from 'path';
+import { joinPaths } from '~/lib/utils/url_utility';
import flash from '~/flash';
import store from './stores';
+import { __ } from '~/locale';
Vue.use(VueRouter);
@@ -34,7 +35,7 @@ const EmptyRouterComponent = {
const router = new VueRouter({
mode: 'history',
- base: `${gon.relative_url_root}/-/ide/`,
+ base: joinPaths(gon.relative_url_root || '', '/-/ide/'),
routes: [
{
path: '/project/:namespace+/:project',
@@ -46,11 +47,11 @@ const router = new VueRouter({
},
{
path: ':targetmode(edit|tree|blob)/:branchid+/',
- redirect: to => joinPath(to.path, '/-/'),
+ redirect: to => joinPaths(to.path, '/-/'),
},
{
path: ':targetmode(edit|tree|blob)',
- redirect: to => joinPath(to.path, '/master/-/'),
+ redirect: to => joinPaths(to.path, '/master/-/'),
},
{
path: 'merge_requests/:mrid',
@@ -58,7 +59,7 @@ const router = new VueRouter({
},
{
path: '',
- redirect: to => joinPath(to.path, '/edit/master/-/'),
+ redirect: to => joinPaths(to.path, '/edit/master/-/'),
},
],
},
@@ -94,7 +95,7 @@ router.beforeEach((to, from, next) => {
})
.catch(e => {
flash(
- 'Error while loading the project data. Please try again.',
+ __('Error while loading the project data. Please try again.'),
'alert',
document,
null,
diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js
index e35595ab1fd..dac2a8e8b51 100644
--- a/app/assets/javascripts/ide/lib/editor_options.js
+++ b/app/assets/javascripts/ide/lib/editor_options.js
@@ -11,7 +11,7 @@ export const defaultEditorOptions = {
export default [
{
- readOnly: model => !!model.file.file_lock,
+ readOnly: model => Boolean(model.file.file_lock),
quickSuggestions: model => !(model.language === 'markdown'),
},
];
diff --git a/app/assets/javascripts/ide/lib/files.js b/app/assets/javascripts/ide/lib/files.js
new file mode 100644
index 00000000000..b8abaa41f23
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/files.js
@@ -0,0 +1,119 @@
+import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
+import { decorateData, sortTree } from '../stores/utils';
+
+export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/');
+
+export const splitParent = path => {
+ const idx = path.lastIndexOf('/');
+
+ return {
+ parent: idx >= 0 ? path.substring(0, idx) : null,
+ name: idx >= 0 ? path.substring(idx + 1) : path,
+ };
+};
+
+/**
+ * Create file objects from a list of file paths.
+ */
+export const decorateFiles = ({
+ data,
+ projectId,
+ branchId,
+ tempFile = false,
+ content = '',
+ base64 = false,
+ binary = false,
+ rawPath = '',
+}) => {
+ const treeList = [];
+ const entries = {};
+
+ // These mutable variable references end up being exported and used by `createTempEntry`
+ let file;
+ let parentPath;
+
+ const insertParent = path => {
+ if (!path) {
+ return null;
+ } else if (entries[path]) {
+ return entries[path];
+ }
+
+ const { parent, name } = splitParent(path);
+ const parentFolder = parent && insertParent(parent);
+ parentPath = parentFolder && parentFolder.path;
+
+ const tree = decorateData({
+ projectId,
+ branchId,
+ id: path,
+ name,
+ path,
+ url: `/${projectId}/tree/${branchId}/-/${escapeFileUrl(path)}/`,
+ type: 'tree',
+ parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`,
+ tempFile,
+ changed: tempFile,
+ opened: tempFile,
+ parentPath,
+ });
+
+ Object.assign(entries, {
+ [path]: tree,
+ });
+
+ if (parentFolder) {
+ parentFolder.tree.push(tree);
+ } else {
+ treeList.push(tree);
+ }
+
+ return tree;
+ };
+
+ data.forEach(path => {
+ const { parent, name } = splitParent(path);
+
+ const fileFolder = parent && insertParent(parent);
+
+ if (name) {
+ parentPath = fileFolder && fileFolder.path;
+
+ file = decorateData({
+ projectId,
+ branchId,
+ id: path,
+ name,
+ path,
+ url: `/${projectId}/blob/${branchId}/-/${escapeFileUrl(path)}`,
+ type: 'blob',
+ parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`,
+ tempFile,
+ changed: tempFile,
+ content,
+ base64,
+ binary,
+ rawPath,
+ previewMode: viewerInformationForPath(name),
+ parentPath,
+ });
+
+ Object.assign(entries, {
+ [path]: file,
+ });
+
+ if (fileFolder) {
+ fileFolder.tree.push(file);
+ } else {
+ treeList.push(file);
+ }
+ }
+ });
+
+ return {
+ entries,
+ treeList: sortTree(treeList),
+ file,
+ parentPath,
+ };
+};
diff --git a/app/assets/javascripts/ide/lib/keymap.json b/app/assets/javascripts/ide/lib/keymap.json
index 131abfebbed..2db87c07dde 100644
--- a/app/assets/javascripts/ide/lib/keymap.json
+++ b/app/assets/javascripts/ide/lib/keymap.json
@@ -7,5 +7,13 @@
"name": "toggleFileFinder",
"params": true
}
+ },
+ {
+ "id": "save-files",
+ "label": "Save files",
+ "bindings": ["CtrlCmd+KEY_S"],
+ "action": {
+ "name": "triggerFilesChange"
+ }
}
]
diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js
index 13449592e62..ba33b6826d6 100644
--- a/app/assets/javascripts/ide/services/index.js
+++ b/app/assets/javascripts/ide/services/index.js
@@ -40,6 +40,9 @@ export default {
getProjectData(namespace, project) {
return Api.project(`${namespace}/${project}`);
},
+ getProjectMergeRequests(projectId, params = {}) {
+ return Api.projectMergeRequests(projectId, params);
+ },
getProjectMergeRequestData(projectId, mergeRequestId, params = {}) {
return Api.projectMergeRequest(projectId, mergeRequestId, params);
},
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index e10a132ab4b..5429b834708 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -1,12 +1,15 @@
import $ from 'jquery';
import Vue from 'vue';
+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 FilesDecoratorWorker from './workers/files_decorator_worker';
+import { decorateFiles } from '../lib/files';
import { stageKeys } from '../constants';
+import service from '../services';
-export const redirectToUrl = (_, url) => visitUrl(url);
+export const redirectToUrl = (self, url) => visitUrl(url);
export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
@@ -53,10 +56,9 @@ export const setResizingStatus = ({ commit }, resizing) => {
export const createTempEntry = (
{ state, commit, dispatch },
- { name, type, content = '', base64 = false },
+ { name, type, content = '', base64 = false, binary = false, rawPath = '' },
) =>
new Promise(resolve => {
- const worker = new FilesDecoratorWorker();
const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name;
if (state.entries[name]) {
@@ -74,40 +76,38 @@ export const createTempEntry = (
return null;
}
- worker.addEventListener('message', ({ data }) => {
- const { file, parentPath } = data;
-
- worker.terminate();
-
- commit(types.CREATE_TMP_ENTRY, {
- data,
- projectId: state.currentProjectId,
- branchId: state.currentBranchId,
- });
-
- if (type === 'blob') {
- commit(types.TOGGLE_FILE_OPEN, file.path);
- commit(types.ADD_FILE_TO_CHANGED, file.path);
- dispatch('setFileActive', file.path);
- }
-
- if (parentPath && !state.entries[parentPath].opened) {
- commit(types.TOGGLE_TREE_OPEN, parentPath);
- }
-
- resolve(file);
- });
-
- worker.postMessage({
+ const data = decorateFiles({
data: [fullName],
projectId: state.currentProjectId,
branchId: state.currentBranchId,
type,
tempFile: true,
- base64,
content,
+ base64,
+ binary,
+ rawPath,
+ });
+ const { file, parentPath } = data;
+
+ commit(types.CREATE_TMP_ENTRY, {
+ data,
+ projectId: state.currentProjectId,
+ branchId: state.currentBranchId,
});
+ if (type === 'blob') {
+ commit(types.TOGGLE_FILE_OPEN, file.path);
+ commit(types.ADD_FILE_TO_CHANGED, file.path);
+ dispatch('setFileActive', file.path);
+ dispatch('triggerFilesChange');
+ }
+
+ if (parentPath && !state.entries[parentPath].opened) {
+ commit(types.TOGGLE_TREE_OPEN, parentPath);
+ }
+
+ resolve(file);
+
return null;
});
@@ -211,26 +211,89 @@ export const deleteEntry = ({ commit, dispatch, state }, path) => {
if (entry.parentPath && state.entries[entry.parentPath].tree.length === 0) {
dispatch('deleteEntry', entry.parentPath);
}
+
+ dispatch('triggerFilesChange');
};
export const resetOpenFiles = ({ commit }) => commit(types.RESET_OPEN_FILES);
-export const renameEntry = ({ dispatch, commit, state }, { path, name, entryPath = null }) => {
+export const renameEntry = (
+ { dispatch, commit, state },
+ { path, name, entryPath = null, parentPath },
+) => {
const entry = state.entries[entryPath || path];
- commit(types.RENAME_ENTRY, { path, name, entryPath });
+ commit(types.RENAME_ENTRY, { path, name, entryPath, parentPath });
if (entry.type === 'tree') {
- state.entries[entryPath || path].tree.forEach(f =>
- dispatch('renameEntry', { path, name, entryPath: f.path }),
- );
+ const slashedParentPath = parentPath ? `${parentPath}/` : '';
+ const targetEntry = entryPath ? entryPath.split('/').pop() : name;
+ const newParentPath = `${slashedParentPath}${targetEntry}`;
+
+ state.entries[entryPath || path].tree.forEach(f => {
+ dispatch('renameEntry', {
+ path,
+ name,
+ entryPath: f.path,
+ parentPath: newParentPath,
+ });
+ });
}
if (!entryPath && !entry.tempFile) {
dispatch('deleteEntry', path);
}
+
+ dispatch('triggerFilesChange');
};
+export const getBranchData = ({ commit, state }, { projectId, branchId, force = false } = {}) =>
+ new Promise((resolve, reject) => {
+ const currentProject = state.projects[projectId];
+ if (!currentProject || !currentProject.branches[branchId] || force) {
+ service
+ .getBranchData(projectId, branchId)
+ .then(({ data }) => {
+ const { id } = data.commit;
+ commit(types.SET_BRANCH, {
+ projectPath: projectId,
+ branchName: branchId,
+ branch: data,
+ });
+ commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id });
+ resolve(data);
+ })
+ .catch(e => {
+ if (e.response.status === 404) {
+ reject(e);
+ } else {
+ flash(
+ __('Error loading branch data. Please try again.'),
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
+
+ reject(
+ new Error(
+ sprintf(
+ __('Branch not loaded - %{branchId}'),
+ {
+ branchId: `<strong>${_.escape(projectId)}/${_.escape(branchId)}</strong>`,
+ },
+ false,
+ ),
+ ),
+ );
+ }
+ });
+ } else {
+ resolve(currentProject.branches[branchId]);
+ }
+ });
+
export * from './actions/tree';
export * from './actions/file';
export * from './actions/project';
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
index e74b880e02c..dc40a1fa6a2 100644
--- a/app/assets/javascripts/ide/stores/actions/file.js
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -1,5 +1,6 @@
-import { __ } from '../../../locale';
-import { normalizeHeaders } from '../../../lib/utils/common_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
+import { normalizeHeaders } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
import eventHub from '../../eventhub';
import service from '../../services';
import * as types from '../mutation_types';
@@ -69,7 +70,7 @@ export const getFileData = (
const url = file.prevPath ? file.url.replace(file.path, file.prevPath) : file.url;
return service
- .getFileData(`${gon.relative_url_root ? gon.relative_url_root : ''}${url.replace('/-/', '/')}`)
+ .getFileData(joinPaths(gon.relative_url_root || '', url.replace('/-/', '/')))
.then(({ data, headers }) => {
const normalizedHeaders = normalizeHeaders(headers);
setPageTitle(decodeURI(normalizedHeaders['PAGE-TITLE']));
@@ -264,3 +265,8 @@ export const removePendingTab = ({ commit }, file) => {
eventHub.$emit(`editor.update.model.dispose.${file.key}`);
};
+
+export const triggerFilesChange = () => {
+ // Used in EE for file mirroring
+ eventHub.$emit('ide.files.change');
+};
diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js
index 18c24369996..1273e375859 100644
--- a/app/assets/javascripts/ide/stores/actions/merge_request.js
+++ b/app/assets/javascripts/ide/stores/actions/merge_request.js
@@ -4,6 +4,39 @@ import service from '../../services';
import * as types from '../mutation_types';
import { activityBarViews } from '../../constants';
+export const getMergeRequestsForBranch = ({ commit, state }, { projectId, branchId } = {}) =>
+ service
+ .getProjectMergeRequests(`${projectId}`, {
+ source_branch: branchId,
+ source_project_id: state.projects[projectId].id,
+ order_by: 'created_at',
+ per_page: 1,
+ })
+ .then(({ data }) => {
+ if (data.length > 0) {
+ const currentMR = data[0];
+
+ commit(types.SET_MERGE_REQUEST, {
+ projectPath: projectId,
+ mergeRequestId: currentMR.iid,
+ mergeRequest: currentMR,
+ });
+
+ commit(types.SET_CURRENT_MERGE_REQUEST, `${currentMR.iid}`);
+ }
+ })
+ .catch(e => {
+ flash(
+ __(`Error fetching merge requests for ${branchId}`),
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
+ throw e;
+ });
+
export const getMergeRequestData = (
{ commit, dispatch, state },
{ projectId, mergeRequestId, targetProjectId = null, force = false } = {},
diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
index b65f631c99c..dd8f17e4f3a 100644
--- a/app/assets/javascripts/ide/stores/actions/project.js
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -35,48 +35,6 @@ export const getProjectData = ({ commit, state }, { namespace, projectId, force
}
});
-export const getBranchData = (
- { commit, dispatch, state },
- { projectId, branchId, force = false } = {},
-) =>
- new Promise((resolve, reject) => {
- if (
- typeof state.projects[`${projectId}`] === 'undefined' ||
- !state.projects[`${projectId}`].branches[branchId] ||
- force
- ) {
- service
- .getBranchData(`${projectId}`, branchId)
- .then(({ data }) => {
- const { id } = data.commit;
- commit(types.SET_BRANCH, {
- projectPath: `${projectId}`,
- branchName: branchId,
- branch: data,
- });
- commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id });
- resolve(data);
- })
- .catch(e => {
- if (e.response.status === 404) {
- dispatch('showBranchNotFoundError', branchId);
- } else {
- flash(
- __('Error loading branch data. Please try again.'),
- 'alert',
- document,
- null,
- false,
- true,
- );
- }
- reject(new Error(`Branch not loaded - ${projectId}/${branchId}`));
- });
- } else {
- resolve(state.projects[`${projectId}`].branches[branchId]);
- }
- });
-
export const refreshLastCommitData = ({ commit }, { projectId, branchId } = {}) =>
service
.getBranchData(projectId, branchId)
@@ -125,28 +83,66 @@ export const showBranchNotFoundError = ({ dispatch }, branchId) => {
});
};
-export const openBranch = ({ dispatch, state }, { projectId, branchId, basePath }) => {
- dispatch('setCurrentBranchId', branchId);
-
- dispatch('getBranchData', {
- projectId,
- branchId,
+export const showEmptyState = ({ commit, state }, { projectId, branchId }) => {
+ const treePath = `${projectId}/${branchId}`;
+ commit(types.CREATE_TREE, { treePath });
+ commit(types.TOGGLE_LOADING, {
+ entry: state.trees[treePath],
+ forceValue: false,
});
+};
- return dispatch('getFiles', {
+export const openBranch = ({ dispatch, state, getters }, { projectId, branchId, basePath }) => {
+ dispatch('setCurrentBranchId', branchId);
+
+ if (getters.emptyRepo) {
+ return dispatch('showEmptyState', { projectId, branchId });
+ }
+ return dispatch('getBranchData', {
projectId,
branchId,
- }).then(() => {
- if (basePath) {
- const path = basePath.slice(-1) === '/' ? basePath.slice(0, -1) : basePath;
- const treeEntryKey = Object.keys(state.entries).find(
- key => key === path && !state.entries[key].pending,
- );
- const treeEntry = state.entries[treeEntryKey];
+ })
+ .then(() => {
+ dispatch('getMergeRequestsForBranch', {
+ projectId,
+ branchId,
+ });
+ dispatch('getFiles', {
+ projectId,
+ branchId,
+ })
+ .then(() => {
+ if (basePath) {
+ const path = basePath.slice(-1) === '/' ? basePath.slice(0, -1) : basePath;
+ const treeEntryKey = Object.keys(state.entries).find(
+ key => key === path && !state.entries[key].pending,
+ );
+ const treeEntry = state.entries[treeEntryKey];
- if (treeEntry) {
- dispatch('handleTreeEntryAction', treeEntry);
- }
- }
- });
+ if (treeEntry) {
+ dispatch('handleTreeEntryAction', treeEntry);
+ } else {
+ dispatch('createTempEntry', {
+ name: path,
+ type: 'blob',
+ });
+ }
+ }
+ })
+ .catch(
+ () =>
+ new Error(
+ sprintf(
+ __('An error occurred whilst getting files for - %{branchId}'),
+ {
+ branchId: `<strong>${_.escape(projectId)}/${_.escape(branchId)}</strong>`,
+ },
+ false,
+ ),
+ ),
+ );
+ })
+ .catch(() => {
+ dispatch('showBranchNotFoundError', branchId);
+ });
};
diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js
index de5f6050074..75511574d3e 100644
--- a/app/assets/javascripts/ide/stores/actions/tree.js
+++ b/app/assets/javascripts/ide/stores/actions/tree.js
@@ -1,7 +1,8 @@
+import _ from 'underscore';
import { __ } from '../../../locale';
import service from '../../services';
import * as types from '../mutation_types';
-import FilesDecoratorWorker from '../workers/files_decorator_worker';
+import { decorateFiles } from '../../lib/files';
export const toggleTreeOpen = ({ commit }, path) => {
commit(types.TOGGLE_TREE_OPEN, path);
@@ -32,6 +33,19 @@ export const handleTreeEntryAction = ({ commit, dispatch }, row) => {
dispatch('showTreeEntry', row.path);
};
+export const setDirectoryData = ({ state, commit }, { projectId, branchId, treeList }) => {
+ const selectedTree = state.trees[`${projectId}/${branchId}`];
+
+ commit(types.SET_DIRECTORY_DATA, {
+ treePath: `${projectId}/${branchId}`,
+ data: treeList,
+ });
+ commit(types.TOGGLE_LOADING, {
+ entry: selectedTree,
+ forceValue: false,
+ });
+};
+
export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = {}) =>
new Promise((resolve, reject) => {
if (
@@ -45,44 +59,28 @@ export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } =
service
.getFiles(selectedProject.web_url, branchId)
.then(({ data }) => {
- const worker = new FilesDecoratorWorker();
- worker.addEventListener('message', e => {
- const { entries, treeList } = e.data;
- const selectedTree = state.trees[`${projectId}/${branchId}`];
-
- commit(types.SET_ENTRIES, entries);
- commit(types.SET_DIRECTORY_DATA, {
- treePath: `${projectId}/${branchId}`,
- data: treeList,
- });
- commit(types.TOGGLE_LOADING, {
- entry: selectedTree,
- forceValue: false,
- });
-
- worker.terminate();
-
- resolve();
- });
-
- worker.postMessage({
+ const { entries, treeList } = decorateFiles({
data,
projectId,
branchId,
});
+
+ commit(types.SET_ENTRIES, entries);
+
+ // Defer setting the directory data because this triggers some intense rendering.
+ // The entries is all we need to load the file editor.
+ _.defer(() => dispatch('setDirectoryData', { projectId, branchId, treeList }));
+
+ resolve();
})
.catch(e => {
- if (e.response.status === 404) {
- dispatch('showBranchNotFoundError', branchId);
- } else {
- dispatch('setErrorMessage', {
- text: __('An error occurred whilst loading all the files.'),
- action: payload =>
- dispatch('getFiles', payload).then(() => dispatch('setErrorMessage', null)),
- actionText: __('Please try again'),
- actionPayload: { projectId, branchId },
- });
- }
+ dispatch('setErrorMessage', {
+ text: __('An error occurred whilst loading all the files.'),
+ action: payload =>
+ dispatch('getFiles', payload).then(() => dispatch('setErrorMessage', null)),
+ actionText: __('Please try again'),
+ actionPayload: { projectId, branchId },
+ });
reject(e);
});
} else {
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index 8ad85074d6b..406903129db 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -25,7 +25,10 @@ export const projectsWithTrees = state =>
});
export const currentMergeRequest = state => {
- if (state.projects[state.currentProjectId]) {
+ if (
+ state.projects[state.currentProjectId] &&
+ state.projects[state.currentProjectId].mergeRequests
+ ) {
return state.projects[state.currentProjectId].mergeRequests[state.currentMergeRequestId];
}
return null;
@@ -33,12 +36,16 @@ export const currentMergeRequest = state => {
export const currentProject = state => state.projects[state.currentProjectId];
+export const emptyRepo = state =>
+ state.projects[state.currentProjectId] && state.projects[state.currentProjectId].empty_repo;
+
export const currentTree = state =>
state.trees[`${state.currentProjectId}/${state.currentBranchId}`];
-export const hasChanges = state => !!state.changedFiles.length || !!state.stagedFiles.length;
+export const hasChanges = state =>
+ Boolean(state.changedFiles.length) || Boolean(state.stagedFiles.length);
-export const hasMergeRequest = state => !!state.currentMergeRequestId;
+export const hasMergeRequest = state => Boolean(state.currentMergeRequestId);
export const allBlobs = state =>
Object.keys(state.entries)
@@ -64,7 +71,7 @@ export const isCommitModeActive = state => state.currentActivityView === activit
export const isReviewModeActive = state => state.currentActivityView === activityBarViews.review;
export const someUncommittedChanges = state =>
- !!(state.changedFiles.length || state.stagedFiles.length);
+ Boolean(state.changedFiles.length || state.stagedFiles.length);
export const getChangesInFolder = state => path => {
const changedFilesCount = state.changedFiles.filter(f => filePathMatches(f.path, path)).length;
@@ -90,7 +97,12 @@ export const lastCommit = (state, getters) => {
export const currentBranch = (state, getters) =>
getters.currentProject && getters.currentProject.branches[state.currentBranchId];
+export const branchName = (_state, getters) => getters.currentBranch && getters.currentBranch.name;
+
export const packageJson = state => state.entries[packageJsonPath];
+export const isOnDefaultBranch = (_state, getters) =>
+ getters.currentProject && getters.currentProject.default_branch === getters.branchName;
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index 24c2f71ae2b..51062f092ad 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -6,7 +6,7 @@ import { createCommitPayload, createNewMergeRequestUrl } from '../../utils';
import router from '../../../ide_router';
import service from '../../../services';
import * as types from './mutation_types';
-import * as consts from './constants';
+import consts from './constants';
import { activityBarViews } from '../../../constants';
import eventHub from '../../../eventhub';
@@ -18,16 +18,42 @@ export const discardDraft = ({ commit }) => {
commit(types.UPDATE_COMMIT_MESSAGE, '');
};
-export const updateCommitAction = ({ commit }, commitAction) => {
- commit(types.UPDATE_COMMIT_ACTION, commitAction);
+export const updateCommitAction = ({ commit, dispatch }, commitAction) => {
+ commit(types.UPDATE_COMMIT_ACTION, {
+ commitAction,
+ });
+ dispatch('setShouldCreateMR');
+};
+
+export const toggleShouldCreateMR = ({ commit }) => {
+ commit(types.TOGGLE_SHOULD_CREATE_MR);
+ commit(types.INTERACT_WITH_NEW_MR);
+};
+
+export const setShouldCreateMR = ({
+ commit,
+ getters,
+ rootGetters,
+ state: { interactedWithNewMR },
+}) => {
+ const committingToExistingMR =
+ getters.isCommittingToCurrentBranch &&
+ rootGetters.hasMergeRequest &&
+ !rootGetters.isOnDefaultBranch;
+
+ if ((getters.isCommittingToDefaultBranch && !interactedWithNewMR) || committingToExistingMR) {
+ commit(types.TOGGLE_SHOULD_CREATE_MR, false);
+ } else if (!interactedWithNewMR) {
+ commit(types.TOGGLE_SHOULD_CREATE_MR, true);
+ }
};
export const updateBranchName = ({ commit }, branchName) => {
commit(types.UPDATE_NEW_BRANCH_NAME, branchName);
};
-export const setLastCommitMessage = ({ rootState, commit }, data) => {
- const currentProject = rootState.projects[rootState.currentProjectId];
+export const setLastCommitMessage = ({ commit, rootGetters }, data) => {
+ const { currentProject } = rootGetters;
const commitStats = data.stats
? sprintf(__('with %{additions} additions, %{deletions} deletions.'), {
additions: data.stats.additions,
@@ -48,8 +74,8 @@ export const setLastCommitMessage = ({ rootState, commit }, data) => {
commit(rootTypes.SET_LAST_COMMIT_MSG, commitMsg, { root: true });
};
-export const updateFilesAfterCommit = ({ commit, dispatch, rootState }, { data }) => {
- const selectedProject = rootState.projects[rootState.currentProjectId];
+export const updateFilesAfterCommit = ({ commit, dispatch, rootState, rootGetters }, { data }) => {
+ const selectedProject = rootGetters.currentProject;
const lastCommit = {
commit_path: `${selectedProject.web_url}/commit/${data.id}`,
commit: {
@@ -95,7 +121,7 @@ export const updateFilesAfterCommit = ({ commit, dispatch, rootState }, { data }
eventHub.$emit(`editor.update.model.content.${file.key}`, {
content: file.content,
- changed: !!changedFile,
+ changed: Boolean(changedFile),
});
});
};
@@ -128,6 +154,17 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
return null;
}
+ if (!data.parent_ids.length) {
+ commit(
+ rootTypes.TOGGLE_EMPTY_STATE,
+ {
+ projectPath: rootState.currentProjectId,
+ value: false,
+ },
+ { root: true },
+ );
+ }
+
dispatch('setLastCommitMessage', data);
dispatch('updateCommitMessage', '');
return dispatch('updateFilesAfterCommit', {
@@ -135,14 +172,15 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
branch: getters.branchName,
})
.then(() => {
- if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR) {
+ if (state.shouldCreateMR) {
+ const { currentProject } = rootGetters;
+ const targetBranch = getters.isCreatingNewBranch
+ ? rootState.currentBranchId
+ : currentProject.default_branch;
+
dispatch(
'redirectToUrl',
- createNewMergeRequestUrl(
- rootState.projects[rootState.currentProjectId].web_url,
- getters.branchName,
- rootState.currentBranchId,
- ),
+ createNewMergeRequestUrl(currentProject.web_url, getters.branchName, targetBranch),
{ root: true },
);
}
diff --git a/app/assets/javascripts/ide/stores/modules/commit/constants.js b/app/assets/javascripts/ide/stores/modules/commit/constants.js
index 230b0a3d9b5..c6c3701effe 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/constants.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/constants.js
@@ -1,3 +1,7 @@
-export const COMMIT_TO_CURRENT_BRANCH = '1';
-export const COMMIT_TO_NEW_BRANCH = '2';
-export const COMMIT_TO_NEW_BRANCH_MR = '3';
+const COMMIT_TO_CURRENT_BRANCH = '1';
+const COMMIT_TO_NEW_BRANCH = '2';
+
+export default {
+ COMMIT_TO_CURRENT_BRANCH,
+ COMMIT_TO_NEW_BRANCH,
+};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js
index 03777e6c10b..64779e9e4df 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/getters.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js
@@ -1,5 +1,5 @@
import { sprintf, n__, __ } from '../../../../locale';
-import * as consts from './constants';
+import consts from './constants';
const BRANCH_SUFFIX_COUNT = 5;
const createTranslatedTextForFiles = (files, text) => {
@@ -14,18 +14,15 @@ const createTranslatedTextForFiles = (files, text) => {
export const discardDraftButtonDisabled = state =>
state.commitMessage === '' || state.submitCommitLoading;
-export const newBranchName = (state, _, rootState) =>
+export const placeholderBranchName = (state, _, rootState) =>
`${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr(
-BRANCH_SUFFIX_COUNT,
)}`;
export const branchName = (state, getters, rootState) => {
- if (
- state.commitAction === consts.COMMIT_TO_NEW_BRANCH ||
- state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR
- ) {
+ if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH) {
if (state.newBranchName === '') {
- return getters.newBranchName;
+ return getters.placeholderBranchName;
}
return state.newBranchName;
@@ -49,5 +46,13 @@ export const preBuiltCommitMessage = (state, _, rootState) => {
.join('\n');
};
+export const isCreatingNewBranch = state => state.commitAction === consts.COMMIT_TO_NEW_BRANCH;
+
+export const isCommittingToCurrentBranch = state =>
+ state.commitAction === consts.COMMIT_TO_CURRENT_BRANCH;
+
+export const isCommittingToDefaultBranch = (_state, getters, _rootState, rootGetters) =>
+ getters.isCommittingToCurrentBranch && rootGetters.isOnDefaultBranch;
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js
index 9221f054e9f..b81918156b0 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js
@@ -2,3 +2,5 @@ export const UPDATE_COMMIT_MESSAGE = 'UPDATE_COMMIT_MESSAGE';
export const UPDATE_COMMIT_ACTION = 'UPDATE_COMMIT_ACTION';
export const UPDATE_NEW_BRANCH_NAME = 'UPDATE_NEW_BRANCH_NAME';
export const UPDATE_LOADING = 'UPDATE_LOADING';
+export const TOGGLE_SHOULD_CREATE_MR = 'TOGGLE_SHOULD_CREATE_MR';
+export const INTERACT_WITH_NEW_MR = 'INTERACT_WITH_NEW_MR';
diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutations.js b/app/assets/javascripts/ide/stores/modules/commit/mutations.js
index 797357e3df9..14957d283bb 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/mutations.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/mutations.js
@@ -6,10 +6,8 @@ export default {
commitMessage,
});
},
- [types.UPDATE_COMMIT_ACTION](state, commitAction) {
- Object.assign(state, {
- commitAction,
- });
+ [types.UPDATE_COMMIT_ACTION](state, { commitAction }) {
+ Object.assign(state, { commitAction });
},
[types.UPDATE_NEW_BRANCH_NAME](state, newBranchName) {
Object.assign(state, {
@@ -21,4 +19,12 @@ export default {
submitCommitLoading,
});
},
+ [types.TOGGLE_SHOULD_CREATE_MR](state, shouldCreateMR) {
+ Object.assign(state, {
+ shouldCreateMR: shouldCreateMR === undefined ? !state.shouldCreateMR : shouldCreateMR,
+ });
+ },
+ [types.INTERACT_WITH_NEW_MR](state) {
+ Object.assign(state, { interactedWithNewMR: true });
+ },
};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/state.js b/app/assets/javascripts/ide/stores/modules/commit/state.js
index 8dae50961b0..53647a7e3e3 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/state.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/state.js
@@ -3,4 +3,6 @@ export default () => ({
commitAction: '1',
newBranchName: '',
submitCommitLoading: false,
+ shouldCreateMR: false,
+ interactedWithNewMR: false,
});
diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/actions.js b/app/assets/javascripts/ide/stores/modules/file_templates/actions.js
index b7090e09daf..59ead8a3dcf 100644
--- a/app/assets/javascripts/ide/stores/modules/file_templates/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/file_templates/actions.js
@@ -23,22 +23,27 @@ export const receiveTemplateTypesError = ({ commit, dispatch }) => {
export const receiveTemplateTypesSuccess = ({ commit }, templates) =>
commit(types.RECEIVE_TEMPLATE_TYPES_SUCCESS, templates);
-export const fetchTemplateTypes = ({ dispatch, state, rootState }, page = 1) => {
+export const fetchTemplateTypes = ({ dispatch, state, rootState }) => {
if (!Object.keys(state.selectedTemplateType).length) return Promise.reject();
dispatch('requestTemplateTypes');
- return Api.projectTemplates(rootState.currentProjectId, state.selectedTemplateType.key, { page })
- .then(({ data, headers }) => {
- const nextPage = parseInt(normalizeHeaders(headers)['X-NEXT-PAGE'], 10);
+ const fetchPages = (page = 1, prev = []) =>
+ Api.projectTemplates(rootState.currentProjectId, state.selectedTemplateType.key, {
+ page,
+ per_page: 100,
+ })
+ .then(({ data, headers }) => {
+ const nextPage = parseInt(normalizeHeaders(headers)['X-NEXT-PAGE'], 10);
+ const nextData = prev.concat(data);
- dispatch('receiveTemplateTypesSuccess', data);
+ dispatch('receiveTemplateTypesSuccess', nextData);
- if (nextPage) {
- dispatch('fetchTemplateTypes', nextPage);
- }
- })
- .catch(() => dispatch('receiveTemplateTypesError'));
+ return nextPage ? fetchPages(nextPage, nextData) : nextData;
+ })
+ .catch(() => dispatch('receiveTemplateTypesError'));
+
+ return fetchPages();
};
export const setSelectedTemplateType = ({ commit, dispatch, rootGetters }, type) => {
diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js
index 628babe6a01..f10891a8e5b 100644
--- a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js
+++ b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js
@@ -1,4 +1,5 @@
import { activityBarViews } from '../../../constants';
+import { __ } from '~/locale';
export const templateTypes = () => [
{
@@ -10,11 +11,11 @@ export const templateTypes = () => [
key: 'gitignores',
},
{
- name: 'LICENSE',
+ name: __('LICENSE'),
key: 'licenses',
},
{
- name: 'Dockerfile',
+ name: __('Dockerfile'),
key: 'dockerfiles',
},
];
diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/mutations.js b/app/assets/javascripts/ide/stores/modules/file_templates/mutations.js
index 25a65b047f1..7fc1c9134a7 100644
--- a/app/assets/javascripts/ide/stores/modules/file_templates/mutations.js
+++ b/app/assets/javascripts/ide/stores/modules/file_templates/mutations.js
@@ -3,13 +3,14 @@ import * as types from './mutation_types';
export default {
[types.REQUEST_TEMPLATE_TYPES](state) {
state.isLoading = true;
+ state.templates = [];
},
[types.RECEIVE_TEMPLATE_TYPES_ERROR](state) {
state.isLoading = false;
},
[types.RECEIVE_TEMPLATE_TYPES_SUCCESS](state, templates) {
state.isLoading = false;
- state.templates = state.templates.concat(templates);
+ state.templates = templates;
},
[types.SET_SELECTED_TEMPLATE_TYPE](state, type) {
state.selectedTemplateType = type;
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/getters.js b/app/assets/javascripts/ide/stores/modules/pipelines/getters.js
index ef7cd4ff8e8..1d127d915d7 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/getters.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/getters.js
@@ -1,6 +1,6 @@
import { states } from './constants';
-export const hasLatestPipeline = state => !state.isLoadingPipeline && !!state.latestPipeline;
+export const hasLatestPipeline = state => !state.isLoadingPipeline && Boolean(state.latestPipeline);
export const pipelineFailed = state =>
state.latestPipeline && state.latestPipeline.details.status.text === states.failed;
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js b/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js
index b4be100cb07..eaaa82cb339 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js
@@ -10,6 +10,7 @@ export default {
},
[types.RECEIVE_LASTEST_PIPELINE_SUCCESS](state, pipeline) {
state.isLoadingPipeline = false;
+ state.hasLoadedPipeline = true;
if (pipeline) {
state.latestPipeline = {
@@ -34,7 +35,7 @@ export default {
};
});
} else {
- state.latestPipeline = false;
+ state.latestPipeline = null;
}
},
[types.REQUEST_JOBS](state, id) {
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/state.js b/app/assets/javascripts/ide/stores/modules/pipelines/state.js
index 8651e267b53..8dfa0ec491f 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/state.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/state.js
@@ -1,5 +1,6 @@
export default () => ({
isLoadingPipeline: true,
+ hasLoadedPipeline: false,
isLoadingJobs: false,
latestPipeline: null,
stages: [],
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index a5f8098dc17..86ab76136df 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -12,6 +12,7 @@ export const SET_LINKS = 'SET_LINKS';
export const SET_PROJECT = 'SET_PROJECT';
export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT';
export const TOGGLE_PROJECT_OPEN = 'TOGGLE_PROJECT_OPEN';
+export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE';
// Merge Request Mutation Types
export const SET_MERGE_REQUEST = 'SET_MERGE_REQUEST';
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index 78cdfda74f0..ae42b87c9a7 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -142,7 +142,7 @@ export default {
Object.assign(state.entries[file.path], {
raw: file.content,
- changed: !!changedFile,
+ changed: Boolean(changedFile),
staged: false,
prevPath: '',
moved: false,
@@ -206,19 +206,17 @@ export default {
}
}
},
- [types.RENAME_ENTRY](state, { path, name, entryPath = null }) {
+ [types.RENAME_ENTRY](state, { path, name, entryPath = null, parentPath }) {
const oldEntry = state.entries[entryPath || path];
- const nameRegex =
- !entryPath && oldEntry.type === 'blob'
- ? new RegExp(`${oldEntry.name}$`)
- : new RegExp(`^${path}`);
- const newPath = oldEntry.path.replace(nameRegex, name);
- const parentPath = oldEntry.parentPath ? oldEntry.parentPath.replace(nameRegex, name) : '';
+ const slashedParentPath = parentPath ? `${parentPath}/` : '';
+ const newPath = entryPath
+ ? `${slashedParentPath}${oldEntry.name}`
+ : `${slashedParentPath}${name}`;
- state.entries[newPath] = {
+ Vue.set(state.entries, newPath, {
...oldEntry,
id: newPath,
- key: `${name}-${oldEntry.type}-${oldEntry.id}`,
+ key: `${newPath}-${oldEntry.type}-${oldEntry.id}`,
path: newPath,
name: entryPath ? oldEntry.name : name,
tempFile: true,
@@ -227,7 +225,8 @@ export default {
tree: [],
parentPath,
raw: '',
- };
+ });
+
oldEntry.moved = true;
oldEntry.movedPath = newPath;
@@ -256,6 +255,7 @@ export default {
Vue.delete(state.entries, oldEntry.path);
}
},
+
...projectMutations,
...mergeRequestMutation,
...fileMutations,
diff --git a/app/assets/javascripts/ide/stores/mutations/branch.js b/app/assets/javascripts/ide/stores/mutations/branch.js
index e09f88878f4..6afd8de2aa4 100644
--- a/app/assets/javascripts/ide/stores/mutations/branch.js
+++ b/app/assets/javascripts/ide/stores/mutations/branch.js
@@ -19,6 +19,12 @@ export default {
});
},
[types.SET_BRANCH_WORKING_REFERENCE](state, { projectId, branchId, reference }) {
+ if (!state.projects[projectId].branches[branchId]) {
+ Object.assign(state.projects[projectId].branches, {
+ [branchId]: {},
+ });
+ }
+
Object.assign(state.projects[projectId].branches[branchId], {
workingReference: reference,
});
diff --git a/app/assets/javascripts/ide/stores/mutations/merge_request.js b/app/assets/javascripts/ide/stores/mutations/merge_request.js
index 334819fe702..e5b5107bc93 100644
--- a/app/assets/javascripts/ide/stores/mutations/merge_request.js
+++ b/app/assets/javascripts/ide/stores/mutations/merge_request.js
@@ -7,6 +7,8 @@ export default {
});
},
[types.SET_MERGE_REQUEST](state, { projectPath, mergeRequestId, mergeRequest }) {
+ const existingMergeRequest = state.projects[projectPath].mergeRequests[mergeRequestId] || {};
+
Object.assign(state.projects[projectPath], {
mergeRequests: {
[mergeRequestId]: {
@@ -15,6 +17,7 @@ export default {
changes: [],
versions: [],
baseCommitSha: null,
+ ...existingMergeRequest,
},
},
});
diff --git a/app/assets/javascripts/ide/stores/mutations/project.js b/app/assets/javascripts/ide/stores/mutations/project.js
index 284b39a2c72..9230f3839c1 100644
--- a/app/assets/javascripts/ide/stores/mutations/project.js
+++ b/app/assets/javascripts/ide/stores/mutations/project.js
@@ -21,4 +21,9 @@ export default {
}),
});
},
+ [types.TOGGLE_EMPTY_STATE](state, { projectPath, value }) {
+ Object.assign(state.projects[projectPath], {
+ empty_repo: value,
+ });
+ },
};
diff --git a/app/assets/javascripts/ide/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js
index eac7441ee54..359943b4ab7 100644
--- a/app/assets/javascripts/ide/stores/mutations/tree.js
+++ b/app/assets/javascripts/ide/stores/mutations/tree.js
@@ -1,5 +1,5 @@
import * as types from '../mutation_types';
-import { sortTree } from '../utils';
+import { sortTree, mergeTrees } from '../utils';
export default {
[types.TOGGLE_TREE_OPEN](state, path) {
@@ -23,9 +23,15 @@ export default {
});
},
[types.SET_DIRECTORY_DATA](state, { data, treePath }) {
- Object.assign(state.trees[treePath], {
- tree: data,
- });
+ const selectedTree = state.trees[treePath];
+
+ // If we opened files while loading the tree, we need to merge them
+ // Otherwise, simply overwrite the tree
+ const tree = !selectedTree.tree.length
+ ? data
+ : selectedTree.loading && mergeTrees(selectedTree.tree, data);
+
+ Object.assign(selectedTree, { tree });
},
[types.SET_LAST_COMMIT_URL](state, { tree = state, url }) {
Object.assign(tree, {
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index 0ede76fd1e0..bcc9ca60d9b 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -1,3 +1,5 @@
+import { commitActionTypes } from '../constants';
+
export const dataStructure = () => ({
id: '',
// Key will contain a mixture of ID and path
@@ -69,14 +71,15 @@ export const decorateData = entity => {
changed = false,
parentTreeUrl = '',
base64 = false,
+ binary = false,
+ rawPath = '',
previewMode,
file_lock,
html,
parentPath = '',
} = entity;
- return {
- ...dataStructure(),
+ return Object.assign(dataStructure(), {
id,
projectId,
branchId,
@@ -93,11 +96,13 @@ export const decorateData = entity => {
renderError,
content,
base64,
+ binary,
+ rawPath,
previewMode,
file_lock,
html,
parentPath,
- };
+ });
};
export const findEntry = (tree, type, name, prop = 'name') =>
@@ -111,14 +116,14 @@ export const setPageTitle = title => {
export const commitActionForFile = file => {
if (file.prevPath) {
- return 'move';
+ return commitActionTypes.move;
} else if (file.deleted) {
- return 'delete';
+ return commitActionTypes.delete;
} else if (file.tempFile) {
- return 'create';
+ return commitActionTypes.create;
}
- return 'update';
+ return commitActionTypes.update;
};
export const getCommitFiles = stagedFiles =>
@@ -171,3 +176,31 @@ export const filePathMatches = (filePath, path) => filePath.indexOf(`${path}/`)
export const getChangesCountForFiles = (files, path) =>
files.filter(f => filePathMatches(f.path, path)).length;
+
+export const mergeTrees = (fromTree, toTree) => {
+ if (!fromTree || !fromTree.length) {
+ return toTree;
+ }
+
+ const recurseTree = (n, t) => {
+ if (!n) {
+ return t;
+ }
+ const existingTreeNode = t.find(el => el.path === n.path);
+
+ if (existingTreeNode && n.tree.length > 0) {
+ existingTreeNode.opened = true;
+ recurseTree(n.tree[0], existingTreeNode.tree);
+ } else if (!existingTreeNode) {
+ const sorted = sortTree(t.concat(n));
+ t.splice(0, t.length + 1, ...sorted);
+ }
+ return t;
+ };
+
+ for (let i = 0, l = fromTree.length; i < l; i += 1) {
+ recurseTree(fromTree[i], toTree);
+ }
+
+ return toTree;
+};
diff --git a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js
deleted file mode 100644
index fa35c215880..00000000000
--- a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js
+++ /dev/null
@@ -1,100 +0,0 @@
-import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
-import { decorateData, sortTree } from '../utils';
-
-// eslint-disable-next-line no-restricted-globals
-self.addEventListener('message', e => {
- const { data, projectId, branchId, tempFile = false, content = '', base64 = false } = e.data;
-
- const treeList = [];
- let file;
- let parentPath;
- const entries = data.reduce((acc, path) => {
- const pathSplit = path.split('/');
- const blobName = pathSplit.pop().trim();
-
- if (pathSplit.length > 0) {
- pathSplit.reduce((pathAcc, folderName) => {
- const parentFolder = acc[pathAcc[pathAcc.length - 1]];
- const folderPath = `${parentFolder ? `${parentFolder.path}/` : ''}${folderName}`;
- const foundEntry = acc[folderPath];
-
- if (!foundEntry) {
- parentPath = parentFolder ? parentFolder.path : null;
-
- const tree = decorateData({
- projectId,
- branchId,
- id: folderPath,
- name: folderName,
- path: folderPath,
- url: `/${projectId}/tree/${branchId}/-/${folderPath}/`,
- type: 'tree',
- parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`,
- tempFile,
- changed: tempFile,
- opened: tempFile,
- parentPath,
- });
-
- Object.assign(acc, {
- [folderPath]: tree,
- });
-
- if (parentFolder) {
- parentFolder.tree.push(tree);
- } else {
- treeList.push(tree);
- }
-
- pathAcc.push(tree.path);
- } else {
- pathAcc.push(foundEntry.path);
- }
-
- return pathAcc;
- }, []);
- }
-
- if (blobName !== '') {
- const fileFolder = acc[pathSplit.join('/')];
- parentPath = fileFolder ? fileFolder.path : null;
-
- file = decorateData({
- projectId,
- branchId,
- id: path,
- name: blobName,
- path,
- url: `/${projectId}/blob/${branchId}/-/${path}`,
- type: 'blob',
- parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`,
- tempFile,
- changed: tempFile,
- content,
- base64,
- previewMode: viewerInformationForPath(blobName),
- parentPath,
- });
-
- Object.assign(acc, {
- [path]: file,
- });
-
- if (fileFolder) {
- fileFolder.tree.push(file);
- } else {
- treeList.push(file);
- }
- }
-
- return acc;
- }, {});
-
- // eslint-disable-next-line no-restricted-globals
- self.postMessage({
- entries,
- treeList: sortTree(treeList),
- file,
- parentPath,
- });
-});
diff --git a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
index 05000c73052..7051a968dac 100644
--- a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
@@ -14,7 +14,7 @@ export function addCommentIndicator(containerEl, { x, y }) {
export function removeCommentIndicator(imageFrameEl) {
const commentIndicatorEl = imageFrameEl.querySelector('.comment-indicator');
const imageEl = imageFrameEl.querySelector('img');
- const willRemove = !!commentIndicatorEl;
+ const willRemove = Boolean(commentIndicatorEl);
let meta = {};
if (willRemove) {
diff --git a/app/assets/javascripts/image_diff/image_diff.js b/app/assets/javascripts/image_diff/image_diff.js
index 3587f073a00..26c1b0ec7be 100644
--- a/app/assets/javascripts/image_diff/image_diff.js
+++ b/app/assets/javascripts/image_diff/image_diff.js
@@ -6,8 +6,8 @@ import { isImageLoaded } from '../lib/utils/image_utility';
export default class ImageDiff {
constructor(el, options) {
this.el = el;
- this.canCreateNote = !!(options && options.canCreateNote);
- this.renderCommentBadge = !!(options && options.renderCommentBadge);
+ this.canCreateNote = Boolean(options && options.canCreateNote);
+ this.renderCommentBadge = Boolean(options && options.renderCommentBadge);
this.$noteContainer = $('.note-container', this.el);
this.imageBadges = [];
}
diff --git a/app/assets/javascripts/image_diff/view_types.js b/app/assets/javascripts/image_diff/view_types.js
index ab0a595571f..1a5123de220 100644
--- a/app/assets/javascripts/image_diff/view_types.js
+++ b/app/assets/javascripts/image_diff/view_types.js
@@ -5,5 +5,5 @@ export const viewTypes = {
};
export function isValidViewType(validate) {
- return !!Object.getOwnPropertyNames(viewTypes).find(viewType => viewType === validate);
+ return Boolean(Object.getOwnPropertyNames(viewTypes).find(viewType => viewType === validate));
}
diff --git a/app/assets/javascripts/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_projects/components/import_projects_table.vue
index 777f8fa6691..00eb0afb3bf 100644
--- a/app/assets/javascripts/import_projects/components/import_projects_table.vue
+++ b/app/assets/javascripts/import_projects/components/import_projects_table.vue
@@ -74,7 +74,7 @@ export default {
<gl-loading-icon
v-if="isLoadingRepos"
class="js-loading-button-icon import-projects-loading-icon"
- :size="4"
+ size="md"
/>
<div v-else-if="hasProviderRepos || hasImportedProjects" class="table-responsive">
<table class="table import-table">
diff --git a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue
index 7cc29fa1b91..3c6c9c71b8c 100644
--- a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue
+++ b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue
@@ -41,7 +41,7 @@ export default {
return {
data: this.namespaceSelectOptions,
containerCssClass:
- 'import-namespace-select js-namespace-select qa-project-namespace-select',
+ 'import-namespace-select js-namespace-select qa-project-namespace-select w-auto',
};
},
diff --git a/app/assets/javascripts/import_projects/index.js b/app/assets/javascripts/import_projects/index.js
index 5c77484aee1..2d99d716609 100644
--- a/app/assets/javascripts/import_projects/index.js
+++ b/app/assets/javascripts/import_projects/index.js
@@ -3,7 +3,7 @@ import { mapActions } from 'vuex';
import Translate from '../vue_shared/translate';
import ImportProjectsTable from './components/import_projects_table.vue';
import { parseBoolean } from '../lib/utils/common_utils';
-import store from './store';
+import createStore from './store';
Vue.use(Translate);
@@ -20,6 +20,7 @@ export default function mountImportProjectsTable(mountElement) {
ciCdOnly,
} = mountElement.dataset;
+ const store = createStore();
return new Vue({
el: mountElement,
store,
diff --git a/app/assets/javascripts/import_projects/store/getters.js b/app/assets/javascripts/import_projects/store/getters.js
index f03474a8404..727b80765bd 100644
--- a/app/assets/javascripts/import_projects/store/getters.js
+++ b/app/assets/javascripts/import_projects/store/getters.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export const namespaceSelectOptions = state => {
const serializedNamespaces = state.namespaces.map(({ fullPath }) => ({
id: fullPath,
@@ -5,9 +7,9 @@ export const namespaceSelectOptions = state => {
}));
return [
- { text: 'Groups', children: serializedNamespaces },
+ { text: __('Groups'), children: serializedNamespaces },
{
- text: 'Users',
+ text: __('Users'),
children: [{ id: state.defaultTargetNamespace, text: state.defaultTargetNamespace }],
},
];
diff --git a/app/assets/javascripts/import_projects/store/index.js b/app/assets/javascripts/import_projects/store/index.js
index 6ac9bfd8189..ff1fd1e598e 100644
--- a/app/assets/javascripts/import_projects/store/index.js
+++ b/app/assets/javascripts/import_projects/store/index.js
@@ -7,9 +7,12 @@ import mutations from './mutations';
Vue.use(Vuex);
-export default new Vuex.Store({
- state: state(),
- actions,
- mutations,
- getters,
-});
+export { state, actions, getters, mutations };
+
+export default () =>
+ new Vuex.Store({
+ state: state(),
+ actions,
+ mutations,
+ getters,
+ });
diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js
index 08b858305ab..a7746bb3a0b 100644
--- a/app/assets/javascripts/integrations/integration_settings_form.js
+++ b/app/assets/javascripts/integrations/integration_settings_form.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import axios from '../lib/utils/axios_utils';
import flash from '../flash';
+import { __ } from '~/locale';
export default class IntegrationSettingsForm {
constructor(formSelector) {
@@ -65,10 +66,10 @@ export default class IntegrationSettingsForm {
* Toggle Submit button label based on Integration status and ability to test service
*/
toggleSubmitBtnLabel(serviceActive) {
- let btnLabel = 'Save changes';
+ let btnLabel = __('Save changes');
if (serviceActive && this.canTestService) {
- btnLabel = 'Test settings and save changes';
+ btnLabel = __('Test settings and save changes');
}
this.$submitBtnLabel.text(btnLabel);
@@ -105,7 +106,7 @@ export default class IntegrationSettingsForm {
if (data.test_failed) {
flashActions = {
- title: 'Save anyway',
+ title: __('Save anyway'),
clickHandler: e => {
e.preventDefault();
this.$form.submit();
@@ -121,7 +122,7 @@ export default class IntegrationSettingsForm {
this.toggleSubmitBtnState(false);
})
.catch(() => {
- flash('Something went wrong on our end.');
+ flash(__('Something went wrong on our end.'));
this.toggleSubmitBtnState(false);
});
}
diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js
index b844e4c5e5b..bc9d7fcf30d 100644
--- a/app/assets/javascripts/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable_bulk_update_actions.js
@@ -4,6 +4,7 @@ import $ from 'jquery';
import _ from 'underscore';
import axios from './lib/utils/axios_utils';
import Flash from './flash';
+import { __ } from './locale';
export default {
init({ container, form, issues, prefixId } = {}) {
@@ -32,7 +33,7 @@ export default {
onFormSubmitFailure() {
this.form.find('[type="submit"]').enable();
- return new Flash('Issue update failed');
+ return new Flash(__('Issue update failed'));
},
getSelectedIssues() {
@@ -81,9 +82,6 @@ export default {
const formData = {
update: {
state_event: this.form.find('input[name="update[state_event]"]').val(),
- // For Merge Requests
- assignee_id: this.form.find('input[name="update[assignee_id]"]').val(),
- // For Issues
assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()],
milestone_id: this.form.find('input[name="update[milestone_id]"]').val(),
issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(),
diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js
index 9336b71cfd7..7576d36f27d 100644
--- a/app/assets/javascripts/issuable_form.js
+++ b/app/assets/javascripts/issuable_form.js
@@ -1,8 +1,8 @@
import $ from 'jquery';
import Pikaday from 'pikaday';
+import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
import Autosave from './autosave';
import UsersSelect from './users_select';
-import GfmAutoComplete from './gfm_auto_complete';
import ZenMode from './zen_mode';
import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility';
diff --git a/app/assets/javascripts/issuable_index.js b/app/assets/javascripts/issuable_index.js
index ffcbd7cf28c..16f88cddce3 100644
--- a/app/assets/javascripts/issuable_index.js
+++ b/app/assets/javascripts/issuable_index.js
@@ -1,7 +1,7 @@
import $ from 'jquery';
import axios from './lib/utils/axios_utils';
import flash from './flash';
-import { __ } from './locale';
+import { s__, __ } from './locale';
import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
@@ -12,7 +12,7 @@ export default class IssuableIndex {
}
initBulkUpdate(pagePrefix) {
const userCanBulkUpdate = $('.issues-bulk-update').length > 0;
- const alreadyInitialized = !!this.bulkUpdateSidebar;
+ const alreadyInitialized = Boolean(this.bulkUpdateSidebar);
if (userCanBulkUpdate && !alreadyInitialized) {
IssuableBulkUpdateActions.init({
@@ -29,7 +29,7 @@ export default class IssuableIndex {
$resetToken.on('click', e => {
e.preventDefault();
- $resetToken.text('resetting...');
+ $resetToken.text(s__('EmailToken|resetting...'));
axios
.put($resetToken.attr('href'))
@@ -38,12 +38,12 @@ export default class IssuableIndex {
.val(data.new_address)
.focus();
- $resetToken.text('reset it');
+ $resetToken.text(s__('EmailToken|reset it'));
})
.catch(() => {
flash(__('There was an error when reseting email token.'));
- $resetToken.text('reset it');
+ $resetToken.text(s__('EmailToken|reset it'));
});
});
}
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index 94b78907d9a..db4607ca58d 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -7,6 +7,7 @@ import flash from './flash';
import TaskList from './task_list';
import CreateMergeRequestDropdown from './create_merge_request_dropdown';
import IssuablesHelper from './helpers/issuables_helper';
+import { __ } from './locale';
export default class Issue {
constructor() {
@@ -15,8 +16,9 @@ export default class Issue {
Issue.$btnNewBranch = $('#new-branch');
Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap');
- Issue.initMergeRequests();
- Issue.initRelatedBranches();
+ if (document.querySelector('#related-branches')) {
+ Issue.initRelatedBranches();
+ }
this.closeButtons = $('a.btn-close');
this.reopenButtons = $('a.btn-reopen');
@@ -43,7 +45,11 @@ export default class Issue {
* @param {Array} data
* @param {String} issueFailMessage
*/
- updateTopState(isClosed, data, issueFailMessage = 'Unable to update this issue at this time.') {
+ updateTopState(
+ isClosed,
+ data,
+ issueFailMessage = __('Unable to update this issue at this time.'),
+ ) {
if ('id' in data) {
const isClosedBadge = $('div.status-box-issue-closed');
const isOpenBadge = $('div.status-box-open');
@@ -80,7 +86,7 @@ export default class Issue {
}
initIssueBtnEventListeners() {
- const issueFailMessage = 'Unable to update this issue at this time.';
+ const issueFailMessage = __('Unable to update this issue at this time.');
return $(document).on(
'click',
@@ -141,19 +147,6 @@ export default class Issue {
}
}
- static initMergeRequests() {
- var $container;
- $container = $('#merge-requests');
- return axios
- .get($container.data('url'))
- .then(({ data }) => {
- if ('html' in data) {
- $container.html(data.html);
- }
- })
- .catch(() => flash('Failed to load referenced merge requests'));
- }
-
static initRelatedBranches() {
var $container;
$container = $('#related-branches');
@@ -164,6 +157,6 @@ export default class Issue {
$container.html(data.html);
}
})
- .catch(() => flash('Failed to load related branches'));
+ .catch(() => flash(__('Failed to load related branches')));
}
}
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index bd757a76ee7..e88ca4747c5 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -156,12 +156,26 @@ export default {
return this.store.formState;
},
hasUpdated() {
- return !!this.state.updatedAt;
+ return Boolean(this.state.updatedAt);
},
issueChanged() {
- const descriptionChanged = this.initialDescriptionText !== this.store.formState.description;
- const titleChanged = this.initialTitleText !== this.store.formState.title;
- return descriptionChanged || titleChanged;
+ const {
+ store: {
+ formState: { description, title },
+ },
+ initialDescriptionText,
+ initialTitleText,
+ } = this;
+
+ if (initialDescriptionText || description) {
+ return initialDescriptionText !== description;
+ }
+
+ if (initialTitleText || title) {
+ return initialTitleText !== title;
+ }
+
+ return false;
},
defaultErrorMessage() {
return sprintf(s__('Error updating %{issuableType}'), { issuableType: this.issuableType });
diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue
index 58f14bac8c8..f2462e50093 100644
--- a/app/assets/javascripts/issue_show/components/description.vue
+++ b/app/assets/javascripts/issue_show/components/description.vue
@@ -140,14 +140,16 @@ export default {
'issue-realtime-pre-pulse': preAnimation,
'issue-realtime-trigger-pulse': pulseAnimation,
}"
- class="wiki"
+ class="md"
v-html="descriptionHtml"
></div>
<textarea
v-if="descriptionText"
+ ref="textarea"
v-model="descriptionText"
:data-update-url="updateUrl"
class="hidden js-task-list-field"
+ dir="auto"
>
</textarea>
diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue
index 299130e56ae..d27dd873125 100644
--- a/app/assets/javascripts/issue_show/components/fields/description.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description.vue
@@ -53,6 +53,7 @@ export default {
v-model="formState.description"
class="note-textarea js-gfm-input js-autosize markdown-area
qa-description-textarea"
+ dir="auto"
data-supports-quick-actions="false"
aria-label="Description"
placeholder="Write a comment or drag your files here…"
diff --git a/app/assets/javascripts/issue_show/components/fields/title.vue b/app/assets/javascripts/issue_show/components/fields/title.vue
index c3d7ba4907f..ce4baf17d09 100644
--- a/app/assets/javascripts/issue_show/components/fields/title.vue
+++ b/app/assets/javascripts/issue_show/components/fields/title.vue
@@ -17,8 +17,10 @@ export default {
<label class="sr-only" for="issuable-title"> Title </label>
<input
id="issuable-title"
+ ref="input"
v-model="formState.title"
class="form-control qa-title-input"
+ dir="auto"
type="text"
placeholder="Title"
aria-label="Title"
diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue
index eade31f1d14..528ccb77efc 100644
--- a/app/assets/javascripts/issue_show/components/form.vue
+++ b/app/assets/javascripts/issue_show/components/form.vue
@@ -1,9 +1,12 @@
<script>
+import $ from 'jquery';
import lockedWarning from './locked_warning.vue';
import titleField from './fields/title.vue';
import descriptionField from './fields/description.vue';
import editActions from './edit_actions.vue';
import descriptionTemplate from './fields/description_template.vue';
+import Autosave from '~/autosave';
+import eventHub from '../event_hub';
export default {
components: {
@@ -68,6 +71,47 @@ export default {
return this.issuableTemplates.length;
},
},
+ created() {
+ eventHub.$on('delete.issuable', this.resetAutosave);
+ eventHub.$on('update.issuable', this.resetAutosave);
+ eventHub.$on('close.form', this.resetAutosave);
+ },
+ mounted() {
+ this.initAutosave();
+ },
+ beforeDestroy() {
+ eventHub.$off('delete.issuable', this.resetAutosave);
+ eventHub.$off('update.issuable', this.resetAutosave);
+ eventHub.$off('close.form', this.resetAutosave);
+ },
+ methods: {
+ initAutosave() {
+ const {
+ description: {
+ $refs: { textarea },
+ },
+ title: {
+ $refs: { input },
+ },
+ } = this.$refs;
+
+ this.autosaveDescription = new Autosave($(textarea), [
+ document.location.pathname,
+ document.location.search,
+ 'description',
+ ]);
+
+ this.autosaveTitle = new Autosave($(input), [
+ document.location.pathname,
+ document.location.search,
+ 'title',
+ ]);
+ },
+ resetAutosave() {
+ this.autosaveDescription.reset();
+ this.autosaveTitle.reset();
+ },
+ },
};
</script>
@@ -89,10 +133,11 @@ export default {
'col-12': !hasIssuableTemplates,
}"
>
- <title-field :form-state="formState" :issuable-templates="issuableTemplates" />
+ <title-field ref="title" :form-state="formState" :issuable-templates="issuableTemplates" />
</div>
</div>
<description-field
+ ref="description"
:form-state="formState"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue
index 3b5c95ccded..1e1dce5f4fc 100644
--- a/app/assets/javascripts/issue_show/components/title.vue
+++ b/app/assets/javascripts/issue_show/components/title.vue
@@ -71,7 +71,8 @@ export default {
'issue-realtime-pre-pulse': preAnimation,
'issue-realtime-trigger-pulse': pulseAnimation,
}"
- class="title"
+ class="title qa-title"
+ dir="auto"
v-html="titleHtml"
></h2>
<button
diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js
index d08e8ba0c4b..529b6386221 100644
--- a/app/assets/javascripts/issue_show/index.js
+++ b/app/assets/javascripts/issue_show/index.js
@@ -1,12 +1,9 @@
import Vue from 'vue';
-import sanitize from 'sanitize-html';
import issuableApp from './components/app.vue';
+import { parseIssuableData } from './utils/parse_data';
import '../vue_shared/vue_resource_interceptor';
export default function initIssueableApp() {
- const initialDataEl = document.getElementById('js-issuable-app-initial-data');
- const props = JSON.parse(sanitize(initialDataEl.textContent).replace(/&quot;/g, '"'));
-
return new Vue({
el: document.getElementById('js-issuable-app'),
components: {
@@ -14,7 +11,7 @@ export default function initIssueableApp() {
},
render(createElement) {
return createElement('issuable-app', {
- props,
+ props: parseIssuableData(),
});
},
});
diff --git a/app/assets/javascripts/issue_show/utils/parse_data.js b/app/assets/javascripts/issue_show/utils/parse_data.js
new file mode 100644
index 00000000000..05e384adad3
--- /dev/null
+++ b/app/assets/javascripts/issue_show/utils/parse_data.js
@@ -0,0 +1,15 @@
+import sanitize from 'sanitize-html';
+
+export const parseIssuableData = () => {
+ try {
+ const initialDataEl = document.getElementById('js-issuable-app-initial-data');
+
+ return JSON.parse(sanitize(initialDataEl.textContent).replace(/&quot;/g, '"'));
+ } catch (e) {
+ console.error(e); // eslint-disable-line no-console
+
+ return {};
+ }
+};
+
+export default {};
diff --git a/app/assets/javascripts/issue_status_select.js b/app/assets/javascripts/issue_status_select.js
index c14803c80e7..75edff41a89 100644
--- a/app/assets/javascripts/issue_status_select.js
+++ b/app/assets/javascripts/issue_status_select.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { __ } from './locale';
export default function issueStatusSelect() {
$('.js-issue-status').each((i, el) => {
@@ -7,7 +8,7 @@ export default function issueStatusSelect() {
selectable: true,
fieldName,
toggleLabel(selected, element, instance) {
- let label = 'Author';
+ let label = __('Author');
const $item = instance.dropdown.find('.is-active');
if ($item.length) {
label = $item.text();
diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue
index 7076a79dd5d..b651a6e4bfb 100644
--- a/app/assets/javascripts/jobs/components/commit_block.vue
+++ b/app/assets/javascripts/jobs/components/commit_block.vue
@@ -39,7 +39,7 @@ export default {
</gl-link>
<clipboard-button
- :text="commit.short_id"
+ :text="commit.id"
:title="__('Copy commit SHA to clipboard')"
css-class="btn btn-clipboard btn-transparent"
/>
diff --git a/app/assets/javascripts/jobs/components/empty_state.vue b/app/assets/javascripts/jobs/components/empty_state.vue
index 668fcf3d673..04f910b6b80 100644
--- a/app/assets/javascripts/jobs/components/empty_state.vue
+++ b/app/assets/javascripts/jobs/components/empty_state.vue
@@ -49,7 +49,7 @@ export default {
<div class="text-content">
<h4 class="js-job-empty-state-title text-center">{{ title }}</h4>
- <p v-if="content" class="js-job-empty-state-content">{{ content }}</p>
+ <p v-if="content" class="js-job-empty-state-content text-center">{{ content }}</p>
<div v-if="action" class="text-center">
<gl-link
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index d473d6a482d..79fb67d38cd 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -15,6 +15,7 @@ import ErasedBlock from './erased_block.vue';
import Log from './job_log.vue';
import LogTopBar from './job_log_controllers.vue';
import StuckBlock from './stuck_block.vue';
+import UnmetPrerequisitesBlock from './unmet_prerequisites_block.vue';
import Sidebar from './sidebar.vue';
import { sprintf } from '~/locale';
import delayedJobMixin from '../mixins/delayed_job_mixin';
@@ -32,8 +33,10 @@ export default {
Log,
LogTopBar,
StuckBlock,
+ UnmetPrerequisitesBlock,
Sidebar,
GlLoadingIcon,
+ SharedRunner: () => import('ee_component/jobs/components/shared_runner_limit_block.vue'),
},
mixins: [delayedJobMixin],
props: {
@@ -47,6 +50,11 @@ export default {
required: false,
default: null,
},
+ deploymentHelpUrl: {
+ type: String,
+ required: false,
+ default: null,
+ },
endpoint: {
type: String,
required: true,
@@ -78,12 +86,15 @@ export default {
'isScrollTopDisabled',
'isScrolledToBottomBeforeReceivingTrace',
'hasError',
+ 'selectedStage',
]),
...mapGetters([
'headerTime',
+ 'hasUnmetPrerequisitesFailure',
'shouldRenderCalloutMessage',
'shouldRenderTriggeredLabel',
'hasEnvironment',
+ 'shouldRenderSharedRunnerLimitWarning',
'hasTrace',
'emptyStateIllustration',
'isScrollingDown',
@@ -111,7 +122,13 @@ export default {
// fetch the stages for the dropdown on the sidebar
job(newVal, oldVal) {
if (_.isEmpty(oldVal) && !_.isEmpty(newVal.pipeline)) {
- this.fetchStages();
+ const stages = this.job.pipeline.details.stages || [];
+
+ const defaultStage = stages.find(stage => stage && stage.name === this.selectedStage);
+
+ if (defaultStage) {
+ this.fetchJobsForStage(defaultStage);
+ }
}
if (newVal.archived) {
@@ -150,7 +167,7 @@ export default {
'setJobEndpoint',
'setTraceOptions',
'fetchJob',
- 'fetchStages',
+ 'fetchJobsForStage',
'hideSidebar',
'showSidebar',
'toggleSidebar',
@@ -208,7 +225,10 @@ export default {
/>
</div>
- <callout v-if="shouldRenderCalloutMessage" :message="job.callout_message" />
+ <callout
+ v-if="shouldRenderCalloutMessage && !hasUnmetPrerequisitesFailure"
+ :message="job.callout_message"
+ />
</header>
<!-- EO Header Section -->
@@ -221,6 +241,20 @@ export default {
:runners-path="runnerSettingsUrl"
/>
+ <unmet-prerequisites-block
+ v-if="hasUnmetPrerequisitesFailure"
+ class="js-job-failed"
+ :help-path="deploymentHelpUrl"
+ />
+
+ <shared-runner
+ v-if="shouldRenderSharedRunnerLimitWarning"
+ class="js-shared-runner-limit"
+ :quota-used="job.runners.quota.used"
+ :quota-limit="job.runners.quota.limit"
+ :runners-path="runnerHelpUrl"
+ />
+
<environments-block
v-if="hasEnvironment"
class="js-job-environment"
@@ -242,13 +276,12 @@ export default {
:class="{ 'sticky-top border-bottom-0': hasTrace }"
>
<icon name="lock" class="align-text-bottom" />
-
{{ __('This job is archived. Only the complete pipeline can be retried.') }}
</div>
<!-- job log -->
<div
v-if="hasTrace"
- class="build-trace-container"
+ class="build-trace-container position-relative"
:class="{ 'prepend-top-default': !job.archived }"
>
<log-top-bar
diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue
index 845699a90b5..a55dffbe488 100644
--- a/app/assets/javascripts/jobs/components/job_container_item.vue
+++ b/app/assets/javascripts/jobs/components/job_container_item.vue
@@ -43,7 +43,7 @@ export default {
<template>
<div
- class="build-job"
+ class="build-job position-relative"
:class="{
retried: job.retried,
active: isActive,
@@ -56,7 +56,11 @@ export default {
data-boundary="viewport"
class="js-job-link"
>
- <icon v-if="isActive" name="arrow-right" class="js-arrow-right icon-arrow-right" />
+ <icon
+ v-if="isActive"
+ name="arrow-right"
+ class="js-arrow-right icon-arrow-right position-absolute d-block"
+ />
<ci-icon :status="job.status" />
diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue
index 52e14f954ee..607b2bd1c74 100644
--- a/app/assets/javascripts/jobs/components/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue
@@ -75,7 +75,11 @@ export default {
<template v-if="isTraceSizeVisible">
{{ jobLogSize }}
- <gl-link v-if="rawPath" :href="rawPath" class="js-raw-link raw-link">
+ <gl-link
+ v-if="rawPath"
+ :href="rawPath"
+ class="js-raw-link text-plain text-underline prepend-left-5"
+ >
{{ s__('Job|Complete Raw') }}
</gl-link>
</template>
diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue
index 1691ac62100..24276c06486 100644
--- a/app/assets/javascripts/jobs/components/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/sidebar.vue
@@ -34,7 +34,7 @@ export default {
},
},
computed: {
- ...mapState(['job', 'stages', 'jobs', 'selectedStage', 'isLoadingStages']),
+ ...mapState(['job', 'stages', 'jobs', 'selectedStage']),
coverage() {
return `${this.job.coverage}%`;
},
@@ -208,7 +208,6 @@ export default {
/>
<stages-dropdown
- v-if="!isLoadingStages"
:stages="stages"
:pipeline="job.pipeline"
:selected-stage="selectedStage"
diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue
index c5076d65ff9..cb073a9b04d 100644
--- a/app/assets/javascripts/jobs/components/stages_dropdown.vue
+++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue
@@ -1,12 +1,16 @@
<script>
import _ from 'underscore';
+import { GlLink } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import PipelineLink from '~/vue_shared/components/ci_pipeline_link.vue';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
CiIcon,
Icon,
+ GlLink,
+ PipelineLink,
},
props: {
pipeline: {
@@ -26,6 +30,12 @@ export default {
hasRef() {
return !_.isEmpty(this.pipeline.ref);
},
+ isTriggeredByMergeRequest() {
+ return Boolean(this.pipeline.merge_request);
+ },
+ isMergeRequestPipeline() {
+ return Boolean(this.pipeline.flags && this.pipeline.flags.merge_request_pipeline);
+ },
},
methods: {
onStageClick(stage) {
@@ -36,16 +46,44 @@ export default {
</script>
<template>
<div class="block-last dropdown">
- <ci-icon :status="pipeline.details.status" class="vertical-align-middle" />
+ <div class="js-pipeline-info">
+ <ci-icon :status="pipeline.details.status" class="vertical-align-middle" />
- <span class="font-weight-bold">{{ __('Pipeline') }}</span>
- <a :href="pipeline.path" class="js-pipeline-path link-commit qa-pipeline-path"
- >#{{ pipeline.id }}</a
- >
- <template v-if="hasRef">
- {{ __('from') }}
- <a :href="pipeline.ref.path" class="link-commit ref-name">{{ pipeline.ref.name }}</a>
- </template>
+ <span class="font-weight-bold">{{ s__('Job|Pipeline') }}</span>
+ <pipeline-link
+ :href="pipeline.path"
+ :pipeline-id="pipeline.id"
+ :pipeline-iid="pipeline.iid"
+ class="js-pipeline-path link-commit qa-pipeline-path"
+ />
+ <template v-if="hasRef">
+ {{ s__('Job|for') }}
+
+ <template v-if="isTriggeredByMergeRequest">
+ <gl-link :href="pipeline.merge_request.path" class="link-commit ref-name js-mr-link"
+ >!{{ pipeline.merge_request.iid }}</gl-link
+ >
+ {{ s__('Job|with') }}
+ <gl-link
+ :href="pipeline.merge_request.source_branch_path"
+ class="link-commit ref-name js-source-branch-link"
+ >{{ pipeline.merge_request.source_branch }}</gl-link
+ >
+
+ <template v-if="isMergeRequestPipeline">
+ {{ s__('Job|into') }}
+ <gl-link
+ :href="pipeline.merge_request.target_branch_path"
+ class="link-commit ref-name js-target-branch-link"
+ >{{ pipeline.merge_request.target_branch }}</gl-link
+ >
+ </template>
+ </template>
+ <gl-link v-else :href="pipeline.ref.path" class="link-commit ref-name">{{
+ pipeline.ref.name
+ }}</gl-link>
+ </template>
+ </div>
<button
type="button"
diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue
index 997737b3e23..922f64d93fe 100644
--- a/app/assets/javascripts/jobs/components/trigger_block.vue
+++ b/app/assets/javascripts/jobs/components/trigger_block.vue
@@ -52,7 +52,7 @@ export default {
</p>
<template v-if="hasVariables">
- <p class="trigger-variables-btn-container">
+ <p class="trigger-variables-btn-container d-flex">
<span class="font-weight-bold">{{ __('Trigger variables:') }}</span>
<gl-button
diff --git a/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue b/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue
new file mode 100644
index 00000000000..25a8da84873
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue
@@ -0,0 +1,30 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+/**
+ * Renders Unmet Prerequisites block for job's view.
+ */
+export default {
+ components: {
+ GlLink,
+ },
+ props: {
+ helpPath: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="bs-callout bs-callout-danger">
+ <p class="js-failed-unmet-prerequisites append-bottom-0">
+ {{
+ s__(`Job|This job failed because the necessary resources were not successfully created.`)
+ }}
+
+ <gl-link :href="helpPath" class="js-help-path">
+ <strong> {{ __('More information') }} </strong>
+ </gl-link>
+ </p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js
index a32e945627c..25132449458 100644
--- a/app/assets/javascripts/jobs/index.js
+++ b/app/assets/javascripts/jobs/index.js
@@ -12,6 +12,7 @@ export default () => {
render(createElement) {
return createElement('job-app', {
props: {
+ deploymentHelpUrl: element.dataset.deploymentHelpUrl,
runnerHelpUrl: element.dataset.runnerHelpUrl,
runnerSettingsUrl: element.dataset.runnerSettingsUrl,
endpoint: element.dataset.endpoint,
diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js
index 8045f6dc3ff..12d67a43599 100644
--- a/app/assets/javascripts/jobs/store/actions.js
+++ b/app/assets/javascripts/jobs/store/actions.js
@@ -179,37 +179,13 @@ export const receiveTraceError = ({ commit }) => {
};
/**
- * Stages dropdown on sidebar
- */
-export const requestStages = ({ commit }) => commit(types.REQUEST_STAGES);
-export const fetchStages = ({ state, dispatch }) => {
- dispatch('requestStages');
-
- axios
- .get(`${state.job.pipeline.path}.json`)
- .then(({ data }) => {
- // Set selected stage
- dispatch('receiveStagesSuccess', data.details.stages);
- const selectedStage = data.details.stages.find(stage => stage.name === state.selectedStage);
- dispatch('fetchJobsForStage', selectedStage);
- })
- .catch(() => dispatch('receiveStagesError'));
-};
-export const receiveStagesSuccess = ({ commit }, data) =>
- commit(types.RECEIVE_STAGES_SUCCESS, data);
-export const receiveStagesError = ({ commit }) => {
- commit(types.RECEIVE_STAGES_ERROR);
- flash(__('An error occurred while fetching stages.'));
-};
-
-/**
* Jobs list on sidebar - depend on stages dropdown
*/
export const requestJobsForStage = ({ commit }, stage) =>
commit(types.REQUEST_JOBS_FOR_STAGE, stage);
// On stage click, set selected stage + fetch job
-export const fetchJobsForStage = ({ dispatch }, stage) => {
+export const fetchJobsForStage = ({ dispatch }, stage = {}) => {
dispatch('requestJobsForStage', stage);
axios
diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js
index 98911717381..406b1a2e375 100644
--- a/app/assets/javascripts/jobs/store/getters.js
+++ b/app/assets/javascripts/jobs/store/getters.js
@@ -3,6 +3,9 @@ import { isScrolledToBottom } from '~/lib/utils/scroll_utils';
export const headerTime = state => (state.job.started ? state.job.started : state.job.created_at);
+export const hasUnmetPrerequisitesFailure = state =>
+ state.job && state.job.failure_reason && state.job.failure_reason === 'unmet_prerequisites';
+
export const shouldRenderCalloutMessage = state =>
!_.isEmpty(state.job.status) && !_.isEmpty(state.job.callout_message);
@@ -28,6 +31,17 @@ export const emptyStateIllustration = state =>
export const emptyStateAction = state =>
(state.job && state.job.status && state.job.status.action) || null;
+/**
+ * Shared runners limit is only rendered when
+ * used quota is bigger or equal than the limit
+ *
+ * @returns {Boolean}
+ */
+export const shouldRenderSharedRunnerLimitWarning = state =>
+ !_.isEmpty(state.job.runners) &&
+ !_.isEmpty(state.job.runners.quota) &&
+ state.job.runners.quota.used >= state.job.runners.quota.limit;
+
export const isScrollingDown = state => isScrolledToBottom() && !state.isTraceComplete;
export const hasRunnersForProject = state =>
diff --git a/app/assets/javascripts/jobs/store/mutation_types.js b/app/assets/javascripts/jobs/store/mutation_types.js
index fd098f13e90..39146b2eefd 100644
--- a/app/assets/javascripts/jobs/store/mutation_types.js
+++ b/app/assets/javascripts/jobs/store/mutation_types.js
@@ -24,10 +24,6 @@ export const STOP_POLLING_TRACE = 'STOP_POLLING_TRACE';
export const RECEIVE_TRACE_SUCCESS = 'RECEIVE_TRACE_SUCCESS';
export const RECEIVE_TRACE_ERROR = 'RECEIVE_TRACE_ERROR';
-export const REQUEST_STAGES = 'REQUEST_STAGES';
-export const RECEIVE_STAGES_SUCCESS = 'RECEIVE_STAGES_SUCCESS';
-export const RECEIVE_STAGES_ERROR = 'RECEIVE_STAGES_ERROR';
-
export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE';
export const REQUEST_JOBS_FOR_STAGE = 'REQUEST_JOBS_FOR_STAGE';
export const RECEIVE_JOBS_FOR_STAGE_SUCCESS = 'RECEIVE_JOBS_FOR_STAGE_SUCCESS';
diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js
index cd440d21c1f..ad08f27b147 100644
--- a/app/assets/javascripts/jobs/store/mutations.js
+++ b/app/assets/javascripts/jobs/store/mutations.js
@@ -65,6 +65,11 @@ export default {
state.isLoading = false;
state.job = job;
+ state.stages =
+ job.pipeline && job.pipeline.details && job.pipeline.details.stages
+ ? job.pipeline.details.stages
+ : [];
+
/**
* We only update it on the first request
* The dropdown can be changed by the user
@@ -101,19 +106,7 @@ export default {
state.isScrolledToBottomBeforeReceivingTrace = toggle;
},
- [types.REQUEST_STAGES](state) {
- state.isLoadingStages = true;
- },
- [types.RECEIVE_STAGES_SUCCESS](state, stages) {
- state.isLoadingStages = false;
- state.stages = stages;
- },
- [types.RECEIVE_STAGES_ERROR](state) {
- state.isLoadingStages = false;
- state.stages = [];
- },
-
- [types.REQUEST_JOBS_FOR_STAGE](state, stage) {
+ [types.REQUEST_JOBS_FOR_STAGE](state, stage = {}) {
state.isLoadingJobs = true;
state.selectedStage = stage.name;
},
diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js
index 04825187c99..6019214e62c 100644
--- a/app/assets/javascripts/jobs/store/state.js
+++ b/app/assets/javascripts/jobs/store/state.js
@@ -25,7 +25,6 @@ export default () => ({
traceState: null,
// sidebar dropdown & list of jobs
- isLoadingStages: false,
isLoadingJobs: false,
selectedStage: '',
stages: [],
diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/label_manager.js
index f134a54dd53..7064731a5ea 100644
--- a/app/assets/javascripts/label_manager.js
+++ b/app/assets/javascripts/label_manager.js
@@ -5,22 +5,26 @@ import Sortable from 'sortablejs';
import flash from './flash';
import axios from './lib/utils/axios_utils';
+import { __ } from './locale';
export default class LabelManager {
constructor({ togglePriorityButton, prioritizedLabels, otherLabels } = {}) {
this.togglePriorityButton = togglePriorityButton || $('.js-toggle-priority');
this.prioritizedLabels = prioritizedLabels || $('.js-prioritized-labels');
this.otherLabels = otherLabels || $('.js-other-labels');
- this.errorMessage = 'Unable to update label prioritization at this time';
+ this.errorMessage = __('Unable to update label prioritization at this time');
this.emptyState = document.querySelector('#js-priority-labels-empty-state');
this.$badgeItemTemplate = $('#js-badge-item-template');
- this.sortable = Sortable.create(this.prioritizedLabels.get(0), {
- filter: '.empty-message',
- forceFallback: true,
- fallbackClass: 'is-dragging',
- dataIdAttr: 'data-id',
- onUpdate: this.onPrioritySortUpdate.bind(this),
- });
+
+ if ('sortable' in this.prioritizedLabels.data()) {
+ Sortable.create(this.prioritizedLabels.get(0), {
+ filter: '.empty-message',
+ forceFallback: true,
+ fallbackClass: 'is-dragging',
+ dataIdAttr: 'data-id',
+ onUpdate: this.onPrioritySortUpdate.bind(this),
+ });
+ }
this.bindEvents();
}
@@ -49,7 +53,7 @@ export default class LabelManager {
toggleEmptyState($label, $btn, action) {
this.emptyState.classList.toggle(
'hidden',
- !!this.prioritizedLabels[0].querySelector(':scope > li'),
+ Boolean(this.prioritizedLabels[0].querySelector(':scope > li')),
);
}
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index f7a611fbca0..3f954b43ee3 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -4,13 +4,14 @@
import $ from 'jquery';
import _ from 'underscore';
-import { sprintf, __ } from './locale';
+import { sprintf, s__, __ } from './locale';
import axios from './lib/utils/axios_utils';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
import CreateLabelDropdown from './create_label';
import flash from './flash';
import ModalStore from './boards/stores/modal_store';
import boardsStore from './boards/stores/boards_store';
+import { isEE, isScopedLabel } from '~/lib/utils/common_utils';
export default class LabelsSelect {
constructor(els, options = {}) {
@@ -86,8 +87,9 @@ export default class LabelsSelect {
return this.value;
})
.get();
+ const scopedLabels = $dropdown.data('scopedLabels');
+ const scopedLabelsDocumentationLink = $dropdown.data('scopedLabelsDocumentationLink');
const { handleClick } = options;
-
$sidebarLabelTooltip.tooltip();
if ($dropdown.closest('.dropdown').find('.dropdown-new-label').length) {
@@ -132,10 +134,51 @@ export default class LabelsSelect {
template = LabelsSelect.getLabelTemplate({
labels: data.labels,
issueUpdateURL,
+ enableScopedLabels: scopedLabels,
+ scopedLabelsDocumentationLink,
});
labelCount = data.labels.length;
+
+ // EE Specific
+ if (isEE) {
+ /**
+ * For Scoped labels, the last label selected with the
+ * same key will be applied to the current issueable.
+ *
+ * If these are the labels - priority::1, priority::2; and if
+ * we apply them in the same order, only priority::2 will stick
+ * with the issuable.
+ *
+ * In the current dropdown implementation, we keep track of all
+ * the labels selected via a hidden DOM element. Since a User
+ * can select priority::1 and priority::2 at the same time, the
+ * DOM will have 2 hidden input and the dropdown will show both
+ * the items selected but in reality server only applied
+ * priority::2.
+ *
+ * We find all the labels then find all the labels server accepted
+ * and then remove the excess ones.
+ */
+ const toRemoveIds = Array.from(
+ $form.find(`input[type="hidden"][name="${fieldName}"]`),
+ )
+ .map(el => el.value)
+ .map(Number);
+
+ data.labels.forEach(label => {
+ const index = toRemoveIds.indexOf(label.id);
+ toRemoveIds.splice(index, 1);
+ });
+
+ toRemoveIds.forEach(id => {
+ $form
+ .find(`input[type="hidden"][name="${fieldName}"][value="${id}"]`)
+ .last()
+ .remove();
+ });
+ }
} else {
- template = '<span class="no-value">None</span>';
+ template = `<span class="no-value">${__('None')}</span>`;
}
$value.removeAttr('style').html(template);
$sidebarCollapsedValue.text(labelCount);
@@ -147,7 +190,9 @@ export default class LabelsSelect {
if (labelTitles.length > 5) {
labelTitles = labelTitles.slice(0, 5);
- labelTitles.push('and ' + (data.labels.length - 5) + ' more');
+ labelTitles.push(
+ sprintf(s__('Labels|and %{count} more'), { count: data.labels.length - 5 }),
+ );
}
labelTooltipTitle = labelTitles.join(', ');
@@ -176,13 +221,13 @@ export default class LabelsSelect {
if (showNo) {
extraData.unshift({
id: 0,
- title: 'No Label',
+ title: __('No Label'),
});
}
if (showAny) {
extraData.unshift({
isAny: true,
- title: 'Any Label',
+ title: __('Any Label'),
});
}
if (extraData.length) {
@@ -199,8 +244,8 @@ export default class LabelsSelect {
.catch(() => flash(__('Error fetching labels.')));
},
renderRow: function(label, instance) {
- var $a,
- $li,
+ var linkEl,
+ listItemEl,
color,
colorEl,
indeterminate,
@@ -209,12 +254,11 @@ export default class LabelsSelect {
spacing,
i,
marked,
- dropdownName,
dropdownValue;
- $li = $('<li>');
- $a = $('<a href="#">');
+
selectedClass = [];
removesAll = label.id <= 0 || label.id == null;
+
if ($dropdown.hasClass('js-filter-bulk-update')) {
indeterminate = $dropdown.data('indeterminate') || [];
marked = $dropdown.data('marked') || [];
@@ -233,7 +277,6 @@ export default class LabelsSelect {
}
} else {
if (this.id(label)) {
- dropdownName = $dropdown.data('fieldName');
dropdownValue = this.id(label)
.toString()
.replace(/'/g, "\\'");
@@ -241,7 +284,7 @@ export default class LabelsSelect {
if (
$form.find(
"input[type='hidden'][name='" +
- dropdownName +
+ this.fieldName +
"'][value='" +
dropdownValue +
"']",
@@ -251,24 +294,34 @@ export default class LabelsSelect {
}
}
- if ($dropdown.hasClass('js-multiselect') && removesAll) {
+ if (this.multiSelect && removesAll) {
selectedClass.push('dropdown-clear-active');
}
}
+
if (label.color) {
colorEl =
"<span class='dropdown-label-box' style='background: " + label.color + "'></span>";
} else {
colorEl = '';
}
+
+ linkEl = document.createElement('a');
+ linkEl.href = '#';
+
// We need to identify which items are actually labels
if (label.id) {
selectedClass.push('label-item');
- $a.attr('data-label-id', label.id);
+ linkEl.dataset.labelId = label.id;
}
- $a.addClass(selectedClass.join(' ')).html(`${colorEl} ${_.escape(label.title)}`);
- // Return generated html
- return $li.html($a).prop('outerHTML');
+
+ linkEl.className = selectedClass.join(' ');
+ linkEl.innerHTML = `${colorEl} ${_.escape(label.title)}`;
+
+ listItemEl = document.createElement('li');
+ listItemEl.appendChild(linkEl);
+
+ return listItemEl;
},
search: {
fields: ['title'],
@@ -290,7 +343,7 @@ export default class LabelsSelect {
if (selected && selected.id === 0) {
this.selected = [];
- return 'No Label';
+ return __('No Label');
} else if (isSelected) {
this.selected.push(title);
} else if (!isSelected && title) {
@@ -350,6 +403,7 @@ export default class LabelsSelect {
} else {
if (!$dropdown.hasClass('js-filter-bulk-update')) {
saveLabelData();
+ $dropdown.data('glDropdown').clearMenu();
}
}
}
@@ -463,19 +517,60 @@ export default class LabelsSelect {
// so best approach is to use traditional way of
// concatenation
// see: http://2ality.com/2016/05/template-literal-whitespace.html#joining-arrays
- const tpl = _.template(
+
+ const labelTemplate = _.template(
[
- '<% _.each(labels, function(label){ %>',
'<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>">',
- '<span class="badge label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;">',
+ '<span class="badge label has-tooltip color-label" <%= linkAttrs %> title="<%= tooltipTitleTemplate({ label, isScopedLabel, enableScopedLabels, escapeStr }) %>" style="background-color: <%= escapeStr(label.color) %>; color: <%= escapeStr(label.text_color) %>;">',
'<%- label.title %>',
'</span>',
'</a>',
+ ].join(''),
+ );
+
+ const infoIconTemplate = _.template(
+ [
+ '<a href="<%= scopedLabelsDocumentationLink %>" class="label scoped-label" target="_blank" rel="noopener">',
+ '<i class="fa fa-question-circle" style="background-color: <%= escapeStr(label.color) %>; color: <%= escapeStr(label.text_color) %>;"></i>',
+ '</a>',
+ ].join(''),
+ );
+
+ const tooltipTitleTemplate = _.template(
+ [
+ '<% if (isScopedLabel(label) && enableScopedLabels) { %>',
+ "<span class='font-weight-bold scoped-label-tooltip-title'>Scoped label</span>",
+ '<br />',
+ '<%= escapeStr(label.description) %>',
+ '<% } else { %>',
+ '<%= escapeStr(label.description) %>',
+ '<% } %>',
+ ].join(''),
+ );
+
+ const tpl = _.template(
+ [
+ '<% _.each(labels, function(label){ %>',
+ '<% if (isScopedLabel(label) && enableScopedLabels) { %>',
+ '<span class="d-inline-block position-relative scoped-label-wrapper">',
+ '<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>',
+ '<%= infoIconTemplate({ label, scopedLabelsDocumentationLink, escapeStr }) %>',
+ '</span>',
+ '<% } else { %>',
+ '<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, escapeStr, linkAttrs: "" }) %>',
+ '<% } %>',
'<% }); %>',
].join(''),
);
- return tpl(tplData);
+ return tpl({
+ ...tplData,
+ labelTemplate,
+ infoIconTemplate,
+ tooltipTitleTemplate,
+ isScopedLabel,
+ escapeStr: _.escape,
+ });
}
bindEvents() {
@@ -486,7 +581,7 @@ export default class LabelsSelect {
if ($('.selected-issuable:checked').length) {
return;
}
- return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text('Label');
+ return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text(__('Label'));
}
// eslint-disable-next-line class-methods-use-this
enableBulkLabelDropdown() {
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index 64e4e899f44..5857f9e22ae 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -1,11 +1,32 @@
-import ApolloClient from 'apollo-boost';
+import { ApolloClient } from 'apollo-client';
+import { InMemoryCache } from 'apollo-cache-inmemory';
+import { createUploadLink } from 'apollo-upload-client';
+import { ApolloLink } from 'apollo-link';
+import { BatchHttpLink } from 'apollo-link-batch-http';
import csrf from '~/lib/utils/csrf';
-export default (clientState = {}) =>
- new ApolloClient({
- uri: `${gon.relative_url_root}/api/graphql`,
+export default (resolvers = {}, config = {}) => {
+ let uri = `${gon.relative_url_root}/api/graphql`;
+
+ if (config.baseUrl) {
+ // Prepend baseUrl and ensure that `///` are replaced with `/`
+ uri = `${config.baseUrl}${uri}`.replace(/\/{3,}/g, '/');
+ }
+
+ const httpOptions = {
+ uri,
headers: {
[csrf.headerKey]: csrf.token,
},
- clientState,
+ };
+
+ return new ApolloClient({
+ link: ApolloLink.split(
+ operation => operation.getContext().hasUpload,
+ createUploadLink(httpOptions),
+ new BatchHttpLink(httpOptions),
+ ),
+ cache: new InMemoryCache(config.cacheConfig),
+ resolvers,
});
+};
diff --git a/app/assets/javascripts/lib/utils/accessor.js b/app/assets/javascripts/lib/utils/accessor.js
index 1d18992af63..39cffedcac6 100644
--- a/app/assets/javascripts/lib/utils/accessor.js
+++ b/app/assets/javascripts/lib/utils/accessor.js
@@ -2,7 +2,7 @@ function isPropertyAccessSafe(base, property) {
let safe;
try {
- safe = !!base[property];
+ safe = Boolean(base[property]);
} catch (error) {
safe = false;
}
diff --git a/app/assets/javascripts/lib/utils/autosave.js b/app/assets/javascripts/lib/utils/autosave.js
new file mode 100644
index 00000000000..023c336db02
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/autosave.js
@@ -0,0 +1,32 @@
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+
+export const clearDraft = autosaveKey => {
+ try {
+ window.localStorage.removeItem(`autosave/${autosaveKey}`);
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ }
+};
+
+export const getDraft = autosaveKey => {
+ try {
+ return window.localStorage.getItem(`autosave/${autosaveKey}`);
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ return null;
+ }
+};
+
+export const updateDraft = (autosaveKey, text) => {
+ try {
+ window.localStorage.setItem(`autosave/${autosaveKey}`, text);
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ }
+};
+
+export const getDiscussionReplyKey = (noteableType, discussionId) =>
+ ['Note', capitalizeFirstCharacter(noteableType), discussionId, 'Reply'].join('/');
diff --git a/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js
index a24c71aeab1..28a7ebfdc69 100644
--- a/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js
+++ b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js
@@ -51,6 +51,7 @@ export default class LinkedTabs {
this.defaultAction = this.options.defaultAction;
this.action = this.options.action || this.defaultAction;
+ this.hashedTabs = this.options.hashedTabs || false;
if (this.action === 'show') {
this.action = this.defaultAction;
@@ -58,6 +59,10 @@ export default class LinkedTabs {
this.currentLocation = window.location;
+ if (this.hashedTabs) {
+ this.action = this.currentLocation.hash || this.action;
+ }
+
const tabSelector = `${this.options.parentEl} a[data-toggle="tab"]`;
// since this is a custom event we need jQuery :(
@@ -91,7 +96,9 @@ export default class LinkedTabs {
copySource.replace(/\/+$/, '');
- const newState = `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`;
+ const newState = this.hashedTabs
+ ? copySource
+ : `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`;
window.history.replaceState(
{
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index a73cdb73690..cc5e12aa467 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -7,6 +7,7 @@ import axios from './axios_utils';
import { getLocationHash } from './url_utility';
import { convertToCamelCase } from './text_utility';
import { isObject } from './type_utility';
+import breakpointInstance from '../../breakpoints';
export const getPagePath = (index = 0) => {
const page = $('body').attr('data-page') || '';
@@ -93,6 +94,8 @@ export const handleLocationHash = () => {
const fixedNav = document.querySelector('.navbar-gitlab');
const performanceBar = document.querySelector('#js-peek');
const topPadding = 8;
+ const diffFileHeader = document.querySelector('.js-file-title');
+ const versionMenusContainer = document.querySelector('.mr-version-menus-container');
let adjustment = 0;
if (fixedNav) adjustment -= fixedNav.offsetHeight;
@@ -113,6 +116,14 @@ export const handleLocationHash = () => {
adjustment -= performanceBar.offsetHeight;
}
+ if (diffFileHeader) {
+ adjustment -= diffFileHeader.offsetHeight;
+ }
+
+ if (versionMenusContainer) {
+ adjustment -= versionMenusContainer.offsetHeight;
+ }
+
if (isInMRPage()) {
adjustment -= topPadding;
}
@@ -193,16 +204,23 @@ export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 2;
export const contentTop = () => {
- const perfBar = $('#js-peek').height() || 0;
- const mrTabsHeight = $('.merge-request-tabs').height() || 0;
- const headerHeight = $('.navbar-gitlab').height() || 0;
- const diffFilesChanged = $('.js-diff-files-changed').height() || 0;
- const diffFileLargeEnoughScreen =
- 'matchMedia' in window ? window.matchMedia('min-width: 768') : true;
+ const perfBar = $('#js-peek').outerHeight() || 0;
+ const mrTabsHeight = $('.merge-request-tabs').outerHeight() || 0;
+ const headerHeight = $('.navbar-gitlab').outerHeight() || 0;
+ const diffFilesChanged = $('.js-diff-files-changed').outerHeight() || 0;
+ const isDesktop = breakpointInstance.isDesktop();
const diffFileTitleBar =
- (diffFileLargeEnoughScreen && $('.diff-file .file-title-flex-parent:visible').height()) || 0;
+ (isDesktop && $('.diff-file .file-title-flex-parent:visible').outerHeight()) || 0;
+ const compareVersionsHeaderHeight = (isDesktop && $('.mr-version-controls').outerHeight()) || 0;
- return perfBar + mrTabsHeight + headerHeight + diffFilesChanged + diffFileTitleBar;
+ return (
+ perfBar +
+ mrTabsHeight +
+ headerHeight +
+ diffFilesChanged +
+ diffFileTitleBar +
+ compareVersionsHeaderHeight
+ );
};
export const scrollToElement = element => {
@@ -708,6 +726,26 @@ export const NavigationType = {
TYPE_RESERVED: 255,
};
+/**
+ * Returns the value of `gon.ee`
+ * Used to check if it's the EE codebase or the CE one.
+ *
+ * @returns Boolean
+ */
+export const isEE = () => window.gon && window.gon.ee;
+
+/**
+ * Checks if the given Label has a special syntax `::` in
+ * it's title.
+ *
+ * Expected Label to be an Object with `title` as a key:
+ * { title: 'LabelTitle', ...otherProperties };
+ *
+ * @param {Object} label
+ * @returns Boolean
+ */
+export const isScopedLabel = ({ title = '' }) => title.indexOf('::') !== -1;
+
window.gl = window.gl || {};
window.gl.utils = {
...(window.gl.utils || {}),
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index d3fe8f77bd4..d521c462ad8 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -3,7 +3,7 @@ import _ from 'underscore';
import timeago from 'timeago.js';
import dateFormat from 'dateformat';
import { pluralize } from './text_utility';
-import { languageCode, s__ } from '../../locale';
+import { languageCode, s__, __ } from '../../locale';
window.timeago = timeago;
@@ -63,7 +63,15 @@ export const pad = (val, len = 2) => `0${val}`.slice(-len);
* @returns {String}
*/
export const getDayName = date =>
- ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][date.getDay()];
+ [
+ __('Sunday'),
+ __('Monday'),
+ __('Tuesday'),
+ __('Wednesday'),
+ __('Thursday'),
+ __('Friday'),
+ __('Saturday'),
+ ][date.getDay()];
/**
* @example
@@ -71,7 +79,12 @@ export const getDayName = date =>
* @param {date} datetime
* @returns {String}
*/
-export const formatDate = datetime => dateFormat(datetime, 'mmm d, yyyy h:MMtt Z');
+export const formatDate = datetime => {
+ if (_.isString(datetime) && datetime.match(/\d+-\d+\d+ /)) {
+ throw new Error(__('Invalid date'));
+ }
+ return dateFormat(datetime, 'mmm d, yyyy h:MMtt Z');
+};
/**
* Timeago uses underscores instead of dashes to separate language from country code.
@@ -92,7 +105,7 @@ export const getTimeago = () => {
const timeAgoLocaleRemaining = [
() => [s__('Timeago|just now'), s__('Timeago|right now')],
- () => [s__('Timeago|%s seconds ago'), s__('Timeago|%s seconds remaining')],
+ () => [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')],
@@ -121,7 +134,7 @@ export const getTimeago = () => {
const timeAgoLocale = [
() => [s__('Timeago|just now'), s__('Timeago|right now')],
- () => [s__('Timeago|%s seconds ago'), s__('Timeago|in %s seconds')],
+ () => [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')],
@@ -160,7 +173,11 @@ export const getTimeago = () => {
* @param {Boolean} setTimeago
*/
export const localTimeAgo = ($timeagoEls, setTimeago = true) => {
- getTimeago().render($timeagoEls, timeagoLanguageCode);
+ getTimeago();
+
+ $timeagoEls.each((i, el) => {
+ $(el).text(timeagoInstance.format($(el).attr('datetime'), timeagoLanguageCode));
+ });
if (!setTimeago) {
return;
@@ -316,13 +333,13 @@ export const getSundays = date => {
}
const daysToSunday = [
- 'Saturday',
- 'Friday',
- 'Thursday',
- 'Wednesday',
- 'Tuesday',
- 'Monday',
- 'Sunday',
+ __('Saturday'),
+ __('Friday'),
+ __('Thursday'),
+ __('Wednesday'),
+ __('Tuesday'),
+ __('Monday'),
+ __('Sunday'),
];
const month = date.getMonth();
@@ -332,7 +349,7 @@ export const getSundays = date => {
while (dateOfMonth.getMonth() === month) {
const dayName = getDayName(dateOfMonth);
- if (dayName === 'Sunday') {
+ if (dayName === __('Sunday')) {
sundays.push(new Date(dateOfMonth.getTime()));
}
@@ -496,7 +513,7 @@ export const stringifyTime = (timeObject, fullNameFormat = false) => {
const reducedTime = _.reduce(
timeObject,
(memo, unitValue, unitName) => {
- const isNonZero = !!unitValue;
+ const isNonZero = Boolean(unitValue);
if (fullNameFormat && isNonZero) {
// Remove traling 's' if unit value is singular
diff --git a/app/assets/javascripts/lib/utils/highlight.js b/app/assets/javascripts/lib/utils/highlight.js
new file mode 100644
index 00000000000..8f0afa3467d
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/highlight.js
@@ -0,0 +1,44 @@
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import _ from 'underscore';
+import sanitize from 'sanitize-html';
+
+/**
+ * Wraps substring matches with HTML `<span>` elements.
+ * Inputs are sanitized before highlighting, so this
+ * filter is safe to use with `v-html` (as long as `matchPrefix`
+ * and `matchSuffix` are not being dynamically generated).
+ *
+ * Note that this function can't be used inside `v-html` as a filter
+ * (Vue filters cannot be used inside `v-html`).
+ *
+ * @param {String} string The string to highlight
+ * @param {String} match The substring match to highlight in the string
+ * @param {String} matchPrefix The string to insert at the beginning of a match
+ * @param {String} matchSuffix The string to insert at the end of a match
+ */
+export default function highlight(string, match = '', matchPrefix = '<b>', matchSuffix = '</b>') {
+ if (_.isUndefined(string) || _.isNull(string)) {
+ return '';
+ }
+
+ if (_.isUndefined(match) || _.isNull(match) || match === '') {
+ return string;
+ }
+
+ const sanitizedValue = sanitize(string.toString(), { allowedTags: [] });
+
+ // occurrences is an array of character indices that should be
+ // highlighted in the original string, i.e. [3, 4, 5, 7]
+ const occurrences = fuzzaldrinPlus.match(sanitizedValue, match.toString());
+
+ return sanitizedValue
+ .split('')
+ .map((character, i) => {
+ if (_.contains(occurrences, i)) {
+ return `${matchPrefix}${character}${matchSuffix}`;
+ }
+
+ return character;
+ })
+ .join('');
+}
diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js
index 14c02218990..37ad1676f7a 100644
--- a/app/assets/javascripts/lib/utils/http_status.js
+++ b/app/assets/javascripts/lib/utils/http_status.js
@@ -16,6 +16,7 @@ const httpStatusCodes = {
IM_USED: 226,
MULTIPLE_CHOICES: 300,
BAD_REQUEST: 400,
+ UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
UNPROCESSABLE_ENTITY: 422,
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index 2ccc51c35f7..61c8b8803d7 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -1,4 +1,5 @@
import { BYTES_IN_KIB } from './constants';
+import { sprintf, __ } from '~/locale';
/**
* Function that allows a number with an X amount of decimals
@@ -72,11 +73,36 @@ export function bytesToGiB(number) {
*/
export function numberToHumanSize(size) {
if (size < BYTES_IN_KIB) {
- return `${size} bytes`;
+ return sprintf(__('%{size} bytes'), { size });
} else if (size < BYTES_IN_KIB * BYTES_IN_KIB) {
- return `${bytesToKiB(size).toFixed(2)} KiB`;
+ return sprintf(__('%{size} KiB'), { size: bytesToKiB(size).toFixed(2) });
} else if (size < BYTES_IN_KIB * BYTES_IN_KIB * BYTES_IN_KIB) {
- return `${bytesToMiB(size).toFixed(2)} MiB`;
+ return sprintf(__('%{size} MiB'), { size: bytesToMiB(size).toFixed(2) });
}
- return `${bytesToGiB(size).toFixed(2)} GiB`;
+ return sprintf(__('%{size} GiB'), { size: bytesToGiB(size).toFixed(2) });
}
+
+/**
+ * A simple method that returns the value of a + b
+ * It seems unessesary, but when combined with a reducer it
+ * adds up all the values in an array.
+ *
+ * e.g. `[1, 2, 3, 4, 5].reduce(sum) // => 15`
+ *
+ * @param {Float} a
+ * @param {Float} b
+ * @example
+ * // return 15
+ * [1, 2, 3, 4, 5].reduce(sum);
+ *
+ * // returns 6
+ * Object.values([{a: 1, b: 2, c: 3].reduce(sum);
+ * @returns {Float} The summed value
+ */
+export const sum = (a = 0, b = 0) => a + b;
+
+/**
+ * Checks if the provided number is odd
+ * @param {Int} number
+ */
+export const isOdd = (number = 0) => number % 2;
diff --git a/app/assets/javascripts/lib/utils/simple_poll.js b/app/assets/javascripts/lib/utils/simple_poll.js
index 473f179ad86..576a9ec880c 100644
--- a/app/assets/javascripts/lib/utils/simple_poll.js
+++ b/app/assets/javascripts/lib/utils/simple_poll.js
@@ -1,10 +1,10 @@
-export default (fn, interval = 2000, timeout = 60000) => {
+export default (fn, { interval = 2000, timeout = 60000 } = {}) => {
const startTime = Date.now();
return new Promise((resolve, reject) => {
const stop = arg => (arg instanceof Error ? reject(arg) : resolve(arg));
const next = () => {
- if (Date.now() - startTime < timeout) {
+ if (timeout === 0 || Date.now() - startTime < timeout) {
setTimeout(fn.bind(null, next, stop), interval);
} else {
reject(new Error('SIMPLE_POLL_TIMEOUT'));
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 84a617acb42..b7922e29bb0 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -223,9 +223,9 @@ export function insertMarkdownText({
return tag.replace(textPlaceholder, val);
}
if (val.indexOf(tag) === 0) {
- return '' + val.replace(tag, '');
+ return String(val.replace(tag, ''));
} else {
- return '' + tag + val;
+ return String(tag) + val;
}
})
.join('\n');
@@ -233,7 +233,7 @@ export function insertMarkdownText({
} else if (tag.indexOf(textPlaceholder) > -1) {
textToInsert = tag.replace(textPlaceholder, selected);
} else {
- textToInsert = '' + startChar + tag + selected + (wrap ? tag : ' ');
+ textToInsert = String(startChar) + tag + selected + (wrap ? tag : ' ');
}
if (removedFirstNewLine) {
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index c49b1bb5a2f..cc1d85fd97d 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -1,3 +1,5 @@
+import _ from 'underscore';
+
/**
* Adds a , to a string composed by numbers, at every 3 chars.
*
@@ -42,18 +44,18 @@ export const pluralize = (str, count) => str + (count > 1 || count === 0 ? 's' :
export const dasherize = str => str.replace(/[_\s]+/g, '-');
/**
- * Removes accents and converts to lower case
+ * Replaces whitespaces with hyphens and converts to lower case
* @param {String} str
* @returns {String}
*/
-export const slugify = str => str.trim().toLowerCase();
+export const slugifyWithHyphens = str => str.toLowerCase().replace(/\s+/g, '-');
/**
- * Replaces whitespaces with hyphens and converts to lower case
+ * Replaces whitespaces with underscore and converts to lower case
* @param {String} str
* @returns {String}
*/
-export const slugifyWithHyphens = str => str.toLowerCase().replace(/\s+/g, '-');
+export const slugifyWithUnderscore = str => str.toLowerCase().replace(/\s+/g, '_');
/**
* Truncates given text
@@ -160,3 +162,33 @@ export const splitCamelCase = string =>
.replace(/([A-Z]+)([A-Z][a-z])/g, ' $1 $2')
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
.trim();
+
+/**
+ * Intelligently truncates an item's namespace by doing two things:
+ * 1. Only include group names in path by removing the item name
+ * 2. Only include the first and last group names in the path
+ * when the namespace includes more than 2 groups
+ *
+ * @param {String} string A string namespace,
+ * i.e. "My Group / My Subgroup / My Project"
+ */
+export const truncateNamespace = (string = '') => {
+ if (_.isNull(string) || !_.isString(string)) {
+ return '';
+ }
+
+ const namespaceArray = string.split(' / ');
+
+ if (namespaceArray.length === 1) {
+ return string;
+ }
+
+ namespaceArray.splice(-1, 1);
+ let namespace = namespaceArray.join(' / ');
+
+ if (namespaceArray.length > 2) {
+ namespace = `${namespaceArray[0]} / ... / ${namespaceArray.pop()}`;
+ }
+
+ return namespace;
+};
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index 4ba84589705..b5474fc5c71 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -120,3 +120,41 @@ export function webIDEUrl(route = undefined) {
}
return returnUrl;
}
+
+/**
+ * Returns current base URL
+ */
+export function getBaseURL() {
+ const { protocol, host } = window.location;
+ return `${protocol}//${host}`;
+}
+
+/**
+ * Returns true if url is an absolute or root-relative URL
+ *
+ * @param {String} url
+ */
+export function isAbsoluteOrRootRelative(url) {
+ return /^(https?:)?\//.test(url);
+}
+
+/**
+ * Checks if the provided URL is a safe URL (absolute http(s) or root-relative URL)
+ *
+ * @param {String} url that will be checked
+ * @returns {Boolean}
+ */
+export function isSafeURL(url) {
+ if (!isAbsoluteOrRootRelative(url)) {
+ return false;
+ }
+
+ try {
+ const parsedUrl = new URL(url, getBaseURL());
+ return ['http:', 'https:'].includes(parsedUrl.protocol);
+ } catch (e) {
+ return false;
+ }
+}
+
+export { join as joinPaths } from 'path';
diff --git a/app/assets/javascripts/lib/utils/webpack.js b/app/assets/javascripts/lib/utils/webpack.js
index 308ad9784e4..37b17f0fe23 100644
--- a/app/assets/javascripts/lib/utils/webpack.js
+++ b/app/assets/javascripts/lib/utils/webpack.js
@@ -1,3 +1,5 @@
+import { joinPaths } from '~/lib/utils/url_utility';
+
// tell webpack to load assets from origin so that web workers don't break
// eslint-disable-next-line import/prefer-default-export
export function resetServiceWorkersPublicPath() {
@@ -5,6 +7,12 @@ export function resetServiceWorkersPublicPath() {
// the webpack publicPath setting at runtime.
// see: https://webpack.js.org/guides/public-path/
const relativeRootPath = (gon && gon.relative_url_root) || '';
- const webpackAssetPath = `${relativeRootPath}/assets/webpack/`;
+ const webpackAssetPath = joinPaths(relativeRootPath, '/assets/webpack/');
__webpack_public_path__ = webpackAssetPath; // eslint-disable-line camelcase
+
+ // monaco-editor-webpack-plugin currently (incorrectly) references the
+ // public path as a property of `window`. Once this is fixed upstream we
+ // can remove this line
+ // see: https://github.com/Microsoft/monaco-editor-webpack-plugin/pull/63
+ window.__webpack_public_path__ = webpackAssetPath; // eslint-disable-line
}
diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js
index 1ae3362c4bc..41aa0f4ddb9 100644
--- a/app/assets/javascripts/locale/index.js
+++ b/app/assets/javascripts/locale/index.js
@@ -11,7 +11,7 @@ delete window.translations;
@param text The text to be translated
@returns {String} The translated text
*/
-const gettext = text => locale.gettext.bind(locale)(ensureSingleLine(text));
+const gettext = text => locale.gettext(ensureSingleLine(text));
/**
Translate the text with a number
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 1b722c0505a..9f30a989295 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -31,6 +31,7 @@ import initPerformanceBar from './performance_bar';
import initSearchAutocomplete from './search_autocomplete';
import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
+import { __ } from './locale';
// expose jQuery as global (TODO: remove these)
window.jQuery = jQuery;
@@ -135,6 +136,24 @@ function deferredInitialisation() {
});
loadAwardsHandler();
+
+ /**
+ * Toggle Canary Badge
+ *
+ * For GitLab.com only, when the user is using canary
+ * we render a Next badge and hide the option to switch
+ * to canay
+ */
+ if (Cookies.get('gitlab_canary') && Cookies.get('gitlab_canary') === 'true') {
+ const canaryBadge = document.querySelector('.js-canary-badge');
+ const canaryLink = document.querySelector('.js-canary-link');
+ if (canaryBadge) {
+ canaryBadge.classList.remove('hidden');
+ }
+ if (canaryLink) {
+ canaryLink.classList.add('hidden');
+ }
+ }
}
document.addEventListener('DOMContentLoaded', () => {
@@ -201,9 +220,9 @@ document.addEventListener('DOMContentLoaded', () => {
const ref = xhrObj.status;
if (ref === 401) {
- Flash('You need to be logged in.');
+ Flash(__('You need to be logged in.'));
} else if (ref === 404 || ref === 500) {
- Flash('Something went wrong on our end.');
+ Flash(__('Something went wrong on our end.'));
}
});
diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js
index bd263c75a3d..af2697444f2 100644
--- a/app/assets/javascripts/members.js
+++ b/app/assets/javascripts/members.js
@@ -16,25 +16,33 @@ export default class Members {
gl.utils.disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change');
}
+ dropdownClicked(options) {
+ this.formSubmit(null, options.$el);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ dropdownToggleLabel(selected, $el) {
+ return $el.text();
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ dropdownIsSelectable(selected, $el) {
+ return !$el.hasClass('is-active');
+ }
+
initGLDropdown() {
$('.js-member-permissions-dropdown').each((i, btn) => {
const $btn = $(btn);
$btn.glDropdown({
selectable: true,
- isSelectable(selected, $el) {
- return !$el.hasClass('is-active');
- },
+ isSelectable: (selected, $el) => this.dropdownIsSelectable(selected, $el),
fieldName: $btn.data('fieldName'),
id(selected, $el) {
return $el.data('id');
},
- toggleLabel(selected, $el) {
- return $el.text();
- },
- clicked: options => {
- this.formSubmit(null, options.$el);
- },
+ toggleLabel: (selected, $el) => this.dropdownToggleLabel(selected, $el, $btn),
+ clicked: options => this.dropdownClicked(options),
});
});
}
@@ -55,6 +63,7 @@ export default class Members {
$toggle.enable();
$dateInput.enable();
}
+
// eslint-disable-next-line class-methods-use-this
getMemberListItems($el) {
const $memberListItem = $el.is('.member') ? $el : $(`#${$el.data('elId')}`);
diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js
index c2de0379d23..3cb406b819d 100644
--- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js
+++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js
@@ -16,7 +16,7 @@ import utilsMixin from '../mixins/line_conflict_utils';
},
},
template: `
- <table>
+ <table class="diff-wrap-lines code js-syntax-highlight">
<tr class="line_holder parallel" v-for="section in file.parallelLines">
<template v-for="line in section">
<td class="diff-line-num header" :class="lineCssClass(line)" v-if="line.isHeader"></td>
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
index 0333335de06..88bc0940741 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
@@ -3,15 +3,16 @@
import $ from 'jquery';
import Vue from 'vue';
import Cookies from 'js-cookie';
+import { s__ } from '~/locale';
(global => {
global.mergeConflicts = global.mergeConflicts || {};
const diffViewType = Cookies.get('diff_view');
- const HEAD_HEADER_TEXT = 'HEAD//our changes';
- const ORIGIN_HEADER_TEXT = 'origin//their changes';
- const HEAD_BUTTON_TITLE = 'Use ours';
- const ORIGIN_BUTTON_TITLE = 'Use theirs';
+ const HEAD_HEADER_TEXT = s__('MergeConflict|HEAD//our changes');
+ const ORIGIN_HEADER_TEXT = s__('MergeConflict|origin//their changes');
+ const HEAD_BUTTON_TITLE = s__('MergeConflict|Use ours');
+ const ORIGIN_BUTTON_TITLE = s__('MergeConflict|Use theirs');
const INTERACTIVE_RESOLVE_MODE = 'interactive';
const EDIT_RESOLVE_MODE = 'edit';
const DEFAULT_RESOLVE_MODE = INTERACTIVE_RESOLVE_MODE;
@@ -173,7 +174,7 @@ import Cookies from 'js-cookie';
getConflictsCountText() {
const count = this.getConflictsCount();
- const text = count > 1 ? 'conflicts' : 'conflict';
+ const text = count > 1 ? s__('MergeConflict|conflicts') : s__('MergeConflict|conflict');
return `${count} ${text}`;
},
@@ -348,8 +349,8 @@ import Cookies from 'js-cookie';
},
getCommitButtonText() {
- const initial = 'Commit to source branch';
- const inProgress = 'Committing...';
+ const initial = s__('MergeConflict|Commit to source branch');
+ const inProgress = s__('MergeConflict|Committing...');
return this.state ? (this.state.isSubmitting ? inProgress : initial) : initial;
},
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
index 7badd68089c..d8d203e0616 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
@@ -8,6 +8,7 @@ import './components/diff_file_editor';
import './components/inline_conflict_lines';
import './components/parallel_conflict_lines';
import syntaxHighlight from '../syntax_highlight';
+import { __ } from '~/locale';
export default function initMergeConflicts() {
const INTERACTIVE_RESOLVE_MODE = 'interactive';
@@ -92,7 +93,7 @@ export default function initMergeConflicts() {
})
.catch(() => {
mergeConflictsStore.setSubmitState(false);
- createFlash('Failed to save merge conflicts resolutions. Please try again!');
+ createFlash(__('Failed to save merge conflicts resolutions. Please try again!'));
});
},
},
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 2f15da42271..e5cf43e8289 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -21,6 +21,7 @@ import { localTimeAgo } from './lib/utils/datetime_utility';
import syntaxHighlight from './syntax_highlight';
import Notes from './notes';
import { polyfillSticky } from './lib/utils/sticky';
+import { __ } from './locale';
// MergeRequestTabs
//
@@ -326,7 +327,7 @@ export default class MergeRequestTabs {
})
.catch(() => {
this.toggleLoading(false);
- flash('An error occurred while fetching this tab.');
+ flash(__('An error occurred while fetching this tab.'));
});
}
@@ -398,7 +399,7 @@ export default class MergeRequestTabs {
const hash = getLocationHash();
const anchor = hash && $container.find(`.note[id="${hash}"]`);
if (anchor && anchor.length > 0) {
- const notesContent = anchor.closest('.notes_content');
+ const notesContent = anchor.closest('.notes-content');
const lineType = notesContent.hasClass('new') ? 'new' : 'old';
Notes.instance.toggleDiffNote({
target: anchor,
@@ -416,7 +417,7 @@ export default class MergeRequestTabs {
})
.catch(() => {
this.toggleLoading(false);
- flash('An error occurred while fetching this tab.');
+ flash(__('An error occurred while fetching this tab.'));
});
}
diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js
index f211632cf24..6aaba4e7c74 100644
--- a/app/assets/javascripts/milestone.js
+++ b/app/assets/javascripts/milestone.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import axios from './lib/utils/axios_utils';
import flash from './flash';
import { mouseenter, debouncedMouseleave, togglePopover } from './shared/popover';
+import { __ } from './locale';
export default class Milestone {
constructor() {
@@ -42,7 +43,7 @@ export default class Milestone {
$(tabElId).html(data.html);
$target.addClass('is-loaded');
})
- .catch(() => flash('Error loading milestone tab'));
+ .catch(() => flash(__('Error loading milestone tab')));
}
}
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index 75c18a9b6a0..43949d5cc86 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -56,14 +56,15 @@ export default class MilestoneSelect {
const $value = $block.find('.value');
const $loading = $block.find('.block-loading').fadeOut();
selectedMilestoneDefault = showAny ? '' : null;
- selectedMilestoneDefault = showNo && defaultNo ? 'No Milestone' : selectedMilestoneDefault;
+ selectedMilestoneDefault =
+ showNo && defaultNo ? __('No Milestone') : selectedMilestoneDefault;
selectedMilestone = $dropdown.data('selected') || selectedMilestoneDefault;
if (issueUpdateURL) {
milestoneLinkTemplate = _.template(
'<a href="<%- web_url %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>',
);
- milestoneLinkNoneTemplate = '<span class="no-value">None</span>';
+ milestoneLinkNoneTemplate = `<span class="no-value">${__('None')}</span>`;
}
return $dropdown.glDropdown({
showMenuAbove: showMenuAbove,
@@ -74,28 +75,28 @@ export default class MilestoneSelect {
extraOptions.push({
id: null,
name: null,
- title: 'Any Milestone',
+ title: __('Any Milestone'),
});
}
if (showNo) {
extraOptions.push({
id: -1,
- name: 'No Milestone',
- title: 'No Milestone',
+ name: __('No Milestone'),
+ title: __('No Milestone'),
});
}
if (showUpcoming) {
extraOptions.push({
id: -2,
name: '#upcoming',
- title: 'Upcoming',
+ title: __('Upcoming'),
});
}
if (showStarted) {
extraOptions.push({
id: -3,
name: '#started',
- title: 'Started',
+ title: __('Started'),
});
}
if (extraOptions.length) {
diff --git a/app/assets/javascripts/mini_pipeline_graph_dropdown.js b/app/assets/javascripts/mini_pipeline_graph_dropdown.js
index 81ab9d8be4b..b39ad764f01 100644
--- a/app/assets/javascripts/mini_pipeline_graph_dropdown.js
+++ b/app/assets/javascripts/mini_pipeline_graph_dropdown.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import flash from './flash';
import axios from './lib/utils/axios_utils';
+import { __ } from './locale';
/**
* In each pipelines table we have a mini pipeline graph for each pipeline.
@@ -98,7 +99,7 @@ export default class MiniPipelineGraph {
) {
$(button).dropdown('toggle');
}
- flash('An error occurred while fetching the builds.', 'alert');
+ flash(__('An error occurred while fetching the builds.'), 'alert');
});
}
diff --git a/app/assets/javascripts/mirrors/mirror_repos.js b/app/assets/javascripts/mirrors/mirror_repos.js
index 196b84621b6..33e9b1c4e46 100644
--- a/app/assets/javascripts/mirrors/mirror_repos.js
+++ b/app/assets/javascripts/mirrors/mirror_repos.js
@@ -87,7 +87,7 @@ export default class MirrorRepos {
project: {
remote_mirrors_attributes: {
id: $target.data('mirrorId'),
- enabled: 0,
+ _destroy: 1,
},
},
};
diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js
index 5bdf5d6277a..bb5ae6ce2d1 100644
--- a/app/assets/javascripts/mirrors/ssh_mirror.js
+++ b/app/assets/javascripts/mirrors/ssh_mirror.js
@@ -20,15 +20,10 @@ export default class SSHMirror {
this.$btnDetectHostKeys = this.$form.find('.js-detect-host-keys');
this.$btnSSHHostsShowAdvanced = this.$form.find('.btn-show-advanced');
this.$dropdownAuthType = this.$form.find('.js-mirror-auth-type');
+ this.$hiddenAuthType = this.$form.find('.js-hidden-mirror-auth-type');
this.$wellAuthTypeChanging = this.$form.find('.js-well-changing-auth');
this.$wellPasswordAuth = this.$form.find('.js-well-password-auth');
- this.$wellSSHAuth = this.$form.find('.js-well-ssh-auth');
- this.$sshPublicKeyWrap = this.$form.find('.js-ssh-public-key-wrap');
- this.$regeneratePublicSshKeyButton = this.$wellSSHAuth.find('.js-btn-regenerate-ssh-key');
- this.$regeneratePublicSshKeyModal = this.$wellSSHAuth.find(
- '.js-regenerate-public-ssh-key-confirm-modal',
- );
}
init() {
@@ -39,15 +34,6 @@ export default class SSHMirror {
this.$dropdownAuthType.on('change', e => this.handleAuthTypeChange(e));
this.$btnDetectHostKeys.on('click', e => this.handleDetectHostKeys(e));
this.$btnSSHHostsShowAdvanced.on('click', e => this.handleSSHHostsAdvanced(e));
- this.$regeneratePublicSshKeyButton.on('click', () =>
- this.$regeneratePublicSshKeyModal.toggle(true),
- );
- $('.js-confirm', this.$regeneratePublicSshKeyModal).on('click', e =>
- this.regeneratePublicSshKey(e),
- );
- $('.js-cancel', this.$regeneratePublicSshKeyModal).on('click', () =>
- this.$regeneratePublicSshKeyModal.toggle(false),
- );
}
/**
@@ -161,53 +147,11 @@ export default class SSHMirror {
* Authentication method dropdown change event listener
*/
handleAuthTypeChange() {
- const projectMirrorAuthTypeEndpoint = `${this.$form.attr('action')}.json`;
- const $sshPublicKey = this.$sshPublicKeyWrap.find('.ssh-public-key');
const selectedAuthType = this.$dropdownAuthType.val();
this.$wellPasswordAuth.collapse('hide');
- this.$wellSSHAuth.collapse('hide');
-
- // This request should happen only if selected Auth type was SSH
- // and SSH Public key was not present on page load
- if (selectedAuthType === AUTH_METHOD.SSH && !$sshPublicKey.text().trim()) {
- if (!this.$wellSSHAuth.length) return;
-
- // Construct request body
- const authTypeData = {
- project: {
- ...this.$regeneratePublicSshKeyButton.data().projectData,
- },
- };
-
- this.$wellAuthTypeChanging.collapse('show');
- this.$dropdownAuthType.disable();
-
- axios
- .put(projectMirrorAuthTypeEndpoint, JSON.stringify(authTypeData), {
- headers: {
- 'Content-Type': 'application/json; charset=utf-8',
- },
- })
- .then(({ data }) => {
- // Show SSH public key container and fill in public key
- this.toggleAuthWell(selectedAuthType);
- this.toggleSSHAuthWellMessage(true);
- this.setSSHPublicKey(data.import_data_attributes.ssh_public_key);
-
- this.$wellAuthTypeChanging.collapse('hide');
- this.$dropdownAuthType.enable();
- })
- .catch(() => {
- Flash(__('Something went wrong on our end.'));
-
- this.$wellAuthTypeChanging.collapse('hide');
- this.$dropdownAuthType.enable();
- });
- } else {
- this.toggleAuthWell(selectedAuthType);
- this.$wellSSHAuth.find('.js-ssh-public-key-present').collapse('show');
- }
+ this.updateHiddenAuthType(selectedAuthType);
+ this.toggleAuthWell(selectedAuthType);
}
/**
@@ -233,57 +177,12 @@ export default class SSHMirror {
*/
toggleAuthWell(authType) {
this.$wellPasswordAuth.collapse(authType === AUTH_METHOD.PASSWORD ? 'show' : 'hide');
- this.$wellSSHAuth.collapse(authType === AUTH_METHOD.SSH ? 'show' : 'hide');
+ this.updateHiddenAuthType(authType);
}
- /**
- * Toggle SSH auth information message
- */
- toggleSSHAuthWellMessage(sshKeyPresent) {
- this.$sshPublicKeyWrap.collapse(sshKeyPresent ? 'show' : 'hide');
- this.$wellSSHAuth.find('.js-ssh-public-key-present').collapse(sshKeyPresent ? 'show' : 'hide');
- this.$regeneratePublicSshKeyButton.collapse(sshKeyPresent ? 'show' : 'hide');
- this.$wellSSHAuth.find('.js-ssh-public-key-pending').collapse(sshKeyPresent ? 'hide' : 'show');
- }
-
- /**
- * Sets SSH Public key to Clipboard button and shows it on UI.
- */
- setSSHPublicKey(sshPublicKey) {
- this.$sshPublicKeyWrap.find('.ssh-public-key').text(sshPublicKey);
- this.$sshPublicKeyWrap
- .find('.btn-copy-ssh-public-key')
- .attr('data-clipboard-text', sshPublicKey);
- }
-
- regeneratePublicSshKey(event) {
- event.preventDefault();
-
- this.$regeneratePublicSshKeyModal.toggle(false);
-
- const button = this.$regeneratePublicSshKeyButton;
- const spinner = $('.js-spinner', button);
- const endpoint = button.data('endpoint');
- const authTypeData = {
- project: {
- ...this.$regeneratePublicSshKeyButton.data().projectData,
- },
- };
-
- button.attr('disabled', 'disabled');
- spinner.removeClass('d-none');
-
- axios
- .patch(endpoint, authTypeData)
- .then(({ data }) => {
- button.removeAttr('disabled');
- spinner.addClass('d-none');
-
- this.setSSHPublicKey(data.import_data_attributes.ssh_public_key);
- })
- .catch(() => {
- Flash(_('Unable to regenerate public ssh key.'));
- });
+ updateHiddenAuthType(authType) {
+ this.$hiddenAuthType.val(authType);
+ this.$hiddenAuthType.prop('disabled', authType === AUTH_METHOD.SSH);
}
destroy() {
@@ -292,8 +191,5 @@ export default class SSHMirror {
this.$dropdownAuthType.off('change');
this.$btnDetectHostKeys.off('click');
this.$btnSSHHostsShowAdvanced.off('click');
- this.$regeneratePublicSshKeyButton.off('click');
- $('.js-confirm', this.$regeneratePublicSshKeyModal).off('click');
- $('.js-cancel', this.$regeneratePublicSshKeyModal).off('click');
}
}
diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue
index 9e031b03579..c43791f2426 100644
--- a/app/assets/javascripts/monitoring/components/charts/area.vue
+++ b/app/assets/javascripts/monitoring/components/charts/area.vue
@@ -1,15 +1,18 @@
<script>
-import { GlAreaChart } from '@gitlab/ui/dist/charts';
+import { GlAreaChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import Icon from '~/vue_shared/components/icon.vue';
+import { chartHeight, graphTypes, lineTypes } from '../../constants';
+import { makeDataSeries } from '~/helpers/monitor_helper';
let debouncedResize;
export default {
components: {
GlAreaChart,
+ GlChartSeriesLabel,
Icon,
},
inheritAttrs: false,
@@ -19,7 +22,6 @@ export default {
required: true,
validator(data) {
return (
- data.queries &&
Array.isArray(data.queries) &&
data.queries.filter(query => {
if (Array.isArray(query.result)) {
@@ -41,31 +43,58 @@ export default {
required: false,
default: () => [],
},
- alertData: {
- type: Object,
+ thresholds: {
+ type: Array,
required: false,
- default: () => ({}),
+ default: () => [],
},
},
data() {
return {
tooltip: {
title: '',
- content: '',
+ content: [],
isDeployment: false,
sha: '',
},
width: 0,
- height: 0,
- scatterSymbol: undefined,
+ height: chartHeight,
+ svgs: {},
+ primaryColor: null,
};
},
computed: {
chartData() {
- return this.graphData.queries.reduce((accumulator, query) => {
- accumulator[query.unit] = query.result.reduce((acc, res) => acc.concat(res.values), []);
- return accumulator;
- }, {});
+ // Transforms & supplements query data to render appropriate labels & styles
+ // Input: [{ queryAttributes1 }, { queryAttributes2 }]
+ // Output: [{ seriesAttributes1 }, { seriesAttributes2 }]
+ return this.graphData.queries.reduce((acc, query) => {
+ const { appearance } = query;
+ const lineType =
+ appearance && appearance.line && appearance.line.type
+ ? appearance.line.type
+ : lineTypes.default;
+ const lineWidth =
+ appearance && appearance.line && appearance.line.width
+ ? appearance.line.width
+ : undefined;
+
+ const series = makeDataSeries(query.result, {
+ name: this.formatLegendLabel(query),
+ lineStyle: {
+ type: lineType,
+ width: lineWidth,
+ },
+ areaStyle: {
+ opacity:
+ appearance && appearance.area && typeof appearance.area.opacity === 'number'
+ ? appearance.area.opacity
+ : undefined,
+ },
+ });
+
+ return acc.concat(series);
+ }, []);
},
chartOptions() {
return {
@@ -78,37 +107,40 @@ export default {
axisPointer: {
snap: true,
},
- nameTextStyle: {
- padding: [18, 0, 0, 0],
- },
},
yAxis: {
name: this.yAxisLabel,
axisLabel: {
formatter: value => value.toFixed(3),
},
- nameTextStyle: {
- padding: [0, 0, 36, 0],
- },
- },
- legend: {
- formatter: this.xAxisLabel,
},
series: this.scatterSeries,
+ dataZoom: this.dataZoomConfig,
};
},
+ dataZoomConfig() {
+ const handleIcon = this.svgs['scroll-handle'];
+
+ return handleIcon ? { handleIcon } : {};
+ },
earliestDatapoint() {
- return Object.values(this.chartData).reduce((acc, data) => {
- const [[timestamp]] = data.sort(([a], [b]) => {
- if (a < b) {
- return -1;
- }
- return a > b ? 1 : 0;
- });
+ return this.chartData.reduce((acc, series) => {
+ const { data } = series;
+ const { length } = data;
+ if (!length) {
+ return acc;
+ }
+
+ const [first] = data[0];
+ const [last] = data[length - 1];
+ const seriesEarliest = first < last ? first : last;
- return timestamp < acc || acc === null ? timestamp : acc;
+ return seriesEarliest < acc || acc === null ? seriesEarliest : acc;
}, null);
},
+ isMultiSeries() {
+ return this.tooltip.content.length > 1;
+ },
recentDeployments() {
return this.deploymentData.reduce((acc, deployment) => {
if (deployment.created_at >= this.earliestDatapoint) {
@@ -129,15 +161,15 @@ export default {
},
scatterSeries() {
return {
- type: 'scatter',
+ type: graphTypes.deploymentData,
data: this.recentDeployments.map(deployment => [deployment.createdAt, 0]),
- symbol: this.scatterSymbol,
+ symbol: this.svgs.rocket,
symbolSize: 14,
+ itemStyle: {
+ color: this.primaryColor,
+ },
};
},
- xAxisLabel() {
- return this.graphData.queries.map(query => query.label).join(', ');
- },
yAxisLabel() {
return `${this.graphData.y_label}`;
},
@@ -151,35 +183,52 @@ export default {
created() {
debouncedResize = debounceByAnimationFrame(this.onResize);
window.addEventListener('resize', debouncedResize);
- this.getScatterSymbol();
+ this.setSvg('rocket');
+ this.setSvg('scroll-handle');
},
methods: {
+ formatLegendLabel(query) {
+ return `${query.label}`;
+ },
formatTooltipText(params) {
- const [seriesData] = params.seriesData;
- this.tooltip.isDeployment = seriesData.componentSubType === 'scatter';
this.tooltip.title = dateFormat(params.value, 'dd mmm yyyy, h:MMTT');
- if (this.tooltip.isDeployment) {
- const [deploy] = this.recentDeployments.filter(
- deployment => deployment.createdAt === seriesData.value[0],
- );
- this.tooltip.sha = deploy.sha.substring(0, 8);
- } else {
- this.tooltip.content = `${this.yAxisLabel} ${seriesData.value[1].toFixed(3)}`;
- }
+ this.tooltip.content = [];
+ params.seriesData.forEach(seriesData => {
+ if (seriesData.componentSubType === graphTypes.deploymentData) {
+ this.tooltip.isDeployment = true;
+ const [deploy] = this.recentDeployments.filter(
+ deployment => deployment.createdAt === seriesData.value[0],
+ );
+ this.tooltip.sha = deploy.sha.substring(0, 8);
+ } else {
+ const { seriesName, color } = seriesData;
+ // seriesData.value contains the chart's [x, y] value pair
+ // seriesData.value[1] is threfore the chart y value
+ const value = seriesData.value[1].toFixed(3);
+
+ this.tooltip.content.push({
+ name: seriesName,
+ value,
+ color,
+ });
+ }
+ });
},
- getScatterSymbol() {
- getSvgIconPathContent('rocket')
+ setSvg(name) {
+ getSvgIconPathContent(name)
.then(path => {
if (path) {
- this.scatterSymbol = `path://${path}`;
+ this.$set(this.svgs, name, `path://${path}`);
}
})
.catch(() => {});
},
+ onChartUpdated(chart) {
+ [this.primaryColor] = chart.getOption().color;
+ },
onResize() {
- const { width, height } = this.$refs.areaChart.$el.getBoundingClientRect();
+ const { width } = this.$refs.areaChart.$el.getBoundingClientRect();
this.width = width;
- this.height = height;
},
},
};
@@ -197,23 +246,39 @@ export default {
:data="chartData"
:option="chartOptions"
:format-tooltip-text="formatTooltipText"
- :thresholds="alertData"
+ :thresholds="thresholds"
:width="width"
:height="height"
+ @updated="onChartUpdated"
>
- <template slot="tooltipTitle">
- <div v-if="tooltip.isDeployment">
+ <template v-if="tooltip.isDeployment">
+ <template slot="tooltipTitle">
{{ __('Deployed') }}
- </div>
- {{ tooltip.title }}
- </template>
- <template slot="tooltipContent">
- <div v-if="tooltip.isDeployment" class="d-flex align-items-center">
+ </template>
+ <div slot="tooltipContent" class="d-flex align-items-center">
<icon name="commit" class="mr-2" />
{{ tooltip.sha }}
</div>
- <template v-else>
- {{ tooltip.content }}
+ </template>
+ <template v-else>
+ <template slot="tooltipTitle">
+ <div class="text-nowrap">
+ {{ tooltip.title }}
+ </div>
+ </template>
+ <template slot="tooltipContent">
+ <div
+ v-for="(content, key) in tooltip.content"
+ :key="key"
+ class="d-flex justify-content-between"
+ >
+ <gl-chart-series-label :color="isMultiSeries ? content.color : ''">
+ {{ content.name }}
+ </gl-chart-series-label>
+ <div class="prepend-left-32">
+ {{ content.value }}
+ </div>
+ </div>
</template>
</template>
</gl-area-chart>
diff --git a/app/assets/javascripts/monitoring/components/charts/single_stat.vue b/app/assets/javascripts/monitoring/components/charts/single_stat.vue
new file mode 100644
index 00000000000..b03a6ca1806
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/charts/single_stat.vue
@@ -0,0 +1,37 @@
+<script>
+import { GlSingleStat } from '@gitlab/ui/dist/charts';
+
+export default {
+ components: {
+ GlSingleStat,
+ },
+ inheritAttrs: false,
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ value: {
+ type: Number,
+ required: true,
+ },
+ unit: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ valueWithUnit() {
+ return `${this.value}${this.unit}`;
+ },
+ },
+};
+</script>
+<template>
+ <div class="prometheus-graph col-12 col-lg-6">
+ <div class="prometheus-graph-header">
+ <h5 ref="graphTitle" class="prometheus-graph-title">{{ title }}</h5>
+ </div>
+ <gl-single-stat :value="valueWithUnit" :title="title" variant="success" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 895a57785bc..2314f7b80cf 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,12 +1,23 @@
<script>
+import {
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlModal,
+ GlModalDirective,
+ GlLink,
+} from '@gitlab/ui';
+import _ from 'underscore';
+import { mapActions, mapState } from 'vuex';
import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
-import Flash from '../../flash';
-import MonitoringService from '../services/monitoring_service';
+import '~/vue_shared/mixins/is_ee';
+import { getParameterValues } from '~/lib/utils/url_utility';
import MonitorAreaChart from './charts/area.vue';
import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue';
-import MonitoringStore from '../stores/monitoring_store';
+import { timeWindows, timeWindowsKeyNames } from '../constants';
+import { getTimeDiff } from '../utils';
const sidebarAnimationDuration = 150;
let sidebarMutationObserver;
@@ -17,8 +28,21 @@ export default {
GraphGroup,
EmptyState,
Icon,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlLink,
+ GlModal,
+ },
+ directives: {
+ GlModalDirective,
},
props: {
+ externalDashboardUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
hasMetrics: {
type: Boolean,
required: false,
@@ -82,21 +106,58 @@ export default {
type: String,
required: true,
},
+ customMetricsAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ customMetricsPath: {
+ type: String,
+ required: true,
+ },
+ validateQueryPath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
- store: new MonitoringStore(),
state: 'gettingStarted',
- showEmptyState: true,
elWidth: 0,
+ selectedTimeWindow: '',
+ selectedTimeWindowKey: '',
+ formIsValid: null,
};
},
+ computed: {
+ canAddMetrics() {
+ return this.customMetricsAvailable && this.customMetricsPath.length;
+ },
+ ...mapState('monitoringDashboard', [
+ 'groups',
+ 'emptyState',
+ 'showEmptyState',
+ 'environments',
+ 'deploymentData',
+ ]),
+ },
created() {
- this.service = new MonitoringService({
+ this.setEndpoints({
metricsEndpoint: this.metricsEndpoint,
- deploymentEndpoint: this.deploymentEndpoint,
environmentsEndpoint: this.environmentsEndpoint,
+ deploymentsEndpoint: this.deploymentEndpoint,
});
+
+ this.timeWindows = timeWindows;
+ this.selectedTimeWindowKey =
+ _.escape(getParameterValues('time_window')[0]) || timeWindowsKeyNames.eightHours;
+
+ // Set default time window if the selectedTimeWindowKey is bogus
+ if (!Object.keys(this.timeWindows).includes(this.selectedTimeWindowKey)) {
+ this.selectedTimeWindowKey = timeWindowsKeyNames.eightHours;
+ }
+
+ this.selectedTimeWindow = this.timeWindows[this.selectedTimeWindowKey];
},
beforeDestroy() {
if (sidebarMutationObserver) {
@@ -105,9 +166,10 @@ export default {
},
mounted() {
if (!this.hasMetrics) {
- this.state = 'gettingStarted';
+ this.setGettingStartedEmptyState();
} else {
- this.getGraphsData();
+ this.fetchData(getTimeDiff(this.selectedTimeWindow));
+
sidebarMutationObserver = new MutationObserver(this.onSidebarMutation);
sidebarMutationObserver.observe(document.querySelector('.layout-page'), {
attributes: true,
@@ -117,71 +179,135 @@ export default {
}
},
methods: {
- getGraphAlerts(graphId) {
- return this.alertData ? this.alertData[graphId] || {} : {};
- },
- getGraphsData() {
- this.state = 'loading';
- Promise.all([
- this.service.getGraphsData().then(data => this.store.storeMetrics(data)),
- this.service
- .getDeploymentData()
- .then(data => this.store.storeDeploymentData(data))
- .catch(() => Flash(s__('Metrics|There was an error getting deployment information.'))),
- this.service
- .getEnvironmentsData()
- .then(data => this.store.storeEnvironmentsData(data))
- .catch(() => Flash(s__('Metrics|There was an error getting environments information.'))),
- ])
- .then(() => {
- if (this.store.groups.length < 1) {
- this.state = 'noData';
- return;
- }
-
- this.showEmptyState = false;
- })
- .catch(() => {
- this.state = 'unableToConnect';
- });
+ ...mapActions('monitoringDashboard', [
+ 'fetchData',
+ 'setGettingStartedEmptyState',
+ 'setEndpoints',
+ ]),
+ getGraphAlerts(queries) {
+ if (!this.allAlerts) return {};
+ const metricIdsForChart = queries.map(q => q.metricId);
+ return _.pick(this.allAlerts, alert => metricIdsForChart.includes(alert.metricId));
+ },
+ getGraphAlertValues(queries) {
+ return Object.values(this.getGraphAlerts(queries));
+ },
+ hideAddMetricModal() {
+ this.$refs.addMetricModal.hide();
},
onSidebarMutation() {
setTimeout(() => {
this.elWidth = this.$el.clientWidth;
}, sidebarAnimationDuration);
},
+ setFormValidity(isValid) {
+ this.formIsValid = isValid;
+ },
+ submitCustomMetricsForm() {
+ this.$refs.customMetricsForm.submit();
+ },
+ activeTimeWindow(key) {
+ return this.timeWindows[key] === this.selectedTimeWindow;
+ },
+ setTimeWindowParameter(key) {
+ return `?time_window=${key}`;
+ },
+ },
+ addMetric: {
+ title: s__('Metrics|Add metric'),
+ modalId: 'add-metric',
},
};
</script>
<template>
- <div v-if="!showEmptyState" class="prometheus-graphs prepend-top-default">
- <div class="environments d-flex align-items-center">
- {{ s__('Metrics|Environment') }}
- <div class="dropdown prepend-left-10">
- <button class="dropdown-menu-toggle" data-toggle="dropdown" type="button">
- <span>{{ currentEnvironmentName }}</span>
- <icon name="chevron-down" />
- </button>
- <div
- v-if="store.environmentsData.length > 0"
- class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"
- >
- <ul>
- <li v-for="environment in store.environmentsData" :key="environment.id">
- <a
- :href="environment.metrics_path"
- :class="{ 'is-active': environment.name == currentEnvironmentName }"
- class="dropdown-item"
- >{{ environment.name }}</a
+ <div v-if="!showEmptyState" class="prometheus-graphs">
+ <div class="gl-p-3 border-bottom bg-gray-light d-flex justify-content-between">
+ <div
+ v-if="environmentsEndpoint"
+ class="dropdowns d-flex align-items-center justify-content-between"
+ >
+ <div class="d-flex align-items-center">
+ <strong>{{ s__('Metrics|Environment') }}</strong>
+ <gl-dropdown
+ class="prepend-left-10 js-environments-dropdown"
+ toggle-class="dropdown-menu-toggle"
+ :text="currentEnvironmentName"
+ :disabled="environments.length === 0"
+ >
+ <gl-dropdown-item
+ v-for="environment in environments"
+ :key="environment.id"
+ :active="environment.name === currentEnvironmentName"
+ active-class="is-active"
+ >{{ environment.name }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ </div>
+ <div class="d-flex align-items-center prepend-left-8">
+ <strong>{{ s__('Metrics|Show last') }}</strong>
+ <gl-dropdown
+ class="prepend-left-10 js-time-window-dropdown"
+ toggle-class="dropdown-menu-toggle"
+ :text="selectedTimeWindow"
+ >
+ <gl-dropdown-item
+ v-for="(value, key) in timeWindows"
+ :key="key"
+ :active="activeTimeWindow(key)"
+ ><gl-link :href="setTimeWindowParameter(key)">{{ value }}</gl-link></gl-dropdown-item
+ >
+ </gl-dropdown>
+ </div>
+ </div>
+ <div class="d-flex">
+ <div v-if="isEE && canAddMetrics">
+ <gl-button
+ v-gl-modal-directive="$options.addMetric.modalId"
+ class="js-add-metric-button text-success border-success"
+ >
+ {{ $options.addMetric.title }}
+ </gl-button>
+ <gl-modal
+ ref="addMetricModal"
+ :modal-id="$options.addMetric.modalId"
+ :title="$options.addMetric.title"
+ >
+ <form ref="customMetricsForm" :action="customMetricsPath" method="post">
+ <custom-metrics-form-fields
+ :validate-query-path="validateQueryPath"
+ form-operation="post"
+ @formValidation="setFormValidity"
+ />
+ </form>
+ <div slot="modal-footer">
+ <gl-button @click="hideAddMetricModal">
+ {{ __('Cancel') }}
+ </gl-button>
+ <gl-button
+ :disabled="!formIsValid"
+ variant="success"
+ @click="submitCustomMetricsForm"
>
- </li>
- </ul>
+ {{ __('Save changes') }}
+ </gl-button>
+ </div>
+ </gl-modal>
</div>
+ <gl-button
+ v-if="externalDashboardUrl.length"
+ class="js-external-dashboard-link prepend-left-8"
+ variant="primary"
+ :href="externalDashboardUrl"
+ target="_blank"
+ >
+ {{ __('View full dashboard') }}
+ <icon name="external-link" />
+ </gl-button>
</div>
</div>
<graph-group
- v-for="(groupData, index) in store.groups"
+ v-for="(groupData, index) in groups"
:key="index"
:name="groupData.group"
:show-panels="showPanels"
@@ -190,16 +316,24 @@ export default {
v-for="(graphData, graphIndex) in groupData.metrics"
:key="graphIndex"
:graph-data="graphData"
- :deployment-data="store.deploymentData"
- :alert-data="getGraphAlerts(graphData.id)"
+ :deployment-data="deploymentData"
+ :thresholds="getGraphAlertValues(graphData.queries)"
:container-width="elWidth"
group-id="monitor-area-chart"
- />
+ >
+ <alert-widget
+ v-if="isEE && prometheusAlertsAvailable && alertsEndpoint && graphData"
+ :alerts-endpoint="alertsEndpoint"
+ :relevant-queries="graphData.queries"
+ :alerts-to-manage="getGraphAlerts(graphData.queries)"
+ @setAlerts="setAlerts"
+ />
+ </monitor-area-chart>
</graph-group>
</div>
<empty-state
v-else
- :selected-state="state"
+ :selected-state="emptyState"
:documentation-path="documentationPath"
:settings-path="settingsPath"
:clusters-path="clustersPath"
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
new file mode 100644
index 00000000000..26f1bf3f68d
--- /dev/null
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -0,0 +1,29 @@
+import { __ } from '~/locale';
+
+export const chartHeight = 300;
+
+export const graphTypes = {
+ deploymentData: 'scatter',
+};
+
+export const lineTypes = {
+ default: 'solid',
+};
+
+export const timeWindows = {
+ thirtyMinutes: __('30 minutes'),
+ threeHours: __('3 hours'),
+ eightHours: __('8 hours'),
+ oneDay: __('1 day'),
+ threeDays: __('3 days'),
+ oneWeek: __('1 week'),
+};
+
+export const timeWindowsKeyNames = {
+ thirtyMinutes: 'thirtyMinutes',
+ threeHours: 'threeHours',
+ eightHours: 'eightHours',
+ oneDay: 'oneDay',
+ threeDays: 'threeDays',
+ oneWeek: 'oneWeek',
+};
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js
index 9d78b5ea110..62c0f44c1e6 100644
--- a/app/assets/javascripts/monitoring/monitoring_bundle.js
+++ b/app/assets/javascripts/monitoring/monitoring_bundle.js
@@ -1,19 +1,22 @@
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
-import Dashboard from './components/dashboard.vue';
+import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue';
+import store from './stores';
-export default () => {
+export default (props = {}) => {
const el = document.getElementById('prometheus-graphs');
if (el && el.dataset) {
// eslint-disable-next-line no-new
new Vue({
el,
+ store,
render(createElement) {
return createElement(Dashboard, {
props: {
...el.dataset,
hasMetrics: parseBoolean(el.dataset.hasMetrics),
+ ...props,
},
});
},
diff --git a/app/assets/javascripts/monitoring/services/monitoring_service.js b/app/assets/javascripts/monitoring/services/monitoring_service.js
deleted file mode 100644
index 24b4acaf6da..00000000000
--- a/app/assets/javascripts/monitoring/services/monitoring_service.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import axios from '../../lib/utils/axios_utils';
-import statusCodes from '../../lib/utils/http_status';
-import { backOff } from '../../lib/utils/common_utils';
-import { s__ } from '../../locale';
-
-const MAX_REQUESTS = 3;
-
-function backOffRequest(makeRequestCallback) {
- let requestCounter = 0;
- 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'));
- }
- } else {
- stop(resp);
- }
- })
- .catch(stop);
- });
-}
-
-export default class MonitoringService {
- constructor({ metricsEndpoint, deploymentEndpoint, environmentsEndpoint }) {
- this.metricsEndpoint = metricsEndpoint;
- this.deploymentEndpoint = deploymentEndpoint;
- this.environmentsEndpoint = environmentsEndpoint;
- }
-
- getGraphsData() {
- return backOffRequest(() => axios.get(this.metricsEndpoint))
- .then(resp => resp.data)
- .then(response => {
- if (!response || !response.data) {
- throw new Error(s__('Metrics|Unexpected metrics data response from prometheus endpoint'));
- }
- return response.data;
- });
- }
-
- getDeploymentData() {
- if (!this.deploymentEndpoint) {
- return Promise.resolve([]);
- }
- return backOffRequest(() => axios.get(this.deploymentEndpoint))
- .then(resp => resp.data)
- .then(response => {
- if (!response || !response.deployments) {
- throw new Error(
- s__('Metrics|Unexpected deployment data response from prometheus endpoint'),
- );
- }
- return response.deployments;
- });
- }
-
- getEnvironmentsData() {
- return axios
- .get(this.environmentsEndpoint)
- .then(resp => resp.data)
- .then(response => {
- if (!response || !response.environments) {
- throw new Error(
- s__('Metrics|There was an error fetching the environments data, please try again'),
- );
- }
- return response.environments;
- });
- }
-}
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
new file mode 100644
index 00000000000..63c23e8449d
--- /dev/null
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -0,0 +1,117 @@
+import * as types from './mutation_types';
+import axios from '~/lib/utils/axios_utils';
+import createFlash from '~/flash';
+import statusCodes from '../../lib/utils/http_status';
+import { backOff } from '../../lib/utils/common_utils';
+import { s__, __ } from '../../locale';
+
+const MAX_REQUESTS = 3;
+
+function backOffRequest(makeRequestCallback) {
+ let requestCounter = 0;
+ 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')));
+ }
+ } else {
+ stop(resp);
+ }
+ })
+ .catch(stop);
+ });
+}
+
+export const setGettingStartedEmptyState = ({ commit }) => {
+ commit(types.SET_GETTING_STARTED_EMPTY_STATE);
+};
+
+export const setEndpoints = ({ commit }, endpoints) => {
+ commit(types.SET_ENDPOINTS, endpoints);
+};
+
+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 }) =>
+ commit(types.RECEIVE_DEPLOYMENTS_DATA_FAILURE);
+export const receiveEnvironmentsDataSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data);
+export const receiveEnvironmentsDataFailure = ({ commit }) =>
+ commit(types.RECEIVE_ENVIRONMENTS_DATA_FAILURE);
+
+export const fetchData = ({ dispatch }, params) => {
+ dispatch('fetchMetricsData', params);
+ dispatch('fetchDeploymentsData');
+ dispatch('fetchEnvironmentsData');
+};
+
+export const fetchMetricsData = ({ state, dispatch }, params) => {
+ dispatch('requestMetricsData');
+
+ return backOffRequest(() => axios.get(state.metricsEndpoint, { params }))
+ .then(resp => resp.data)
+ .then(response => {
+ if (!response || !response.data || !response.success) {
+ dispatch('receiveMetricsDataFailure', null);
+ createFlash(s__('Metrics|Unexpected metrics data response from prometheus endpoint'));
+ }
+ dispatch('receiveMetricsDataSuccess', response.data);
+ })
+ .catch(error => {
+ dispatch('receiveMetricsDataFailure', error);
+ createFlash(s__('Metrics|There was an error while retrieving metrics'));
+ });
+};
+
+export const fetchDeploymentsData = ({ state, dispatch }) => {
+ if (!state.deploymentEndpoint) {
+ return Promise.resolve([]);
+ }
+ return backOffRequest(() => axios.get(state.deploymentEndpoint))
+ .then(resp => resp.data)
+ .then(response => {
+ if (!response || !response.deployments) {
+ createFlash(s__('Metrics|Unexpected deployment data response from prometheus endpoint'));
+ }
+
+ dispatch('receiveDeploymentsDataSuccess', response.deployments);
+ })
+ .catch(() => {
+ dispatch('receiveDeploymentsDataFailure');
+ createFlash(s__('Metrics|There was an error getting deployment information.'));
+ });
+};
+
+export const fetchEnvironmentsData = ({ state, dispatch }) => {
+ if (!state.environmentsEndpoint) {
+ return Promise.resolve([]);
+ }
+ return axios
+ .get(state.environmentsEndpoint)
+ .then(resp => resp.data)
+ .then(response => {
+ if (!response || !response.environments) {
+ createFlash(
+ s__('Metrics|There was an error fetching the environments data, please try again'),
+ );
+ }
+ dispatch('receiveEnvironmentsDataSuccess', response.environments);
+ })
+ .catch(() => {
+ dispatch('receiveEnvironmentsDataFailure');
+ createFlash(s__('Metrics|There was an error getting environments information.'));
+ });
+};
+
+// 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
new file mode 100644
index 00000000000..d58398c54ae
--- /dev/null
+++ b/app/assets/javascripts/monitoring/stores/index.js
@@ -0,0 +1,21 @@
+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({
+ modules: {
+ monitoringDashboard: {
+ namespaced: true,
+ actions,
+ mutations,
+ state,
+ },
+ },
+ });
+
+export default createStore();
diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js
deleted file mode 100644
index 70635059bd9..00000000000
--- a/app/assets/javascripts/monitoring/stores/monitoring_store.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import _ from 'underscore';
-
-function sortMetrics(metrics) {
- return _.chain(metrics)
- .sortBy('title')
- .sortBy('weight')
- .value();
-}
-
-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)), []);
-}
-
-function normalizeMetrics(metrics) {
- return metrics.map(metric => {
- const queries = metric.queries.map(query => ({
- ...query,
- result: query.result.map(result => ({
- ...result,
- values: result.values.map(([timestamp, value]) => [
- new Date(timestamp * 1000).toISOString(),
- Number(value),
- ]),
- })),
- }));
-
- return {
- ...metric,
- queries: removeTimeSeriesNoData(queries),
- };
- });
-}
-
-export default class MonitoringStore {
- constructor() {
- this.groups = [];
- this.deploymentData = [];
- this.environmentsData = [];
- }
-
- storeMetrics(groups = []) {
- this.groups = groups.map(group => ({
- ...group,
- metrics: normalizeMetrics(sortMetrics(group.metrics)),
- }));
- }
-
- storeDeploymentData(deploymentData = []) {
- this.deploymentData = deploymentData;
- }
-
- storeEnvironmentsData(environmentsData = []) {
- this.environmentsData = environmentsData.filter(environment => !!environment.last_deployment);
- }
-
- getMetricsCount() {
- return this.groups.reduce((count, group) => count + group.metrics.length, 0);
- }
-}
diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js
new file mode 100644
index 00000000000..3fd9e07fa8b
--- /dev/null
+++ b/app/assets/javascripts/monitoring/stores/mutation_types.js
@@ -0,0 +1,12 @@
+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_TIME_WINDOW = 'SET_TIME_WINDOW';
+export const SET_ENDPOINTS = 'SET_ENDPOINTS';
+export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE';
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
new file mode 100644
index 00000000000..c1779333d75
--- /dev/null
+++ b/app/assets/javascripts/monitoring/stores/mutations.js
@@ -0,0 +1,45 @@
+import * as types from './mutation_types';
+import { normalizeMetrics, sortMetrics } from './utils';
+
+export default {
+ [types.REQUEST_METRICS_DATA](state) {
+ state.emptyState = 'loading';
+ state.showEmptyState = true;
+ },
+ [types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) {
+ state.groups = groupData.map(group => ({
+ ...group,
+ metrics: normalizeMetrics(sortMetrics(group.metrics)),
+ }));
+
+ if (!state.groups.length) {
+ state.emptyState = 'noData';
+ } else {
+ state.showEmptyState = false;
+ }
+ },
+ [types.RECEIVE_METRICS_DATA_FAILURE](state, error) {
+ state.emptyState = error ? 'unableToConnect' : 'noData';
+ state.showEmptyState = true;
+ },
+ [types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS](state, deployments) {
+ state.deploymentData = deployments;
+ },
+ [types.RECEIVE_DEPLOYMENTS_DATA_FAILURE](state) {
+ state.deploymentData = [];
+ },
+ [types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS](state, environments) {
+ state.environments = environments;
+ },
+ [types.RECEIVE_ENVIRONMENTS_DATA_FAILURE](state) {
+ state.environments = [];
+ },
+ [types.SET_ENDPOINTS](state, endpoints) {
+ state.metricsEndpoint = endpoints.metricsEndpoint;
+ state.environmentsEndpoint = endpoints.environmentsEndpoint;
+ state.deploymentsEndpoint = endpoints.deploymentsEndpoint;
+ },
+ [types.SET_GETTING_STARTED_EMPTY_STATE](state) {
+ state.emptyState = 'gettingStarted';
+ },
+};
diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js
new file mode 100644
index 00000000000..5103122612a
--- /dev/null
+++ b/app/assets/javascripts/monitoring/stores/state.js
@@ -0,0 +1,12 @@
+export default () => ({
+ hasMetrics: false,
+ showPanels: true,
+ metricsEndpoint: null,
+ environmentsEndpoint: null,
+ deploymentsEndpoint: null,
+ emptyState: 'gettingStarted',
+ showEmptyState: true,
+ groups: [],
+ deploymentData: [],
+ environments: [],
+});
diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js
new file mode 100644
index 00000000000..9216554ecbf
--- /dev/null
+++ b/app/assets/javascripts/monitoring/stores/utils.js
@@ -0,0 +1,83 @@
+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 }]}
+// ]
+function groupQueriesByChartInfo(metrics) {
+ const metricsByChart = metrics.reduce((accumulator, metric) => {
+ const { queries, ...chart } = metric;
+ const metricId = chart.id ? chart.id.toString() : null;
+
+ const chartKey = `${chart.title}|${chart.y_label}`;
+ accumulator[chartKey] = accumulator[chartKey] || { ...chart, queries: [] };
+
+ queries.forEach(queryAttrs => accumulator[chartKey].queries.push({ metricId, ...queryAttrs }));
+
+ return accumulator;
+ }, {});
+
+ return Object.values(metricsByChart);
+}
+
+export const sortMetrics = metrics =>
+ _.chain(metrics)
+ .sortBy('title')
+ .sortBy('weight')
+ .value();
+
+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(result => ({
+ ...result,
+ values: result.values.map(([timestamp, value]) => [
+ new Date(timestamp * 1000).toISOString(),
+ Number(value),
+ ]),
+ })),
+ }));
+
+ return {
+ ...metric,
+ queries: removeTimeSeriesNoData(queries),
+ };
+ });
+};
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
new file mode 100644
index 00000000000..ef309c8a398
--- /dev/null
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -0,0 +1,33 @@
+import { timeWindows } from './constants';
+
+/**
+ * method that converts a predetermined time window to minutes
+ * defaults to 8 hours as the default option
+ * @param {String} timeWindow - The time window to convert to minutes
+ * @returns {number} The time window in minutes
+ */
+const getTimeDifferenceSeconds = timeWindow => {
+ switch (timeWindow) {
+ case timeWindows.thirtyMinutes:
+ return 60 * 30;
+ case timeWindows.threeHours:
+ return 60 * 60 * 3;
+ case timeWindows.oneDay:
+ return 60 * 60 * 24 * 1;
+ case timeWindows.threeDays:
+ return 60 * 60 * 24 * 3;
+ case timeWindows.oneWeek:
+ return 60 * 60 * 24 * 7 * 1;
+ default:
+ return 60 * 60 * 8;
+ }
+};
+
+export const getTimeDiff = selectedTimeWindow => {
+ const end = Date.now() / 1000; // convert milliseconds to seconds
+ const start = end - getTimeDifferenceSeconds(selectedTimeWindow);
+
+ return { start, end };
+};
+
+export default {};
diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js
index 9e99aa4f724..8eccba07c38 100644
--- a/app/assets/javascripts/mr_notes/index.js
+++ b/app/assets/javascripts/mr_notes/index.js
@@ -1,11 +1,9 @@
-import $ from 'jquery';
import Vue from 'vue';
-import { mapActions, mapState, mapGetters } from 'vuex';
+import store from 'ee_else_ce/mr_notes/stores';
+import initNotesApp from './init_notes';
import initDiffsApp from '../diffs';
-import notesApp from '../notes/components/notes_app.vue';
import discussionCounter from '../notes/components/discussion_counter.vue';
import initDiscussionFilters from '../notes/discussion_filters';
-import store from './stores';
import MergeRequest from '../merge_request';
import { resetServiceWorkersPublicPath } from '../lib/utils/webpack';
@@ -18,68 +16,7 @@ export default function initMrNotes() {
action: mrShowNode.dataset.mrAction,
});
- // eslint-disable-next-line no-new
- new Vue({
- el: '#js-vue-mr-discussions',
- name: 'MergeRequestDiscussions',
- components: {
- notesApp,
- },
- store,
- data() {
- const notesDataset = document.getElementById('js-vue-mr-discussions').dataset;
- const noteableData = JSON.parse(notesDataset.noteableData);
- noteableData.noteableType = notesDataset.noteableType;
- noteableData.targetType = notesDataset.targetType;
-
- return {
- noteableData,
- currentUserData: JSON.parse(notesDataset.currentUserData),
- notesData: JSON.parse(notesDataset.notesData),
- helpPagePath: notesDataset.helpPagePath,
- };
- },
- computed: {
- ...mapGetters(['discussionTabCounter']),
- ...mapState({
- activeTab: state => state.page.activeTab,
- }),
- },
- watch: {
- discussionTabCounter() {
- this.updateDiscussionTabCounter();
- },
- },
- created() {
- this.setActiveTab(window.mrTabs.getCurrentAction());
- },
- mounted() {
- this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge');
- $(document).on('visibilitychange', this.updateDiscussionTabCounter);
- window.mrTabs.eventHub.$on('MergeRequestTabChange', this.setActiveTab);
- },
- beforeDestroy() {
- $(document).off('visibilitychange', this.updateDiscussionTabCounter);
- window.mrTabs.eventHub.$off('MergeRequestTabChange', this.setActiveTab);
- },
- methods: {
- ...mapActions(['setActiveTab']),
- updateDiscussionTabCounter() {
- this.notesCountBadge.text(this.discussionTabCounter);
- },
- },
- render(createElement) {
- return createElement('notes-app', {
- props: {
- noteableData: this.noteableData,
- notesData: this.notesData,
- userData: this.currentUserData,
- shouldShow: this.activeTab === 'show',
- helpPagePath: this.helpPagePath,
- },
- });
- },
- });
+ initNotesApp();
// eslint-disable-next-line no-new
new Vue({
diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js
new file mode 100644
index 00000000000..842a209a545
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/init_notes.js
@@ -0,0 +1,70 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import { mapActions, mapState, mapGetters } from 'vuex';
+import store from 'ee_else_ce/mr_notes/stores';
+import notesApp from '../notes/components/notes_app.vue';
+
+export default () => {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: '#js-vue-mr-discussions',
+ name: 'MergeRequestDiscussions',
+ components: {
+ notesApp,
+ },
+ store,
+ data() {
+ const notesDataset = document.getElementById('js-vue-mr-discussions').dataset;
+ const noteableData = JSON.parse(notesDataset.noteableData);
+ noteableData.noteableType = notesDataset.noteableType;
+ noteableData.targetType = notesDataset.targetType;
+
+ return {
+ noteableData,
+ currentUserData: JSON.parse(notesDataset.currentUserData),
+ notesData: JSON.parse(notesDataset.notesData),
+ helpPagePath: notesDataset.helpPagePath,
+ };
+ },
+ computed: {
+ ...mapGetters(['discussionTabCounter']),
+ ...mapState({
+ activeTab: state => state.page.activeTab,
+ }),
+ },
+ watch: {
+ discussionTabCounter() {
+ this.updateDiscussionTabCounter();
+ },
+ },
+ created() {
+ this.setActiveTab(window.mrTabs.getCurrentAction());
+ },
+ mounted() {
+ this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge');
+ $(document).on('visibilitychange', this.updateDiscussionTabCounter);
+ window.mrTabs.eventHub.$on('MergeRequestTabChange', this.setActiveTab);
+ },
+ beforeDestroy() {
+ $(document).off('visibilitychange', this.updateDiscussionTabCounter);
+ window.mrTabs.eventHub.$off('MergeRequestTabChange', this.setActiveTab);
+ },
+ methods: {
+ ...mapActions(['setActiveTab']),
+ updateDiscussionTabCounter() {
+ this.notesCountBadge.text(this.discussionTabCounter);
+ },
+ },
+ render(createElement) {
+ return createElement('notes-app', {
+ props: {
+ noteableData: this.noteableData,
+ notesData: this.notesData,
+ userData: this.currentUserData,
+ shouldShow: this.activeTab === 'show',
+ helpPagePath: this.helpPagePath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/mr_notes/stores/getters.js b/app/assets/javascripts/mr_notes/stores/getters.js
index b10e9f9f9f1..e48cfcd9564 100644
--- a/app/assets/javascripts/mr_notes/stores/getters.js
+++ b/app/assets/javascripts/mr_notes/stores/getters.js
@@ -1,5 +1,5 @@
export default {
isLoggedIn(state, getters) {
- return !!getters.getUserData.id;
+ return Boolean(getters.getUserData.id);
},
};
diff --git a/app/assets/javascripts/mr_popover/components/mr_popover.vue b/app/assets/javascripts/mr_popover/components/mr_popover.vue
new file mode 100644
index 00000000000..8e2d8fa816a
--- /dev/null
+++ b/app/assets/javascripts/mr_popover/components/mr_popover.vue
@@ -0,0 +1,110 @@
+<script>
+import { GlPopover, GlSkeletonLoading } from '@gitlab/ui';
+import Icon from '../../vue_shared/components/icon.vue';
+import CiIcon from '../../vue_shared/components/ci_icon.vue';
+import timeagoMixin from '../../vue_shared/mixins/timeago';
+import query from '../queries/merge_request.graphql';
+import { mrStates, humanMRStates } from '../constants';
+
+export default {
+ name: 'MRPopover',
+ components: {
+ GlPopover,
+ GlSkeletonLoading,
+ Icon,
+ CiIcon,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ target: {
+ type: HTMLAnchorElement,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ mergeRequestIID: {
+ type: String,
+ required: true,
+ },
+ mergeRequestTitle: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ mergeRequest: {},
+ };
+ },
+ computed: {
+ detailedStatus() {
+ return this.mergeRequest.headPipeline && this.mergeRequest.headPipeline.detailedStatus;
+ },
+ formattedTime() {
+ return this.timeFormated(this.mergeRequest.createdAt);
+ },
+ statusBoxClass() {
+ switch (this.mergeRequest.state) {
+ case mrStates.merged:
+ return 'status-box-mr-merged';
+ case mrStates.closed:
+ return 'status-box-closed';
+ default:
+ return 'status-box-open';
+ }
+ },
+ stateHumanName() {
+ switch (this.mergeRequest.state) {
+ case mrStates.merged:
+ return humanMRStates.merged;
+ case mrStates.closed:
+ return humanMRStates.closed;
+ default:
+ return humanMRStates.open;
+ }
+ },
+ showDetails() {
+ return Object.keys(this.mergeRequest).length > 0;
+ },
+ },
+ apollo: {
+ mergeRequest: {
+ query,
+ update: data => data.project.mergeRequest,
+ variables() {
+ const { projectPath, mergeRequestIID } = this;
+
+ return {
+ projectPath,
+ mergeRequestIID,
+ };
+ },
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-popover :target="target" boundary="viewport" placement="top" show>
+ <div class="mr-popover">
+ <div v-if="$apollo.loading">
+ <gl-skeleton-loading :lines="1" class="animation-container-small mt-1" />
+ </div>
+ <div v-else-if="showDetails" class="d-flex align-items-center justify-content-between">
+ <div class="d-inline-flex align-items-center">
+ <div :class="`issuable-status-box status-box ${statusBoxClass}`">
+ {{ stateHumanName }}
+ </div>
+ <span class="text-secondary">Opened <time v-text="formattedTime"></time></span>
+ </div>
+ <ci-icon v-if="detailedStatus" :status="detailedStatus" />
+ </div>
+ <h5 class="my-2">{{ mergeRequestTitle }}</h5>
+ <div class="text-secondary">
+ {{ `${projectPath}!${mergeRequestIID}` }}
+ </div>
+ </div>
+ </gl-popover>
+</template>
diff --git a/app/assets/javascripts/mr_popover/constants.js b/app/assets/javascripts/mr_popover/constants.js
new file mode 100644
index 00000000000..c13c417cc18
--- /dev/null
+++ b/app/assets/javascripts/mr_popover/constants.js
@@ -0,0 +1,12 @@
+import { __ } from '~/locale';
+
+export const mrStates = {
+ merged: 'merged',
+ closed: 'closed',
+};
+
+export const humanMRStates = {
+ merged: __('Merged'),
+ closed: __('Closed'),
+ open: __('Open'),
+};
diff --git a/app/assets/javascripts/mr_popover/index.js b/app/assets/javascripts/mr_popover/index.js
new file mode 100644
index 00000000000..18c0e201300
--- /dev/null
+++ b/app/assets/javascripts/mr_popover/index.js
@@ -0,0 +1,67 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import MRPopover from './components/mr_popover.vue';
+import createDefaultClient from '~/lib/graphql';
+
+let renderedPopover;
+let renderFn;
+
+const handleUserPopoverMouseOut = ({ target }) => {
+ target.removeEventListener('mouseleave', handleUserPopoverMouseOut);
+
+ if (renderFn) {
+ clearTimeout(renderFn);
+ }
+ if (renderedPopover) {
+ renderedPopover.$destroy();
+ renderedPopover = null;
+ }
+};
+
+/**
+ * Adds a MergeRequestPopover component to the body, hands over as much data as the target element has in data attributes.
+ * loads based on data-project-path and data-iid more data about an MR from the API and sets it on the popover
+ */
+const handleMRPopoverMount = ({ apolloProvider, projectPath, mrTitle, iid }) => ({ target }) => {
+ // Add listener to actually remove it again
+ target.addEventListener('mouseleave', handleUserPopoverMouseOut);
+
+ renderFn = setTimeout(() => {
+ const MRPopoverComponent = Vue.extend(MRPopover);
+ renderedPopover = new MRPopoverComponent({
+ propsData: {
+ target,
+ projectPath,
+ mergeRequestIID: iid,
+ mergeRequestTitle: mrTitle,
+ },
+ apolloProvider,
+ });
+
+ renderedPopover.$mount();
+ }, 200); // 200ms delay so not every mouseover triggers Popover + API Call
+};
+
+export default elements => {
+ const mrLinks = elements || [...document.querySelectorAll('.gfm-merge_request')];
+ if (mrLinks.length > 0) {
+ Vue.use(VueApollo);
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+ const listenerAddedAttr = 'data-mr-listener-added';
+
+ mrLinks.forEach(el => {
+ const { projectPath, mrTitle, iid } = el.dataset;
+
+ if (!el.getAttribute(listenerAddedAttr) && projectPath && mrTitle && iid) {
+ el.addEventListener(
+ 'mouseenter',
+ handleMRPopoverMount({ apolloProvider, projectPath, mrTitle, iid }),
+ );
+ el.setAttribute(listenerAddedAttr, true);
+ }
+ });
+ }
+};
diff --git a/app/assets/javascripts/mr_popover/queries/merge_request.graphql b/app/assets/javascripts/mr_popover/queries/merge_request.graphql
new file mode 100644
index 00000000000..0bb9bc03bc7
--- /dev/null
+++ b/app/assets/javascripts/mr_popover/queries/merge_request.graphql
@@ -0,0 +1,14 @@
+query mergeRequest($projectPath: ID!, $mergeRequestIID: ID!) {
+ project(fullPath: $projectPath) {
+ mergeRequest(iid: $mergeRequestIID) {
+ createdAt
+ state
+ headPipeline {
+ detailedStatus {
+ icon
+ group
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js
index ee1a5274ff7..03d349ac714 100644
--- a/app/assets/javascripts/namespace_select.js
+++ b/app/assets/javascripts/namespace_select.js
@@ -4,6 +4,7 @@ import $ from 'jquery';
import Api from './api';
import { mergeUrlParams } from './lib/utils/url_utility';
import { parseBoolean } from '~/lib/utils/common_utils';
+import { __ } from './locale';
export default class NamespaceSelect {
constructor(opts) {
@@ -29,7 +30,7 @@ export default class NamespaceSelect {
return Api.namespaces(term, function(namespaces) {
if (isFilter) {
const anyNamespace = {
- text: 'Any namespace',
+ text: __('Any namespace'),
id: null,
};
namespaces.unshift(anyNamespace);
diff --git a/app/assets/javascripts/namespaces/leave_by_url.js b/app/assets/javascripts/namespaces/leave_by_url.js
new file mode 100644
index 00000000000..b817d38960c
--- /dev/null
+++ b/app/assets/javascripts/namespaces/leave_by_url.js
@@ -0,0 +1,22 @@
+import Flash from '~/flash';
+import { __, sprintf } from '~/locale';
+import { getParameterByName } from '~/lib/utils/common_utils';
+
+const PARAMETER_NAME = 'leave';
+const LEAVE_LINK_SELECTOR = '.js-leave-link';
+
+export default function leaveByUrl(namespaceType) {
+ if (!namespaceType) throw new Error('namespaceType not provided');
+
+ const param = getParameterByName(PARAMETER_NAME);
+ if (!param) return;
+
+ const leaveLink = document.querySelector(LEAVE_LINK_SELECTOR);
+ if (leaveLink) {
+ leaveLink.click();
+ } else {
+ Flash(
+ sprintf(__('You do not have permission to leave this %{namespaceType}.'), { namespaceType }),
+ );
+ }
+}
diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue
index c5ae7e7ee10..b59ddd0d57a 100644
--- a/app/assets/javascripts/notebook/cells/output/index.vue
+++ b/app/assets/javascripts/notebook/cells/output/index.vue
@@ -20,12 +20,20 @@ export default {
required: true,
},
},
- data() {
- return {
- outputType: '',
- };
- },
methods: {
+ outputType(output) {
+ if (output.text) {
+ return 'text/plain';
+ } else if (output.data['image/png']) {
+ return 'image/png';
+ } else if (output.data['text/html']) {
+ return 'text/html';
+ } else if (output.data['image/svg+xml']) {
+ return 'image/svg+xml';
+ }
+
+ return 'text/plain';
+ },
dataForType(output, type) {
let data = output.data[type];
@@ -39,20 +47,13 @@ export default {
if (output.text) {
return CodeOutput;
} else if (output.data['image/png']) {
- this.outputType = 'image/png';
-
return ImageOutput;
} else if (output.data['text/html']) {
- this.outputType = 'text/html';
-
return HtmlOutput;
} else if (output.data['image/svg+xml']) {
- this.outputType = 'image/svg+xml';
-
return HtmlOutput;
}
- this.outputType = 'text/plain';
return CodeOutput;
},
rawCode(output) {
@@ -60,7 +61,7 @@ export default {
return output.text.join('');
}
- return this.dataForType(output, this.outputType);
+ return this.dataForType(output, this.outputType(output));
},
},
};
@@ -73,7 +74,7 @@ export default {
v-for="(output, index) in outputs"
:key="index"
type="output"
- :output-type="outputType"
+ :output-type="outputType(output)"
:count="count"
:index="index"
:raw-code="rawCode(output)"
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index c9c01354333..a7156bd2406 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -7,12 +7,16 @@ no-unused-vars, no-shadow, no-useless-escape, class-methods-use-this */
/* global ResolveService */
/* global mrRefreshWidgetUrl */
+/*
+old_notes_spec.js is the spec for the legacy, jQuery notes application. It has nothing to do with the new, fancy Vue notes app.
+ */
+
import $ from 'jquery';
import _ from 'underscore';
import Cookies from 'js-cookie';
import Autosize from 'autosize';
-import 'vendor/jquery.caret'; // required by jquery.atwho
-import 'vendor/jquery.atwho';
+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';
@@ -35,6 +39,7 @@ import {
} from './lib/utils/common_utils';
import imageDiffHelper from './image_diff/helpers/index';
import { localTimeAgo } from './lib/utils/datetime_utility';
+import { sprintf, s__, __ } from './locale';
window.autosize = Autosize;
@@ -253,7 +258,7 @@ export default class Notes {
discussionNoteForm = $textarea.closest('.js-discussion-note-form');
if (discussionNoteForm.length) {
if ($textarea.val() !== '') {
- if (!window.confirm('Are you sure you want to cancel creating this comment?')) {
+ if (!window.confirm(__('Are you sure you want to cancel creating this comment?'))) {
return;
}
}
@@ -265,7 +270,7 @@ export default class Notes {
originalText = $textarea.closest('form').data('originalNote');
newText = $textarea.val();
if (originalText !== newText) {
- if (!window.confirm('Are you sure you want to cancel editing this comment?')) {
+ if (!window.confirm(__('Are you sure you want to cancel editing this comment?'))) {
return;
}
}
@@ -506,7 +511,7 @@ export default class Notes {
var contentContainerClass =
'.' +
$notes
- .closest('.notes_content')
+ .closest('.notes-content')
.attr('class')
.split(' ')
.join('.');
@@ -636,7 +641,7 @@ export default class Notes {
this.glForm = new GLForm(form, enableGFM);
textarea = form.find('.js-note-text');
key = [
- 'Note',
+ s__('NoteForm|Note'),
form.find('#note_noteable_type').val(),
form.find('#note_noteable_id').val(),
form.find('#note_commit_id').val(),
@@ -670,7 +675,9 @@ export default class Notes {
formParentTimeline = $form.closest('.discussion-notes').find('.notes');
}
return this.addFlash(
- 'Your comment could not be submitted! Please check your network connection and try again.',
+ __(
+ 'Your comment could not be submitted! Please check your network connection and try again.',
+ ),
'alert',
formParentTimeline.get(0),
);
@@ -679,7 +686,7 @@ export default class Notes {
updateNoteError($parentTimeline) {
// eslint-disable-next-line no-new
new Flash(
- 'Your comment could not be updated! Please check your network connection and try again.',
+ __('Your comment could not be updated! Please check your network connection and try again.'),
);
}
@@ -983,6 +990,14 @@ export default class Notes {
form.find('#note_position').val(dataHolder.attr('data-position'));
form
+ .prepend(
+ `<div class="avatar-note-form-holder"><div class="content"><a href="${escape(
+ gon.current_username,
+ )}" class="user-avatar-link d-none d-sm-block"><img class="avatar s40" src="${encodeURI(
+ gon.current_user_avatar_url,
+ )}" alt="${escape(gon.current_user_fullname)}" /></a></div></div>`,
+ )
+ .append('</div>')
.find('.js-close-discussion-note-form')
.show()
.removeClass('hide');
@@ -1018,6 +1033,9 @@ export default class Notes {
target: $link,
lineType: link.dataset.lineType,
showReplyInput,
+ currentUsername: gon.current_username,
+ currentUserAvatar: gon.current_user_avatar_url,
+ currentUserFullname: gon.current_user_fullname,
});
}
@@ -1046,7 +1064,15 @@ export default class Notes {
this.setupDiscussionNoteForm($link, newForm);
}
- toggleDiffNote({ target, lineType, forceShow, showReplyInput = false }) {
+ toggleDiffNote({
+ target,
+ lineType,
+ forceShow,
+ showReplyInput = false,
+ currentUsername,
+ currentUserAvatar,
+ currentUserFullname,
+ }) {
var $link,
addForm,
hasNotes,
@@ -1069,14 +1095,14 @@ export default class Notes {
addForm = false;
let lineTypeSelector = '';
rowCssToAdd =
- '<tr class="notes_holder js-temp-notes-holder"><td class="notes_content" colspan="3"><div class="content"></div></td></tr>';
+ '<tr class="notes_holder js-temp-notes-holder"><td class="notes-content" colspan="3"><div class="content"></div></td></tr>';
// In parallel view, look inside the correct left/right pane
if (this.isParallelView()) {
lineTypeSelector = `.${lineType}`;
rowCssToAdd =
- '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes_content parallel old"><div class="content"></div></td><td class="notes_line new"></td><td class="notes_content parallel new"><div class="content"></div></td></tr>';
+ '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes-content parallel old"><div class="content"></div></td><td class="notes_line new"></td><td class="notes-content parallel new"><div class="content"></div></td></tr>';
}
- const notesContentSelector = `.notes_content${lineTypeSelector} .content`;
+ const notesContentSelector = `.notes-content${lineTypeSelector} .content`;
let notesContent = targetRow.find(notesContentSelector);
if (hasNotes && showReplyInput) {
@@ -1258,12 +1284,19 @@ export default class Notes {
putConflictEditWarningInPlace(noteEntity, $note) {
if ($note.find('.js-conflict-edit-warning').length === 0) {
+ const open_link = `<a href="#note_${
+ noteEntity.id
+ }" target="_blank" rel="noopener noreferrer">`;
const $alert = $(`<div class="js-conflict-edit-warning alert alert-danger">
- This comment has changed since you started editing, please review the
- <a href="#note_${noteEntity.id}" target="_blank" rel="noopener noreferrer">
- updated comment
- </a>
- to ensure information is not lost
+ ${sprintf(
+ s__(
+ 'Notes|This comment has changed since you started editing, please review the %{open_link}updated comment%{close_link} to ensure information is not lost',
+ ),
+ {
+ open_link,
+ close_link: '</a>',
+ },
+ )}
</div>`);
$alert.insertAfter($note.find('.note-text'));
}
@@ -1491,13 +1524,15 @@ export default class Notes {
if (executedCommands && executedCommands.length) {
if (executedCommands.length > 1) {
- tempFormContent = 'Applying multiple commands';
+ tempFormContent = __('Applying multiple commands');
} else {
const commandDescription = executedCommands[0].description.toLowerCase();
- tempFormContent = `Applying command to ${commandDescription}`;
+ tempFormContent = sprintf(__('Applying command to %{commandDescription}'), {
+ commandDescription,
+ });
}
} else {
- tempFormContent = 'Applying command';
+ tempFormContent = __('Applying command');
}
return tempFormContent;
@@ -1530,7 +1565,9 @@ export default class Notes {
<div class="note-header">
<div class="note-header-info">
<a href="/${_.escape(currentUsername)}">
- <span class="d-none d-sm-inline-block">${_.escape(currentUsername)}</span>
+ <span class="d-none d-sm-inline-block bold">${_.escape(
+ currentUsername,
+ )}</span>
<span class="note-headline-light">${_.escape(currentUsername)}</span>
</a>
</div>
@@ -1817,7 +1854,9 @@ export default class Notes {
$editingNote
.find('.note-headline-meta a')
.html(
- '<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>',
+ `<i class="fa fa-spinner fa-spin" aria-label="${__(
+ 'Comment is being updated',
+ )}" aria-hidden="true"></i>`,
);
// Make request to update comment on server
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 1d6cb9485f7..075c28e8d07 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -11,6 +11,7 @@ import {
capitalizeFirstCharacter,
convertToCamelCase,
splitCamelCase,
+ slugifyWithUnderscore,
} from '../../lib/utils/text_utility';
import * as constants from '../constants';
import eventHub from '../event_hub';
@@ -115,8 +116,11 @@ export default {
author() {
return this.getUserData;
},
- canUpdateIssue() {
- return this.getNoteableData.current_user.can_update;
+ canToggleIssueState() {
+ return (
+ this.getNoteableData.current_user.can_update &&
+ this.getNoteableData.state !== constants.MERGED
+ );
},
endpoint() {
return this.getNoteableData.create_note_path;
@@ -126,6 +130,9 @@ export default {
? 'merge request'
: 'issue';
},
+ trackingLabel() {
+ return slugifyWithUnderscore(`${this.commentButtonTitle} button`);
+ },
},
watch: {
note(newNote) {
@@ -330,6 +337,8 @@ Please check your network connection and try again.`;
v-if="hasWarning(getNoteableData)"
:is-locked="isLocked(getNoteableData)"
:is-confidential="isConfidential(getNoteableData)"
+ :locked-issue-docs-path="lockedIssueDocsPath"
+ :confidential-issue-docs-path="confidentialIssueDocsPath"
/>
<markdown-field
@@ -344,6 +353,7 @@ Please check your network connection and try again.`;
ref="textarea"
slot="textarea"
v-model="note"
+ dir="auto"
:disabled="isSubmitting"
name="note[note]"
class="note-textarea js-vue-comment-form js-note-text
@@ -367,6 +377,8 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
class="btn btn-success js-comment-button js-comment-submit-button
qa-comment-button"
type="submit"
+ :data-track-label="trackingLabel"
+ data-track-event="click_button"
@click.prevent="handleSave()"
>
{{ __(commentButtonTitle) }}
@@ -415,7 +427,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
</div>
<loading-button
- v-if="canUpdateIssue"
+ v-if="canToggleIssueState"
:loading="isToggleStateButtonLoading"
:container-class="[
actionButtonClassNames,
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
index d8947e8ca50..b95835ed10a 100644
--- a/app/assets/javascripts/notes/components/diff_with_note.vue
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -72,8 +72,8 @@ export default {
:can-current-user-fork="false"
:expanded="!discussion.diff_file.viewer.collapsed"
/>
- <div v-if="isTextFile" :class="$options.userColorSchemeClass" class="diff-content code">
- <table>
+ <div v-if="isTextFile" class="diff-content">
+ <table class="code js-syntax-highlight" :class="$options.userColorSchemeClass">
<template v-if="hasTruncatedDiffLines">
<tr
v-for="line in discussion.truncated_diff_lines"
@@ -81,8 +81,8 @@ export default {
:key="line.line_code"
class="line_holder"
>
- <td class="diff-line-num old_line">{{ line.old_line }}</td>
- <td class="diff-line-num new_line">{{ line.new_line }}</td>
+ <td :class="line.type" class="diff-line-num old_line">{{ line.old_line }}</td>
+ <td :class="line.type" class="diff-line-num new_line">{{ line.new_line }}</td>
<td :class="line.type" class="line_content" v-html="line.rich_text"></td>
</tr>
</template>
@@ -105,7 +105,7 @@ export default {
</td>
</tr>
<tr class="notes_holder">
- <td class="notes_content" colspan="3"><slot></slot></td>
+ <td class="notes-content" colspan="3"><slot></slot></td>
</tr>
</table>
</div>
diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue
new file mode 100644
index 00000000000..22cca756ef6
--- /dev/null
+++ b/app/assets/javascripts/notes/components/discussion_actions.vue
@@ -0,0 +1,58 @@
+<script>
+import ReplyPlaceholder from './discussion_reply_placeholder.vue';
+import ResolveDiscussionButton from './discussion_resolve_button.vue';
+import ResolveWithIssueButton from './discussion_resolve_with_issue_button.vue';
+import JumpToNextDiscussionButton from './discussion_jump_to_next_button.vue';
+
+export default {
+ name: 'DiscussionActions',
+ components: {
+ ReplyPlaceholder,
+ ResolveDiscussionButton,
+ ResolveWithIssueButton,
+ JumpToNextDiscussionButton,
+ },
+ props: {
+ discussion: {
+ type: Object,
+ required: true,
+ },
+ isResolving: {
+ type: Boolean,
+ required: true,
+ },
+ resolveButtonTitle: {
+ type: String,
+ required: true,
+ },
+ resolveWithIssuePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ shouldShowJumpToNextDiscussion: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="discussion-with-resolve-btn">
+ <reply-placeholder class="qa-discussion-reply" @onClick="$emit('showReplyForm')" />
+ <resolve-discussion-button
+ v-if="discussion.resolvable"
+ :is-resolving="isResolving"
+ :button-title="resolveButtonTitle"
+ @onClick="$emit('resolve')"
+ />
+ <div v-if="discussion.resolvable" class="btn-group discussion-actions ml-sm-2" role="group">
+ <resolve-with-issue-button v-if="resolveWithIssuePath" :url="resolveWithIssuePath" />
+ <jump-to-next-discussion-button
+ v-if="shouldShowJumpToNextDiscussion"
+ @onClick="$emit('jumpToNextDiscussion')"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index c7cfc0f0f3b..efd84f5722c 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -49,22 +49,26 @@ export default {
</script>
<template>
- <div v-if="resolvableDiscussionsCount > 0" class="line-resolve-all-container prepend-top-8">
- <div>
+ <div v-if="resolvableDiscussionsCount > 0" class="line-resolve-all-container full-width-mobile">
+ <div class="full-width-mobile d-flex d-sm-block">
<div :class="{ 'has-next-btn': hasNextButton }" class="line-resolve-all">
<span
:class="{ 'is-active': allResolved }"
class="line-resolve-btn is-disabled"
type="button"
>
- <icon name="check-circle" />
+ <icon :name="allResolved ? 'check-circle-filled' : 'check-circle'" />
</span>
<span class="line-resolve-text">
{{ resolvedDiscussionsCount }}/{{ resolvableDiscussionsCount }}
{{ n__('discussion resolved', 'discussions resolved', resolvableDiscussionsCount) }}
</span>
</div>
- <div v-if="resolveAllDiscussionsIssuePath && !allResolved" class="btn-group" role="group">
+ <div
+ v-if="resolveAllDiscussionsIssuePath && !allResolved"
+ class="btn-group btn-group-sm"
+ role="group"
+ >
<a
v-gl-tooltip
:href="resolveAllDiscussionsIssuePath"
@@ -74,7 +78,7 @@ export default {
<icon name="issue-new" />
</a>
</div>
- <div v-if="isLoggedIn && !allResolved" class="btn-group" role="group">
+ <div v-if="isLoggedIn && !allResolved" class="btn-group btn-group-sm" role="group">
<button
v-gl-tooltip
title="Jump to first unresolved discussion"
diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue
index e03d6e9cd02..eb3fbbe1385 100644
--- a/app/assets/javascripts/notes/components/discussion_filter.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter.vue
@@ -7,7 +7,9 @@ import {
DISCUSSION_FILTERS_DEFAULT_VALUE,
HISTORY_ONLY_FILTER_VALUE,
DISCUSSION_TAB_LABEL,
+ DISCUSSION_FILTER_TYPES,
} from '../constants';
+import notesEventHub from '../event_hub';
export default {
components: {
@@ -20,7 +22,7 @@ export default {
},
selectedValue: {
type: Number,
- default: null,
+ default: DISCUSSION_FILTERS_DEFAULT_VALUE,
required: false,
},
},
@@ -46,6 +48,7 @@ export default {
this.toggleFilters(currentTab);
}
+ notesEventHub.$on('dropdownSelect', this.selectFilter);
window.addEventListener('hashchange', this.handleLocationHash);
this.handleLocationHash();
},
@@ -53,6 +56,7 @@ export default {
this.toggleCommentsForm();
},
destroyed() {
+ notesEventHub.$off('dropdownSelect', this.selectFilter);
window.removeEventListener('hashchange', this.handleLocationHash);
},
methods: {
@@ -86,28 +90,44 @@ export default {
this.setTargetNoteHash(hash);
}
},
+ filterType(value) {
+ if (value === 0) {
+ return DISCUSSION_FILTER_TYPES.ALL;
+ } else if (value === 1) {
+ return DISCUSSION_FILTER_TYPES.COMMENTS;
+ }
+ return DISCUSSION_FILTER_TYPES.HISTORY;
+ },
},
};
</script>
<template>
- <div v-if="displayFilters" class="discussion-filter-container d-inline-block align-bottom">
+ <div
+ v-if="displayFilters"
+ class="discussion-filter-container js-discussion-filter-container d-inline-block align-bottom full-width-mobile"
+ >
<button
id="discussion-filter-dropdown"
ref="dropdownToggle"
- class="btn btn-default qa-discussion-filter"
+ class="btn btn-sm qa-discussion-filter"
data-toggle="dropdown"
aria-expanded="false"
>
{{ currentFilter.title }} <icon name="chevron-down" />
</button>
<div
+ ref="dropdownMenu"
class="dropdown-menu dropdown-menu-selectable dropdown-menu-right"
aria-labelledby="discussion-filter-dropdown"
>
<div class="dropdown-content">
<ul>
- <li v-for="filter in filters" :key="filter.value">
+ <li
+ v-for="filter in filters"
+ :key="filter.value"
+ :data-filter-type="filterType(filter.value)"
+ >
<button
:class="{ 'is-active': filter.value === currentValue }"
class="qa-filter-options"
diff --git a/app/assets/javascripts/notes/components/discussion_filter_note.vue b/app/assets/javascripts/notes/components/discussion_filter_note.vue
new file mode 100644
index 00000000000..889731df180
--- /dev/null
+++ b/app/assets/javascripts/notes/components/discussion_filter_note.vue
@@ -0,0 +1,52 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+import { __, sprintf } from '~/locale';
+
+import notesEventHub from '../event_hub';
+
+export default {
+ components: {
+ GlButton,
+ Icon,
+ },
+ computed: {
+ timelineContent() {
+ return sprintf(
+ __(
+ "You're only seeing %{startTag}other activity%{endTag} in the feed. To add a comment, switch to one of the following options.",
+ ),
+ {
+ startTag: `<b>`,
+ endTag: `</b>`,
+ },
+ false,
+ );
+ },
+ },
+ methods: {
+ selectFilter(value) {
+ notesEventHub.$emit('dropdownSelect', value);
+ },
+ },
+};
+</script>
+
+<template>
+ <li class="timeline-entry note note-wrapper discussion-filter-note js-discussion-filter-note">
+ <div class="timeline-icon d-none d-lg-flex">
+ <icon name="comment" />
+ </div>
+ <div class="timeline-content">
+ <div v-html="timelineContent"></div>
+ <div class="discussion-filter-actions mt-2">
+ <gl-button variant="default" @click="selectFilter(0)">
+ {{ __('Show all activity') }}
+ </gl-button>
+ <gl-button variant="default" @click="selectFilter(1)">
+ {{ __('Show comments only') }}
+ </gl-button>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/notes/components/discussion_locked_widget.vue b/app/assets/javascripts/notes/components/discussion_locked_widget.vue
index c469a6b7bcd..53f509185a8 100644
--- a/app/assets/javascripts/notes/components/discussion_locked_widget.vue
+++ b/app/assets/javascripts/notes/components/discussion_locked_widget.vue
@@ -1,12 +1,24 @@
<script>
+import { GlLink } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
+import { __, sprintf } from '~/locale';
import Issuable from '~/vue_shared/mixins/issuable';
+import issuableStateMixin from '../mixins/issuable_state';
export default {
components: {
Icon,
+ GlLink,
+ },
+ mixins: [Issuable, issuableStateMixin],
+ computed: {
+ lockedIssueWarning() {
+ return sprintf(
+ __('This %{issuableDisplayName} is locked. Only project members can comment.'),
+ { issuableDisplayName: this.issuableDisplayName },
+ );
+ },
},
- mixins: [Issuable],
};
</script>
@@ -15,7 +27,11 @@ export default {
<span class="issuable-note-warning inline">
<icon :size="16" name="lock" class="icon" />
<span>
- This {{ issuableDisplayName }} is locked. Only <b>project members</b> can comment.
+ {{ lockedIssueWarning }}
+
+ <gl-link :href="lockedIssueDocsPath" target="_blank" class="learn-more">
+ {{ __('Learn more') }}
+ </gl-link>
</span>
</span>
</div>
diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue
new file mode 100644
index 00000000000..228bb652597
--- /dev/null
+++ b/app/assets/javascripts/notes/components/discussion_notes.vue
@@ -0,0 +1,155 @@
+<script>
+import { mapGetters } from 'vuex';
+import { SYSTEM_NOTE } from '../constants';
+import { __ } from '~/locale';
+import NoteableNote from './noteable_note.vue';
+import PlaceholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
+import PlaceholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
+import SystemNote from '~/vue_shared/components/notes/system_note.vue';
+import ToggleRepliesWidget from './toggle_replies_widget.vue';
+import NoteEditedText from './note_edited_text.vue';
+
+export default {
+ name: 'DiscussionNotes',
+ components: {
+ ToggleRepliesWidget,
+ NoteEditedText,
+ },
+ props: {
+ discussion: {
+ type: Object,
+ required: true,
+ },
+ isExpanded: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ diffLine: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ line: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ shouldGroupReplies: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ ...mapGetters(['userCanReply']),
+ hasReplies() {
+ return Boolean(this.replies.length);
+ },
+ replies() {
+ return this.discussion.notes.slice(1);
+ },
+ firstNote() {
+ return this.discussion.notes.slice(0, 1)[0];
+ },
+ resolvedText() {
+ return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved');
+ },
+ commit() {
+ if (!this.discussion.for_commit) {
+ return null;
+ }
+
+ return {
+ id: this.discussion.commit_id,
+ url: this.discussion.discussion_path,
+ };
+ },
+ },
+ methods: {
+ componentName(note) {
+ if (note.isPlaceholderNote) {
+ if (note.placeholderType === SYSTEM_NOTE) {
+ return PlaceholderSystemNote;
+ }
+
+ return PlaceholderNote;
+ }
+
+ if (note.system) {
+ return SystemNote;
+ }
+
+ return NoteableNote;
+ },
+ componentData(note) {
+ return note.isPlaceholderNote ? note.notes[0] : note;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="discussion-notes">
+ <ul class="notes">
+ <template v-if="shouldGroupReplies">
+ <component
+ :is="componentName(firstNote)"
+ :note="componentData(firstNote)"
+ :line="line"
+ :commit="commit"
+ :help-page-path="helpPagePath"
+ :show-reply-button="userCanReply"
+ @handle-delete-note="$emit('deleteNote')"
+ @start-replying="$emit('startReplying')"
+ >
+ <note-edited-text
+ v-if="discussion.resolved"
+ slot="discussion-resolved-text"
+ :edited-at="discussion.resolved_at"
+ :edited-by="discussion.resolved_by"
+ :action-text="resolvedText"
+ class-name="discussion-headline-light js-discussion-headline discussion-resolved-text"
+ />
+ <slot slot="avatar-badge" name="avatar-badge"></slot>
+ </component>
+ <toggle-replies-widget
+ v-if="hasReplies"
+ :collapsed="!isExpanded"
+ :replies="replies"
+ @toggle="$emit('toggleDiscussion')"
+ />
+ <template v-if="isExpanded">
+ <component
+ :is="componentName(note)"
+ v-for="note in replies"
+ :key="note.id"
+ :note="componentData(note)"
+ :help-page-path="helpPagePath"
+ :line="line"
+ @handle-delete-note="$emit('deleteNote')"
+ />
+ </template>
+ </template>
+ <template v-else>
+ <component
+ :is="componentName(note)"
+ v-for="(note, index) in discussion.notes"
+ :key="note.id"
+ :note="componentData(note)"
+ :help-page-path="helpPagePath"
+ :line="diffLine"
+ @handle-delete-note="$emit('deleteNote')"
+ >
+ <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot>
+ </component>
+ </template>
+ </ul>
+ <slot :show-replies="isExpanded || !hasReplies" name="footer"></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index de1ea0f58d6..844d0c3e376 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -2,6 +2,7 @@
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 ReplyButton from './note_actions/reply_button.vue';
export default {
@@ -14,6 +15,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [resolvedStatusMixin],
props: {
authorId: {
type: Number,
@@ -86,9 +88,6 @@ export default {
},
computed: {
...mapGetters(['getUserDataByProp']),
- showReplyButton() {
- return gon.features && gon.features.replyToIndividualNotes && this.showReply;
- },
shouldShowActionsDropdown() {
return this.currentUserId && (this.canEdit || this.canReportAsAbuse);
},
@@ -101,15 +100,6 @@ export default {
currentUserId() {
return this.getUserDataByProp('id');
},
- resolveButtonTitle() {
- let title = 'Mark as resolved';
-
- if (this.resolvedBy) {
- title = `Resolved by ${this.resolvedBy.name}`;
- }
-
- return title;
- },
},
methods: {
onEdit() {
@@ -145,7 +135,7 @@ export default {
@click="onResolve"
>
<template v-if="!isResolving">
- <icon name="check-circle" />
+ <icon :name="isResolved ? 'check-circle-filled' : 'check-circle'" />
</template>
<gl-loading-icon v-else inline />
</button>
@@ -157,18 +147,15 @@ export default {
class="note-action-button note-emoji-button js-add-award js-note-emoji"
href="#"
title="Add reaction"
+ data-position="right"
>
- <gl-loading-icon inline />
- <icon
- css-classes="link-highlight award-control-icon-neutral"
- name="emoji_slightly_smiling_face"
- />
- <icon css-classes="link-highlight award-control-icon-positive" name="emoji_smiley" />
- <icon css-classes="link-highlight award-control-icon-super-positive" name="emoji_smiley" />
+ <icon css-classes="link-highlight award-control-icon-neutral" name="slight-smile" />
+ <icon css-classes="link-highlight award-control-icon-positive" name="smiley" />
+ <icon css-classes="link-highlight award-control-icon-super-positive" name="smiley" />
</a>
</div>
<reply-button
- v-if="showReplyButton"
+ v-if="showReply"
ref="replyButton"
class="js-reply-button"
@startReplying="$emit('startReplying')"
@@ -208,7 +195,7 @@ export default {
</button>
<ul class="dropdown-menu more-actions-dropdown dropdown-open-left">
<li v-if="canReportAsAbuse">
- <a :href="reportAbusePath">{{ __('Report abuse to GitLab') }}</a>
+ <a :href="reportAbusePath">{{ __('Report abuse to admin') }}</a>
</li>
<li v-if="noteUrl">
<button
diff --git a/app/assets/javascripts/notes/components/note_actions/reply_button.vue b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
index f50cab81efe..be8e42af9ea 100644
--- a/app/assets/javascripts/notes/components/note_actions/reply_button.vue
+++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
@@ -18,7 +18,7 @@ export default {
<div class="note-actions-item">
<gl-button
ref="button"
- v-gl-tooltip.bottom
+ v-gl-tooltip
class="note-action-button"
variant="transparent"
:title="__('Reply to comment')"
diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue
index 17e5fcab5b7..941b6d5cab3 100644
--- a/app/assets/javascripts/notes/components/note_awards_list.vue
+++ b/app/assets/javascripts/notes/components/note_awards_list.vue
@@ -189,13 +189,13 @@ export default {
type="button"
>
<span class="award-control-icon award-control-icon-neutral">
- <icon name="emoji_slightly_smiling_face" />
+ <icon name="slight-smile" />
</span>
<span class="award-control-icon award-control-icon-positive">
- <icon name="emoji_smiley" />
+ <icon name="smiley" />
</span>
<span class="award-control-icon award-control-icon-super-positive">
- <icon name="emoji_smiley" />
+ <icon name="smiley" />
</span>
<i
aria-hidden="true"
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index fb1d98355b3..88454c3fb4c 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -1,6 +1,7 @@
<script>
import { mapActions } from 'vuex';
import $ from 'jquery';
+import getDiscussion from 'ee_else_ce/notes/mixins/get_discussion';
import noteEditedText from './note_edited_text.vue';
import noteAwardsList from './note_awards_list.vue';
import noteAttachment from './note_attachment.vue';
@@ -16,7 +17,7 @@ export default {
noteForm,
Suggestions,
},
- mixins: [autosave],
+ mixins: [autosave, getDiscussion],
props: {
note: {
type: Object,
@@ -76,16 +77,18 @@ export default {
renderGFM() {
$(this.$refs['note-body']).renderGFM();
},
- handleFormUpdate(note, parentElement, callback) {
- this.$emit('handleFormUpdate', note, parentElement, callback);
+ handleFormUpdate(note, parentElement, callback, resolveDiscussion) {
+ this.$emit('handleFormUpdate', note, parentElement, callback, resolveDiscussion);
},
formCancelHandler(shouldConfirm, isDirty) {
this.$emit('cancelForm', shouldConfirm, isDirty);
},
- applySuggestion({ suggestionId, flashContainer, callback }) {
+ applySuggestion({ suggestionId, flashContainer, callback = () => {} }) {
const { discussion_id: discussionId, id: noteId } = this.note;
- this.submitSuggestion({ discussionId, noteId, suggestionId, flashContainer, callback });
+ return this.submitSuggestion({ discussionId, noteId, suggestionId, flashContainer }).then(
+ callback,
+ );
},
},
};
@@ -95,7 +98,6 @@ export default {
<div ref="note-body" :class="{ 'js-task-list-container': canEdit }" class="note-body">
<suggestions
v-if="hasSuggestion && !isEditing"
- class="note-text md"
:suggestions="note.suggestions"
:note-html="note.note_html"
:line-type="lineType"
@@ -112,6 +114,8 @@ export default {
:line="line"
:note="note"
:help-page-path="helpPagePath"
+ :discussion="discussion"
+ :resolve-discussion="note.resolve_discussion"
@handleFormUpdate="handleFormUpdate"
@cancelForm="formCancelHandler"
/>
@@ -120,6 +124,7 @@ export default {
v-model="note.note"
:data-update-url="note.path"
class="hidden js-task-list-field"
+ dir="auto"
></textarea>
<note-edited-text
v-if="note.last_edited_at"
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 92258a25438..09ecb695214 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -7,6 +7,8 @@ import markdownField from '../../vue_shared/components/markdown/field.vue';
import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
import { __ } from '~/locale';
+import { getDraft, updateDraft } from '~/lib/utils/autosave';
+import noteFormMixin from 'ee_else_ce/notes/mixins/note_form';
export default {
name: 'NoteForm',
@@ -14,7 +16,7 @@ export default {
issueWarning,
markdownField,
},
- mixins: [issuableStateMixin, resolvable],
+ mixins: [issuableStateMixin, resolvable, noteFormMixin],
props: {
noteBody: {
type: String,
@@ -60,15 +62,31 @@ export default {
required: false,
default: null,
},
+ diffFile: {
+ type: Object,
+ required: false,
+ default: null,
+ },
helpPagePath: {
type: String,
required: false,
default: '',
},
+ autosaveKey: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
+ let updatedNoteBody = this.noteBody;
+
+ if (!updatedNoteBody && this.autosaveKey) {
+ updatedNoteBody = getDraft(this.autosaveKey) || '';
+ }
+
return {
- updatedNoteBody: this.noteBody,
+ updatedNoteBody,
conflictWhileEditing: false,
isSubmitting: false,
isResolving: this.resolveDiscussion,
@@ -90,9 +108,42 @@ export default {
}
return '#';
},
+ diffParams() {
+ if (this.diffFile) {
+ return {
+ filePath: this.diffFile.file_path,
+ refs: this.diffFile.diff_refs,
+ };
+ } else if (this.note && this.note.position) {
+ return {
+ filePath: this.note.position.new_path,
+ refs: this.note.position,
+ };
+ } else if (this.discussion && this.discussion.diff_file) {
+ return {
+ filePath: this.discussion.diff_file.file_path,
+ refs: this.discussion.diff_file.diff_refs,
+ };
+ }
+
+ return null;
+ },
markdownPreviewPath() {
const notable = this.getNoteableDataByProp('preview_note_path');
- return mergeUrlParams({ preview_suggestions: true }, notable);
+
+ const previewSuggestions = this.line && this.diffParams;
+ const params = previewSuggestions
+ ? {
+ preview_suggestions: previewSuggestions,
+ line: this.line.new_line,
+ file_path: this.diffParams.filePath,
+ base_sha: this.diffParams.refs.base_sha,
+ start_sha: this.diffParams.refs.start_sha,
+ head_sha: this.diffParams.refs.head_sha,
+ }
+ : {};
+
+ return mergeUrlParams(params, notable);
},
markdownDocsPath() {
return this.getNotesDataByProp('markdownDocsPath');
@@ -145,21 +196,6 @@ export default {
return shouldResolve || shouldToggleState;
},
- handleKeySubmit() {
- this.handleUpdate();
- },
- handleUpdate(shouldResolve) {
- const beforeSubmitDiscussionState = this.discussionResolved;
- this.isSubmitting = true;
-
- this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => {
- this.isSubmitting = false;
-
- if (this.shouldToggleResolved(shouldResolve, beforeSubmitDiscussionState)) {
- this.resolveHandler(beforeSubmitDiscussionState);
- }
- });
- },
editMyLastNote() {
if (this.updatedNoteBody === '') {
const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion);
@@ -175,6 +211,12 @@ export default {
// Sends information about confirm message and if the textarea has changed
this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody);
},
+ onInput() {
+ if (this.autosaveKey) {
+ const { autosaveKey, updatedNoteBody: text } = this;
+ updateDraft(autosaveKey, text);
+ }
+ },
},
};
</script>
@@ -192,6 +234,8 @@ export default {
v-if="hasWarning(getNoteableData)"
:is-locked="isLocked(getNoteableData)"
:is-confidential="isConfidential(getNoteableData)"
+ :locked-issue-docs-path="lockedIssueDocsPath"
+ :confidential-issue-docs-path="confidentialIssueDocsPath"
/>
<markdown-field
@@ -212,37 +256,85 @@ export default {
:data-supports-quick-actions="!isEditing"
name="note[note]"
class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form js-vue-textarea qa-reply-input"
+ dir="auto"
aria-label="Description"
placeholder="Write a comment or drag your files here…"
@keydown.meta.enter="handleKeySubmit()"
@keydown.ctrl.enter="handleKeySubmit()"
- @keydown.up="editMyLastNote()"
- @keydown.esc="cancelHandler(true)"
+ @keydown.exact.up="editMyLastNote()"
+ @keydown.exact.esc="cancelHandler(true)"
+ @input="onInput"
></textarea>
</markdown-field>
<div class="note-form-actions clearfix">
- <button
- :disabled="isDisabled"
- type="button"
- class="js-vue-issue-save btn btn-success js-comment-button qa-reply-comment-button"
- @click="handleUpdate()"
- >
- {{ saveButtonTitle }}
- </button>
- <button
- v-if="discussion.resolvable"
- class="btn btn-nr btn-default append-right-10 js-comment-resolve-button"
- @click.prevent="handleUpdate(true)"
- >
- {{ resolveButtonTitle }}
- </button>
- <button
- class="btn btn-cancel note-edit-cancel js-close-discussion-note-form"
- type="button"
- @click="cancelHandler()"
- >
- Cancel
- </button>
+ <template v-if="showBatchCommentsActions">
+ <p v-if="showResolveDiscussionToggle">
+ <label>
+ <template v-if="discussionResolved">
+ <input
+ v-model="isUnresolving"
+ type="checkbox"
+ class="qa-unresolve-review-discussion"
+ />
+ {{ __('Unresolve discussion') }}
+ </template>
+ <template v-else>
+ <input v-model="isResolving" type="checkbox" class="qa-resolve-review-discussion" />
+ {{ __('Resolve discussion') }}
+ </template>
+ </label>
+ </p>
+ <div>
+ <button
+ :disabled="isDisabled"
+ type="button"
+ class="btn btn-success qa-start-review"
+ @click="handleAddToReview"
+ >
+ <template v-if="hasDrafts">{{ __('Add to review') }}</template>
+ <template v-else>{{ __('Start a review') }}</template>
+ </button>
+ <button
+ :disabled="isDisabled"
+ type="button"
+ class="btn qa-comment-now"
+ @click="handleUpdate()"
+ >
+ {{ __('Add comment now') }}
+ </button>
+ <button
+ class="btn btn-cancel note-edit-cancel js-close-discussion-note-form"
+ type="button"
+ @click="cancelHandler()"
+ >
+ {{ __('Cancel') }}
+ </button>
+ </div>
+ </template>
+ <template v-else>
+ <button
+ :disabled="isDisabled"
+ type="button"
+ class="js-vue-issue-save btn btn-success js-comment-button qa-reply-comment-button"
+ @click="handleUpdate()"
+ >
+ {{ saveButtonTitle }}
+ </button>
+ <button
+ v-if="discussion.resolvable"
+ class="btn btn-nr btn-default append-right-10 js-comment-resolve-button"
+ @click.prevent="handleUpdate(true)"
+ >
+ {{ resolveButtonTitle }}
+ </button>
+ <button
+ class="btn btn-cancel note-edit-cancel js-close-discussion-note-form"
+ type="button"
+ @click="cancelHandler()"
+ >
+ Cancel
+ </button>
+ </template>
</div>
</form>
</div>
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 7b39901024d..fbf82fab9e9 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -69,7 +69,7 @@ export default {
type="button"
@click="handleToggle"
>
- <i :class="toggleChevronClass" class="fa" aria-hidden="true"> </i>
+ <i :class="toggleChevronClass" class="fa" aria-hidden="true"></i>
{{ __('Toggle discussion') }}
</button>
</div>
@@ -81,35 +81,31 @@ export default {
:data-user-id="author.id"
:data-username="author.username"
>
- <span class="note-header-author-name">{{ author.name }}</span>
+ <slot name="note-header-info"></slot>
+ <span class="note-header-author-name bold">{{ author.name }}</span>
<span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
- <span class="note-headline-light"> @{{ author.username }} </span>
+ <span class="note-headline-light">@{{ author.username }}</span>
</a>
- <span v-else> {{ __('A deleted user') }} </span>
- <span class="note-headline-light">
- <span class="note-headline-meta">
- <span class="system-note-message"> <slot></slot> </span>
- <template v-if="createdAt">
- <span class="system-note-separator">
- <template v-if="actionText">
- {{ actionText }}
- </template>
- </span>
- <a
- :href="noteTimestampLink"
- class="note-timestamp system-note-separator"
- @click="updateTargetNoteHash"
- >
- <time-ago-tooltip :time="createdAt" tooltip-placement="bottom" />
- </a>
- </template>
- <i
- class="fa fa-spinner fa-spin editing-spinner"
- aria-label="Comment is being updated"
- aria-hidden="true"
+ <span v-else>{{ __('A deleted user') }}</span>
+ <span class="note-headline-light note-headline-meta">
+ <span class="system-note-message"> <slot></slot> </span>
+ <template v-if="createdAt">
+ <span class="system-note-separator">
+ <template v-if="actionText">{{ actionText }}</template>
+ </span>
+ <a
+ :href="noteTimestampLink"
+ class="note-timestamp system-note-separator"
+ @click="updateTargetNoteHash"
>
- </i>
- </span>
+ <time-ago-tooltip :time="createdAt" tooltip-placement="bottom" />
+ </a>
+ </template>
+ <i
+ class="fa fa-spinner fa-spin editing-spinner"
+ aria-label="Comment is being updated"
+ aria-hidden="true"
+ ></i>
</span>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 3894dc8c677..eb6a4a67fff 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -4,55 +4,42 @@ import { mapActions, mapGetters } from 'vuex';
import { GlTooltipDirective } from '@gitlab/ui';
import { truncateSha } from '~/lib/utils/text_utility';
import { s__, __, sprintf } from '~/locale';
-import systemNote from '~/vue_shared/components/notes/system_note.vue';
+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 { SYSTEM_NOTE } from '../constants';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
-import noteableNote from './noteable_note.vue';
import noteHeader from './note_header.vue';
-import resolveDiscussionButton from './discussion_resolve_button.vue';
-import toggleRepliesWidget from './toggle_replies_widget.vue';
import noteSignedOutWidget from './note_signed_out_widget.vue';
import noteEditedText from './note_edited_text.vue';
import noteForm from './note_form.vue';
import diffWithNote from './diff_with_note.vue';
-import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
-import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
-import autosave from '../mixins/autosave';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
import discussionNavigation from '../mixins/discussion_navigation';
-import ReplyPlaceholder from './discussion_reply_placeholder.vue';
-import ResolveWithIssueButton from './discussion_resolve_with_issue_button.vue';
-import jumpToNextDiscussionButton from './discussion_jump_to_next_button.vue';
import eventHub from '../event_hub';
+import DiscussionNotes from './discussion_notes.vue';
+import DiscussionActions from './discussion_actions.vue';
export default {
name: 'NoteableDiscussion',
components: {
icon,
- noteableNote,
userAvatarLink,
noteHeader,
noteSignedOutWidget,
noteEditedText,
noteForm,
- resolveDiscussionButton,
- jumpToNextDiscussionButton,
- toggleRepliesWidget,
- ReplyPlaceholder,
- placeholderNote,
- placeholderSystemNote,
- ResolveWithIssueButton,
- systemNote,
+ DraftNote: () => import('ee_component/batch_comments/components/draft_note.vue'),
TimelineEntryItem,
+ DiscussionNotes,
+ DiscussionActions,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [autosave, noteable, resolvable, discussionNavigation],
+ mixins: [noteable, resolvable, discussionNavigation, diffLineNoteFormMixin],
props: {
discussion: {
type: Object,
@@ -85,42 +72,38 @@ export default {
},
},
data() {
- const { diff_discussion: isDiffDiscussion, resolved } = this.discussion;
-
return {
isReplying: false,
isResolving: false,
resolveAsThread: true,
- isRepliesCollapsed: Boolean(!isDiffDiscussion && resolved),
};
},
computed: {
...mapGetters([
'convertedDisscussionIds',
'getNoteableData',
+ 'userCanReply',
'nextUnresolvedDiscussionId',
'unresolvedDiscussionsCount',
'hasUnresolvedDiscussions',
'showJumpToNextDiscussion',
+ 'getUserData',
]),
+ currentUser() {
+ return this.getUserData;
+ },
author() {
- return this.initialDiscussion.author;
+ return this.firstNote.author;
},
- canReply() {
- return this.getNoteableData.current_user.can_create_note;
+ autosaveKey() {
+ return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id);
},
newNotePath() {
return this.getNoteableData.create_note_path;
},
- hasReplies() {
- return this.discussion.notes.length > 1;
- },
- initialDiscussion() {
+ firstNote() {
return this.discussion.notes.slice(0, 1)[0];
},
- replies() {
- return this.discussion.notes.slice(1);
- },
lastUpdatedBy() {
const { notes } = this.discussion;
@@ -173,11 +156,11 @@ export default {
return '';
},
- shouldShowDiscussions() {
- const { expanded, resolved } = this.discussion;
- const isResolvedNonDiffDiscussion = !this.discussion.diff_discussion && resolved;
-
- return expanded || this.alwaysExpanded || isResolvedNonDiffDiscussion;
+ isExpanded() {
+ return this.discussion.expanded || this.alwaysExpanded;
+ },
+ shouldHideDiscussionBody() {
+ return this.shouldRenderDiffs && !this.isExpanded;
},
actionText() {
const linkStart = `<a href="${_.escape(this.discussion.discussion_path)}">`;
@@ -226,30 +209,8 @@ export default {
return null;
},
- commit() {
- if (!this.discussion.for_commit) {
- return null;
- }
-
- return {
- id: this.discussion.commit_id,
- url: this.discussion.discussion_path,
- };
- },
resolveWithIssuePath() {
- return !this.discussionResolved && this.discussion.resolve_with_issue_path;
- },
- },
- watch: {
- isReplying() {
- if (this.isReplying) {
- this.$nextTick(() => {
- // Pass an extra key to separate reply and note edit forms
- this.initAutoSave({ ...this.initialDiscussion, ...this.discussion }, ['Reply']);
- });
- } else {
- this.disposeAutoSave();
- }
+ return !this.discussionResolved ? this.discussion.resolve_with_issue_path : '';
},
},
created() {
@@ -268,30 +229,9 @@ export default {
'removeConvertedDiscussion',
]),
truncateSha,
- componentName(note) {
- if (note.isPlaceholderNote) {
- if (note.placeholderType === SYSTEM_NOTE) {
- return placeholderSystemNote;
- }
-
- return placeholderNote;
- }
-
- if (note.system) {
- return systemNote;
- }
-
- return noteableNote;
- },
- componentData(note) {
- return note.isPlaceholderNote ? note.notes[0] : note;
- },
toggleDiscussionHandler() {
this.toggleDiscussion({ discussionId: this.discussion.id });
},
- toggleReplies() {
- this.isRepliesCollapsed = !this.isRepliesCollapsed;
- },
showReplyForm() {
this.isReplying = true;
},
@@ -310,7 +250,7 @@ export default {
}
this.isReplying = false;
- this.resetAutoSave();
+ clearDraft(this.autosaveKey);
},
saveReply(noteText, form, callback) {
const postData = {
@@ -336,7 +276,7 @@ export default {
this.isReplying = false;
this.saveNote(replyData)
.then(() => {
- this.resetAutoSave();
+ clearDraft(this.autosaveKey);
callback();
})
.catch(err => {
@@ -388,8 +328,8 @@ Please check your network connection and try again.`;
<div class="timeline-content">
<note-header
:author="author"
- :created-at="initialDiscussion.created_at"
- :note-id="initialDiscussion.id"
+ :created-at="firstNote.created_at"
+ :note-id="firstNote.id"
:include-toggle="true"
:expanded="discussion.expanded"
@toggleHandler="toggleDiscussionHandler"
@@ -412,110 +352,79 @@ Please check your network connection and try again.`;
/>
</div>
</div>
- <div v-if="shouldShowDiscussions" class="discussion-body">
+ <div v-if="!shouldHideDiscussionBody" class="discussion-body">
<component
:is="wrapperComponent"
v-bind="wrapperComponentProps"
class="card discussion-wrapper"
>
- <div class="discussion-notes">
- <ul class="notes">
- <template v-if="shouldGroupReplies">
- <component
- :is="componentName(initialDiscussion)"
- :note="componentData(initialDiscussion)"
- :line="line"
- :commit="commit"
- :help-page-path="helpPagePath"
- :show-reply-button="canReply"
- @handleDeleteNote="deleteNoteHandler"
- @startReplying="showReplyForm"
- >
- <note-edited-text
- v-if="discussion.resolved"
- slot="discussion-resolved-text"
- :edited-at="discussion.resolved_at"
- :edited-by="discussion.resolved_by"
- :action-text="resolvedText"
- class-name="discussion-headline-light js-discussion-headline discussion-resolved-text"
- />
- <slot slot="avatar-badge" name="avatar-badge"></slot>
- </component>
- <toggle-replies-widget
- v-if="hasReplies"
- :collapsed="isRepliesCollapsed"
- :replies="replies"
- @toggle="toggleReplies"
+ <discussion-notes
+ :discussion="discussion"
+ :diff-line="diffLine"
+ :help-page-path="helpPagePath"
+ :is-expanded="isExpanded"
+ :line="line"
+ :should-group-replies="shouldGroupReplies"
+ @startReplying="showReplyForm"
+ @toggleDiscussion="toggleDiscussionHandler"
+ @deleteNote="deleteNoteHandler"
+ >
+ <slot slot="avatar-badge" name="avatar-badge"></slot>
+ <template #footer="{ showReplies }">
+ <draft-note
+ v-if="showDraft(discussion.reply_id)"
+ :key="`draft_${discussion.id}`"
+ :draft="draftForDiscussion(discussion.reply_id)"
+ />
+ <div
+ v-else-if="showReplies"
+ :class="{ 'is-replying': isReplying }"
+ class="discussion-reply-holder"
+ >
+ <user-avatar-link
+ v-if="!isReplying && currentUser"
+ :link-href="currentUser.path"
+ :img-src="currentUser.avatar_url"
+ :img-alt="currentUser.name"
+ :img-size="40"
+ class="d-none d-sm-block"
+ />
+ <discussion-actions
+ v-if="!isReplying && userCanReply"
+ :discussion="discussion"
+ :is-resolving="isResolving"
+ :resolve-button-title="resolveButtonTitle"
+ :resolve-with-issue-path="resolveWithIssuePath"
+ :should-show-jump-to-next-discussion="shouldShowJumpToNextDiscussion"
+ @showReplyForm="showReplyForm"
+ @resolve="resolveHandler"
+ @jumpToNextDiscussion="jumpToNextDiscussion"
/>
- <template v-if="!isRepliesCollapsed">
- <component
- :is="componentName(note)"
- v-for="note in replies"
- :key="note.id"
- :note="componentData(note)"
- :help-page-path="helpPagePath"
- :line="line"
- @handleDeleteNote="deleteNoteHandler"
+ <div v-if="isReplying" class="avatar-note-form-holder">
+ <user-avatar-link
+ v-if="currentUser"
+ :link-href="currentUser.path"
+ :img-src="currentUser.avatar_url"
+ :img-alt="currentUser.name"
+ :img-size="40"
+ class="d-none d-sm-block"
/>
- </template>
- </template>
- <template v-else>
- <component
- :is="componentName(note)"
- v-for="(note, index) in discussion.notes"
- :key="note.id"
- :note="componentData(note)"
- :help-page-path="helpPagePath"
- :line="diffLine"
- @handleDeleteNote="deleteNoteHandler"
- >
- <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot>
- </component>
- </template>
- </ul>
- <div
- v-if="!isRepliesCollapsed || !hasReplies"
- :class="{ 'is-replying': isReplying }"
- class="discussion-reply-holder"
- >
- <template v-if="!isReplying && canReply">
- <div class="discussion-with-resolve-btn">
- <reply-placeholder class="qa-discussion-reply" @onClick="showReplyForm" />
- <resolve-discussion-button
- v-if="discussion.resolvable"
- :is-resolving="isResolving"
- :button-title="resolveButtonTitle"
- @onClick="resolveHandler"
+ <note-form
+ ref="noteForm"
+ :discussion="discussion"
+ :is-editing="false"
+ :line="diffLine"
+ save-button-title="Comment"
+ :autosave-key="autosaveKey"
+ @handleFormUpdateAddToReview="addReplyToReview"
+ @handleFormUpdate="saveReply"
+ @cancelForm="cancelReplyForm"
/>
- <div
- v-if="discussion.resolvable"
- class="btn-group discussion-actions ml-sm-2"
- role="group"
- >
- <resolve-with-issue-button
- v-if="resolveWithIssuePath"
- :url="resolveWithIssuePath"
- />
- <jump-to-next-discussion-button
- v-if="shouldShowJumpToNextDiscussion"
- @onClick="jumpToNextDiscussion"
- />
- </div>
</div>
- </template>
- <note-form
- v-if="isReplying"
- ref="noteForm"
- :discussion="discussion"
- :is-editing="false"
- :line="diffLine"
- save-button-title="Comment"
- @handleFormUpdate="saveReply"
- @cancelForm="cancelReplyForm"
- />
- <note-signed-out-widget v-if="!canReply" />
- </div>
- </div>
+ <note-signed-out-widget v-if="!userCanReply" />
+ </div>
+ </template>
+ </discussion-notes>
</component>
</div>
</div>
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 04e74a43acc..aa80e25a3e0 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -4,12 +4,13 @@ import { mapGetters, mapActions } from 'vuex';
import { escape } from 'underscore';
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';
import noteHeader from './note_header.vue';
import noteActions from './note_actions.vue';
-import noteBody from './note_body.vue';
+import NoteBody from './note_body.vue';
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
@@ -20,10 +21,10 @@ export default {
userAvatarLink,
noteHeader,
noteActions,
- noteBody,
+ NoteBody,
TimelineEntryItem,
},
- mixins: [noteable, resolvable],
+ mixins: [noteable, resolvable, draftMixin],
props: {
note: {
type: Object,
@@ -73,11 +74,8 @@ export default {
'is-editable': this.note.current_user.can_edit,
};
},
- canResolve() {
- return this.note.resolvable && !!this.getUserData.id;
- },
canReportAsAbuse() {
- return !!this.note.report_abuse_path && this.author.id !== this.getUserData.id;
+ return Boolean(this.note.report_abuse_path) && this.author.id !== this.getUserData.id;
},
noteAnchorId() {
return `note_${this.note.id}`;
@@ -96,7 +94,7 @@ export default {
return '';
}
- // We need to do this to ensure we have the currect sentence order
+ // We need to do this to ensure we have the correct sentence order
// when translating this as the sentence order may change from one
// language to the next. See:
// https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/24427#note_133713771
@@ -156,12 +154,16 @@ export default {
this.$refs.noteBody.resetAutoSave();
this.$emit('updateSuccess');
},
- formUpdateHandler(noteText, parentElement, callback) {
+ formUpdateHandler(noteText, parentElement, callback, resolveDiscussion) {
this.$emit('handleUpdateNote', {
note: this.note,
noteText,
+ resolveDiscussion,
callback: () => this.updateSuccess(),
});
+
+ if (this.isDraft) return;
+
const data = {
endpoint: this.note.path,
note: {
@@ -207,7 +209,10 @@ export default {
// we need to do this to prevent noteForm inconsistent content warning
// this is something we intentionally do so we need to recover the content
this.note.note = noteText;
- this.$refs.noteBody.note.note = noteText;
+ const { noteBody } = this.$refs;
+ if (noteBody) {
+ noteBody.note.note = noteText;
+ }
},
},
};
@@ -219,7 +224,7 @@ export default {
:class="classNameBindings"
:data-award-url="note.toggle_award_path"
:data-note-id="note.id"
- class="note note-wrapper"
+ class="note note-wrapper qa-noteable-note-item"
>
<div v-once class="timeline-icon">
<user-avatar-link
@@ -234,6 +239,7 @@ export default {
<div class="timeline-content">
<div class="note-header">
<note-header v-once :author="author" :created-at="note.created_at" :note-id="note.id">
+ <slot slot="note-header-info" name="note-header-info"></slot>
<span v-if="commit" v-html="actionText"></span>
<span v-else class="d-none d-sm-inline">&middot;</span>
</note-header>
@@ -247,12 +253,15 @@ export default {
:can-award-emoji="note.current_user.can_award_emoji"
:can-delete="note.current_user.can_edit"
:can-report-as-abuse="canReportAsAbuse"
- :can-resolve="note.current_user.can_resolve"
+ :can-resolve="canResolve"
:report-abuse-path="note.report_abuse_path"
- :resolvable="note.resolvable"
- :is-resolved="note.resolved"
+ :resolvable="note.resolvable || note.isDraft"
+ :is-resolved="note.resolved || note.resolve_discussion"
:is-resolving="isResolving"
:resolved-by="note.resolved_by"
+ :is-draft="note.isDraft"
+ :resolve-discussion="note.isDraft && note.resolve_discussion"
+ :discussion-id="discussionId"
@handleEdit="editHandler"
@handleDelete="deleteHandler"
@handleResolve="resolveHandler"
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 8d3f6d902f8..4d00e957973 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -6,6 +6,7 @@ import * as constants from '../constants';
import eventHub from '../event_hub';
import noteableNote from './noteable_note.vue';
import noteableDiscussion from './noteable_discussion.vue';
+import discussionFilterNote from './discussion_filter_note.vue';
import systemNote from '../../vue_shared/components/notes/system_note.vue';
import commentForm from './comment_form.vue';
import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
@@ -24,6 +25,7 @@ export default {
placeholderNote,
placeholderSystemNote,
skeletonLoadingContainer,
+ discussionFilterNote,
},
props: {
noteableData: {
@@ -65,6 +67,7 @@ export default {
'isLoading',
'commentsDisabled',
'getNoteableData',
+ 'userCanReply',
]),
noteableType() {
return this.noteableData.noteableType;
@@ -81,7 +84,7 @@ export default {
return this.discussions;
},
canReply() {
- return this.getNoteableData.current_user.can_create_note && !this.commentsDisabled;
+ return this.userCanReply && !this.commentsDisabled;
},
},
watch: {
@@ -124,6 +127,9 @@ export default {
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
});
},
+ beforeDestroy() {
+ this.stopPolling();
+ },
methods: {
...mapActions([
'setLoadingState',
@@ -141,6 +147,7 @@ export default {
'expandDiscussion',
'startTaskList',
'convertToDiscussion',
+ 'stopPolling',
]),
fetchNotes() {
if (this.isFetching) return null;
@@ -235,6 +242,7 @@ export default {
:help-page-path="helpPagePath"
/>
</template>
+ <discussion-filter-note v-show="commentsDisabled" />
</ul>
<comment-form v-if="!commentsDisabled" :noteable-type="noteableType" />
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
index 78d365fe94b..bdfb6b8f105 100644
--- a/app/assets/javascripts/notes/constants.js
+++ b/app/assets/javascripts/notes/constants.js
@@ -7,6 +7,7 @@ export const COMMENT = 'comment';
export const OPENED = 'opened';
export const REOPENED = 'reopened';
export const CLOSED = 'closed';
+export const MERGED = 'merged';
export const EMOJI_THUMBSUP = 'thumbsup';
export const EMOJI_THUMBSDOWN = 'thumbsdown';
export const ISSUE_NOTEABLE_TYPE = 'issue';
@@ -24,3 +25,9 @@ export const NOTEABLE_TYPE_MAPPING = {
MergeRequest: MERGE_REQUEST_NOTEABLE_TYPE,
Epic: EPIC_NOTEABLE_TYPE,
};
+
+export const DISCUSSION_FILTER_TYPES = {
+ ALL: 'all',
+ COMMENTS: 'comments',
+ HISTORY: 'history',
+};
diff --git a/app/assets/javascripts/notes/discussion_filters.js b/app/assets/javascripts/notes/discussion_filters.js
index 5c5f38a3fb0..cdf9a46c5aa 100644
--- a/app/assets/javascripts/notes/discussion_filters.js
+++ b/app/assets/javascripts/notes/discussion_filters.js
@@ -6,12 +6,16 @@ export default store => {
if (discussionFilterEl) {
const { defaultFilter, notesFilters } = discussionFilterEl.dataset;
- const selectedValue = defaultFilter ? parseInt(defaultFilter, 10) : null;
const filterValues = notesFilters ? JSON.parse(notesFilters) : {};
const filters = Object.keys(filterValues).map(entry => ({
title: entry,
value: filterValues[entry],
}));
+ const props = { filters };
+
+ if (defaultFilter) {
+ props.selectedValue = parseInt(defaultFilter, 10);
+ }
return new Vue({
el: discussionFilterEl,
@@ -21,12 +25,7 @@ export default store => {
},
store,
render(createElement) {
- return createElement('discussion-filter', {
- props: {
- filters,
- selectedValue,
- },
- });
+ return createElement('discussion-filter', { props });
},
});
}
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index 4883266dae5..57dd1c5cab2 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -1,4 +1,6 @@
import Vue from 'vue';
+import { isEE } from '~/lib/utils/common_utils';
+import initNoteStats from 'ee_else_ce/event_tracking/notes';
import notesApp from './components/notes_app.vue';
import initDiscussionFilters from './discussion_filters';
import createStore from './stores';
@@ -6,9 +8,8 @@ import createStore from './stores';
document.addEventListener('DOMContentLoaded', () => {
const store = createStore();
- initDiscussionFilters(store);
-
- return new Vue({
+ // eslint-disable-next-line no-new
+ new Vue({
el: '#js-vue-notes',
components: {
notesApp,
@@ -39,6 +40,11 @@ document.addEventListener('DOMContentLoaded', () => {
notesData: JSON.parse(notesDataset.notesData),
};
},
+ mounted() {
+ if (isEE) {
+ initNoteStats();
+ }
+ },
render(createElement) {
return createElement('notes-app', {
props: {
@@ -49,4 +55,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
},
});
+
+ initDiscussionFilters(store);
});
diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js
index 4f45f912479..b161773f5f1 100644
--- a/app/assets/javascripts/notes/mixins/autosave.js
+++ b/app/assets/javascripts/notes/mixins/autosave.js
@@ -1,12 +1,13 @@
import $ from 'jquery';
import Autosave from '../../autosave';
import { capitalizeFirstCharacter } from '../../lib/utils/text_utility';
+import { s__ } from '~/locale';
export default {
methods: {
initAutoSave(noteable, extraKeys = []) {
let keys = [
- 'Note',
+ s__('Autosave|Note'),
capitalizeFirstCharacter(noteable.noteable_type || noteable.noteableType),
noteable.id,
];
diff --git a/app/assets/javascripts/notes/mixins/diff_line_note_form.js b/app/assets/javascripts/notes/mixins/diff_line_note_form.js
new file mode 100644
index 00000000000..188556e8921
--- /dev/null
+++ b/app/assets/javascripts/notes/mixins/diff_line_note_form.js
@@ -0,0 +1,10 @@
+export default {
+ computed: {
+ draftForDiscussion: () => () => ({}),
+ },
+ methods: {
+ showDraft: () => false,
+ addReplyToReview: () => {},
+ addToReview: () => {},
+ },
+};
diff --git a/app/assets/javascripts/notes/mixins/draft.js b/app/assets/javascripts/notes/mixins/draft.js
new file mode 100644
index 00000000000..1370f3978df
--- /dev/null
+++ b/app/assets/javascripts/notes/mixins/draft.js
@@ -0,0 +1,8 @@
+export default {
+ computed: {
+ isDraft: () => false,
+ canResolve() {
+ return this.note.current_user.can_resolve;
+ },
+ },
+};
diff --git a/app/assets/javascripts/notes/mixins/get_discussion.js b/app/assets/javascripts/notes/mixins/get_discussion.js
new file mode 100644
index 00000000000..b5d820fe083
--- /dev/null
+++ b/app/assets/javascripts/notes/mixins/get_discussion.js
@@ -0,0 +1,7 @@
+export default {
+ computed: {
+ discussion() {
+ return {};
+ },
+ },
+};
diff --git a/app/assets/javascripts/notes/mixins/issuable_state.js b/app/assets/javascripts/notes/mixins/issuable_state.js
index 97f3ea0d5de..d97d9f6850a 100644
--- a/app/assets/javascripts/notes/mixins/issuable_state.js
+++ b/app/assets/javascripts/notes/mixins/issuable_state.js
@@ -1,11 +1,22 @@
+import { mapGetters } from 'vuex';
+
export default {
+ computed: {
+ ...mapGetters(['getNoteableDataByProp']),
+ lockedIssueDocsPath() {
+ return this.getNoteableDataByProp('locked_discussion_docs_path');
+ },
+ confidentialIssueDocsPath() {
+ return this.getNoteableDataByProp('confidential_issues_docs_path');
+ },
+ },
methods: {
isConfidential(issue) {
- return !!issue.confidential;
+ return Boolean(issue.confidential);
},
isLocked(issue) {
- return !!issue.discussion_locked;
+ return Boolean(issue.discussion_locked);
},
hasWarning(issue) {
diff --git a/app/assets/javascripts/notes/mixins/note_form.js b/app/assets/javascripts/notes/mixins/note_form.js
new file mode 100644
index 00000000000..b74879f2256
--- /dev/null
+++ b/app/assets/javascripts/notes/mixins/note_form.js
@@ -0,0 +1,24 @@
+export default {
+ data() {
+ return {
+ showBatchCommentsActions: false,
+ };
+ },
+ methods: {
+ handleKeySubmit() {
+ this.handleUpdate();
+ },
+ handleUpdate(shouldResolve) {
+ const beforeSubmitDiscussionState = this.discussionResolved;
+ this.isSubmitting = true;
+
+ this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => {
+ this.isSubmitting = false;
+
+ if (this.shouldToggleResolved(shouldResolve, beforeSubmitDiscussionState)) {
+ this.resolveHandler(beforeSubmitDiscussionState);
+ }
+ });
+ },
+ },
+};
diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js
index 8edf3d088bb..2329727bca2 100644
--- a/app/assets/javascripts/notes/mixins/resolvable.js
+++ b/app/assets/javascripts/notes/mixins/resolvable.js
@@ -31,6 +31,10 @@ export default {
},
methods: {
resolveHandler(resolvedState = false) {
+ if (this.note && this.note.isDraft) {
+ return this.$emit('toggleResolveStatus');
+ }
+
this.isResolving = true;
const isResolved = this.discussionResolved || resolvedState;
const discussion = this.resolveAsThread;
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 1a0dba69a7c..63658d49a05 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -142,6 +142,23 @@ export const createNewNote = ({ commit, dispatch }, { endpoint, data }) =>
export const removePlaceholderNotes = ({ commit }) => commit(types.REMOVE_PLACEHOLDER_NOTES);
+export const resolveDiscussion = ({ state, dispatch, getters }, { discussionId }) => {
+ const discussion = utils.findNoteObjectById(state.discussions, discussionId);
+ const isResolved = getters.isDiscussionResolved(discussionId);
+
+ if (!discussion) {
+ return Promise.reject();
+ } else if (isResolved) {
+ return Promise.resolve();
+ }
+
+ return dispatch('toggleResolveNote', {
+ endpoint: discussion.resolve_path,
+ isResolved,
+ discussion: true,
+ });
+};
+
export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved, discussion }) =>
service
.toggleResolveNote(endpoint, isResolved)
@@ -251,11 +268,20 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
const { errors } = res;
const commandsChanges = res.commands_changes;
- if (hasQuickActions && errors && Object.keys(errors).length) {
- eTagPoll.makeRequest();
+ if (errors && Object.keys(errors).length) {
+ /*
+ The following reply means that quick actions have been successfully applied:
+
+ {"commands_changes":{},"valid":false,"errors":{"commands_only":["Commands applied"]}}
+ */
+ if (hasQuickActions) {
+ eTagPoll.makeRequest();
- $('.js-gfm-input').trigger('clear-commands-cache.atwho');
- Flash('Commands applied', 'notice', noteData.flashContainer);
+ $('.js-gfm-input').trigger('clear-commands-cache.atwho');
+ Flash(__('Commands applied'), 'notice', noteData.flashContainer);
+ } else {
+ throw new Error(__('Failed to save comment!'));
+ }
}
if (commandsChanges) {
@@ -269,7 +295,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
})
.catch(() => {
Flash(
- 'Something went wrong while adding your award. Please try again.',
+ __('Something went wrong while adding your award. Please try again.'),
'alert',
noteData.flashContainer,
);
@@ -311,7 +337,7 @@ export const poll = ({ commit, state, getters, dispatch }) => {
data: state,
successCallback: resp =>
resp.json().then(data => pollSuccessCallBack(data, commit, state, getters, dispatch)),
- errorCallback: () => Flash('Something went wrong while fetching latest comments.'),
+ errorCallback: () => Flash(__('Something went wrong while fetching latest comments.')),
});
if (!Visibility.hidden()) {
@@ -347,7 +373,7 @@ export const fetchData = ({ commit, state, getters }) => {
.poll(requestData)
.then(resp => resp.json)
.then(data => pollSuccessCallBack(data, commit, state, getters))
- .catch(() => Flash('Something went wrong while fetching latest comments.'));
+ .catch(() => Flash(__('Something went wrong while fetching latest comments.')));
};
export const toggleAward = ({ commit, getters }, { awardName, noteId }) => {
@@ -420,15 +446,13 @@ export const updateResolvableDiscussonsCounts = ({ commit }) =>
commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS);
export const submitSuggestion = (
- { commit },
- { discussionId, noteId, suggestionId, flashContainer, callback },
-) => {
+ { commit, dispatch },
+ { discussionId, noteId, suggestionId, flashContainer },
+) =>
service
.applySuggestion(suggestionId)
- .then(() => {
- commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId });
- callback();
- })
+ .then(() => commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId }))
+ .then(() => dispatch('resolveDiscussion', { discussionId }).catch(() => {}))
.catch(err => {
const defaultMessage = __(
'Something went wrong while applying the suggestion. Please try again.',
@@ -436,9 +460,7 @@ export const submitSuggestion = (
const flashMessage = err.response.data ? `${err.response.data.message}.` : defaultMessage;
Flash(__(flashMessage), 'alert', flashContainer);
- callback();
});
-};
export const convertToDiscussion = ({ commit }, noteId) =>
commit(types.CONVERT_TO_DISCUSSION, noteId);
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index 5026c13dab5..d7982be3e4b 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -20,6 +20,8 @@ export const getNoteableData = state => state.noteableData;
export const getNoteableDataByProp = state => prop => state.noteableData[prop];
+export const userCanReply = state => Boolean(state.noteableData.current_user.can_create_note);
+
export const openState = state => state.noteableData.state;
export const getUserData = state => state.userData || {};
@@ -191,6 +193,9 @@ export const firstUnresolvedDiscussionId = (state, getters) => diffOrder => {
return getters.unresolvedDiscussionsIdsByDate[0];
};
+export const getDiscussion = state => discussionId =>
+ state.discussions.find(discussion => discussion.id === discussionId);
+
export const commentsDisabled = state => state.commentsDisabled;
// prevent babel-plugin-rewire from generating an invalid default during karma tests
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index ae6f8b7790a..fa44ef2d057 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -193,6 +193,10 @@ export default {
const noteObj = utils.findNoteObjectById(state.discussions, note.discussion_id);
if (noteObj.individual_note) {
+ if (note.type === constants.DISCUSSION_NOTE) {
+ noteObj.individual_note = false;
+ }
+
noteObj.notes.splice(0, 1, note);
} else {
const comment = utils.findNoteObjectById(noteObj.notes, note.id);
diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js
index 4b0feb0f94d..ed4cef4a917 100644
--- a/app/assets/javascripts/notes/stores/utils.js
+++ b/app/assets/javascripts/notes/stores/utils.js
@@ -1,12 +1,14 @@
import AjaxCache from '~/lib/utils/ajax_cache';
import { trimFirstCharOfLineContent } from '~/diffs/store/utils';
+import { sprintf, __ } from '~/locale';
-const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm;
+// factory function because global flag makes RegExp stateful
+const createQuickActionsRegex = () => /^\/\w+.*$/gm;
export const findNoteObjectById = (notes, id) => notes.filter(n => n.id === id)[0];
export const getQuickActionText = note => {
- let text = 'Applying command';
+ let text = __('Applying command');
const quickActions = AjaxCache.get(gl.GfmAutoComplete.dataSources.commands) || [];
const executedCommands = quickActions.filter(command => {
@@ -16,19 +18,19 @@ export const getQuickActionText = note => {
if (executedCommands && executedCommands.length) {
if (executedCommands.length > 1) {
- text = 'Applying multiple commands';
+ text = __('Applying multiple commands');
} else {
const commandDescription = executedCommands[0].description.toLowerCase();
- text = `Applying command to ${commandDescription}`;
+ text = sprintf(__('Applying command to %{commandDescription}', { commandDescription }));
}
}
return text;
};
-export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note);
+export const hasQuickActions = note => createQuickActionsRegex().test(note);
-export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim();
+export const stripQuickActions = note => note.replace(createQuickActionsRegex(), '').trim();
export const prepareDiffLines = diffLines =>
diffLines.map(line => ({ ...trimFirstCharOfLineContent(line) }));
diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js
index e7fa05faa8a..08545dcea46 100644
--- a/app/assets/javascripts/notifications_dropdown.js
+++ b/app/assets/javascripts/notifications_dropdown.js
@@ -1,9 +1,11 @@
import $ from 'jquery';
import Flash from './flash';
+import { __ } from '~/locale';
export default function notificationsDropdown() {
$(document).on('click', '.update-notification', function updateNotificationCallback(e) {
e.preventDefault();
+
if ($(this).is('.is-active') && $(this).data('notificationLevel') === 'custom') {
return;
}
@@ -26,7 +28,7 @@ export default function notificationsDropdown() {
.closest('.js-notification-dropdown')
.replaceWith(data.html);
} else {
- Flash('Failed to save new settings', 'alert');
+ Flash(__('Failed to save new settings'), 'alert');
}
});
}
diff --git a/app/assets/javascripts/operation_settings/components/external_dashboard.vue b/app/assets/javascripts/operation_settings/components/external_dashboard.vue
new file mode 100644
index 00000000000..ed518611d0b
--- /dev/null
+++ b/app/assets/javascripts/operation_settings/components/external_dashboard.vue
@@ -0,0 +1,67 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { GlButton, GlFormGroup, GlFormInput, GlLink } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlLink,
+ },
+ computed: {
+ ...mapState([
+ 'externalDashboardHelpPagePath',
+ 'externalDashboardUrl',
+ 'operationsSettingsEndpoint',
+ ]),
+ userDashboardUrl: {
+ get() {
+ return this.externalDashboardUrl;
+ },
+ set(url) {
+ this.setExternalDashboardUrl(url);
+ },
+ },
+ },
+ methods: {
+ ...mapActions(['setExternalDashboardUrl', 'updateExternalDashboardUrl']),
+ },
+};
+</script>
+
+<template>
+ <section class="settings no-animate">
+ <div class="settings-header">
+ <h4 class="js-section-header">
+ {{ s__('ExternalMetrics|External Dashboard') }}
+ </h4>
+ <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button>
+ <p class="js-section-sub-header">
+ {{
+ s__(
+ 'ExternalMetrics|Add a button to the metrics dashboard linking directly to your existing external dashboards.',
+ )
+ }}
+ <gl-link :href="externalDashboardHelpPagePath">{{ __('Learn more') }}</gl-link>
+ </p>
+ </div>
+ <div class="settings-content">
+ <form>
+ <gl-form-group
+ :label="s__('ExternalMetrics|Full dashboard URL')"
+ :description="s__('ExternalMetrics|Enter the URL of the dashboard you want to link to')"
+ >
+ <gl-form-input
+ v-model="userDashboardUrl"
+ placeholder="https://my-org.gitlab.io/my-dashboards"
+ @keydown.enter.native.prevent="updateExternalDashboardUrl"
+ />
+ </gl-form-group>
+ <gl-button variant="success" @click="updateExternalDashboardUrl">
+ {{ __('Save Changes') }}
+ </gl-button>
+ </form>
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/operation_settings/index.js b/app/assets/javascripts/operation_settings/index.js
new file mode 100644
index 00000000000..6946578e6d2
--- /dev/null
+++ b/app/assets/javascripts/operation_settings/index.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import store from './store';
+import ExternalDashboardForm from './components/external_dashboard.vue';
+
+export default () => {
+ /**
+ * This check can be removed when we remove
+ * the :grafana_dashboard_link feature flag
+ */
+ if (!gon.features.grafanaDashboardLink) {
+ return null;
+ }
+
+ const el = document.querySelector('.js-operation-settings');
+
+ return new Vue({
+ el,
+ store: store(el.dataset),
+ render(createElement) {
+ return createElement(ExternalDashboardForm);
+ },
+ });
+};
diff --git a/app/assets/javascripts/operation_settings/store/actions.js b/app/assets/javascripts/operation_settings/store/actions.js
new file mode 100644
index 00000000000..ec05b0c76cf
--- /dev/null
+++ b/app/assets/javascripts/operation_settings/store/actions.js
@@ -0,0 +1,38 @@
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import createFlash from '~/flash';
+import { refreshCurrentPage } from '~/lib/utils/url_utility';
+import * as mutationTypes from './mutation_types';
+
+export const setExternalDashboardUrl = ({ commit }, url) =>
+ commit(mutationTypes.SET_EXTERNAL_DASHBOARD_URL, url);
+
+export const updateExternalDashboardUrl = ({ state, dispatch }) =>
+ axios
+ .patch(state.operationsSettingsEndpoint, {
+ project: {
+ metrics_setting_attributes: {
+ external_dashboard_url: state.externalDashboardUrl,
+ },
+ },
+ })
+ .then(() => dispatch('receiveExternalDashboardUpdateSuccess'))
+ .catch(error => dispatch('receiveExternalDashboardUpdateError', error));
+
+export const receiveExternalDashboardUpdateSuccess = () => {
+ /**
+ * The operations_controller currently handles successful requests
+ * by creating a flash banner messsage to notify the user.
+ */
+ refreshCurrentPage();
+};
+
+export const receiveExternalDashboardUpdateError = (_, error) => {
+ const { response } = error;
+ const message = response.data && response.data.message ? response.data.message : '';
+
+ createFlash(`${__('There was an error saving your changes.')} ${message}`, 'alert');
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/operation_settings/store/index.js b/app/assets/javascripts/operation_settings/store/index.js
new file mode 100644
index 00000000000..e96bb1e8aad
--- /dev/null
+++ b/app/assets/javascripts/operation_settings/store/index.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import createState from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+export const createStore = initialState =>
+ new Vuex.Store({
+ state: createState(initialState),
+ actions,
+ mutations,
+ });
+
+export default createStore;
diff --git a/app/assets/javascripts/operation_settings/store/mutation_types.js b/app/assets/javascripts/operation_settings/store/mutation_types.js
new file mode 100644
index 00000000000..237d2b6122f
--- /dev/null
+++ b/app/assets/javascripts/operation_settings/store/mutation_types.js
@@ -0,0 +1,3 @@
+/* eslint-disable import/prefer-default-export */
+
+export const SET_EXTERNAL_DASHBOARD_URL = 'SET_EXTERNAL_DASHBOARD_URL';
diff --git a/app/assets/javascripts/operation_settings/store/mutations.js b/app/assets/javascripts/operation_settings/store/mutations.js
new file mode 100644
index 00000000000..64bb33bb89f
--- /dev/null
+++ b/app/assets/javascripts/operation_settings/store/mutations.js
@@ -0,0 +1,7 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_EXTERNAL_DASHBOARD_URL](state, url) {
+ state.externalDashboardUrl = url;
+ },
+};
diff --git a/app/assets/javascripts/operation_settings/store/state.js b/app/assets/javascripts/operation_settings/store/state.js
new file mode 100644
index 00000000000..72167141c48
--- /dev/null
+++ b/app/assets/javascripts/operation_settings/store/state.js
@@ -0,0 +1,5 @@
+export default (initialState = {}) => ({
+ externalDashboardUrl: initialState.externalDashboardUrl || '',
+ operationsSettingsEndpoint: initialState.operationsSettingsEndpoint,
+ externalDashboardHelpPagePath: initialState.externalDashboardHelpPagePath,
+});
diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
index d5ded3f9a79..6e00e31b828 100644
--- a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
+++ b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
@@ -22,7 +22,7 @@ export default () => {
_.debounce(function onMessageInput() {
const message = $(this).val();
if (message === '') {
- $('.js-broadcast-message-preview').text('Your message here');
+ $('.js-broadcast-message-preview').text(__('Your message here'));
} else {
axios
.post(previewPath, {
diff --git a/app/assets/javascripts/pages/admin/clusters/destroy/index.js b/app/assets/javascripts/pages/admin/clusters/destroy/index.js
new file mode 100644
index 00000000000..8001d2dd1da
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/clusters/destroy/index.js
@@ -0,0 +1,5 @@
+import ClustersBundle from '~/clusters/clusters_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ClustersBundle(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/admin/clusters/edit/index.js b/app/assets/javascripts/pages/admin/clusters/edit/index.js
new file mode 100644
index 00000000000..8001d2dd1da
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/clusters/edit/index.js
@@ -0,0 +1,5 @@
+import ClustersBundle from '~/clusters/clusters_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ClustersBundle(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/admin/clusters/index.js b/app/assets/javascripts/pages/admin/clusters/index.js
new file mode 100644
index 00000000000..d0c9ae66c6a
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/clusters/index.js
@@ -0,0 +1,21 @@
+import PersistentUserCallout from '~/persistent_user_callout';
+import initGkeDropdowns from '~/projects/gke_cluster_dropdowns';
+
+function initGcpSignupCallout() {
+ const callout = document.querySelector('.gcp-signup-offer');
+ PersistentUserCallout.factory(callout);
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ const { page } = document.body.dataset;
+ const newClusterViews = [
+ 'admin:clusters:new',
+ 'admin:clusters:create_gcp',
+ 'admin:clusters:create_user',
+ ];
+
+ if (newClusterViews.indexOf(page) > -1) {
+ initGcpSignupCallout();
+ initGkeDropdowns();
+ }
+});
diff --git a/app/assets/javascripts/pages/admin/clusters/index/index.js b/app/assets/javascripts/pages/admin/clusters/index/index.js
new file mode 100644
index 00000000000..30d519d0e37
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/clusters/index/index.js
@@ -0,0 +1,6 @@
+import PersistentUserCallout from '~/persistent_user_callout';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const callout = document.querySelector('.gcp-signup-offer');
+ PersistentUserCallout.factory(callout);
+});
diff --git a/app/assets/javascripts/pages/admin/clusters/show/index.js b/app/assets/javascripts/pages/admin/clusters/show/index.js
new file mode 100644
index 00000000000..8001d2dd1da
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/clusters/show/index.js
@@ -0,0 +1,5 @@
+import ClustersBundle from '~/clusters/clusters_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ClustersBundle(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/admin/groups/edit/index.js b/app/assets/javascripts/pages/admin/groups/edit/index.js
index d3d125a1859..ad7276132b9 100644
--- a/app/assets/javascripts/pages/admin/groups/edit/index.js
+++ b/app/assets/javascripts/pages/admin/groups/edit/index.js
@@ -1,3 +1,3 @@
-import groupAvatar from '~/group_avatar';
+import initAvatarPicker from '~/avatar_picker';
-document.addEventListener('DOMContentLoaded', groupAvatar);
+document.addEventListener('DOMContentLoaded', initAvatarPicker);
diff --git a/app/assets/javascripts/pages/admin/groups/new/index.js b/app/assets/javascripts/pages/admin/groups/new/index.js
index 21f1ce222ac..6de740ee9ce 100644
--- a/app/assets/javascripts/pages/admin/groups/new/index.js
+++ b/app/assets/javascripts/pages/admin/groups/new/index.js
@@ -1,9 +1,9 @@
import BindInOut from '../../../../behaviors/bind_in_out';
import Group from '../../../../group';
-import groupAvatar from '../../../../group_avatar';
+import initAvatarPicker from '~/avatar_picker';
document.addEventListener('DOMContentLoaded', () => {
BindInOut.initAll();
new Group(); // eslint-disable-line no-new
- groupAvatar();
+ initAvatarPicker();
});
diff --git a/app/assets/javascripts/pages/dashboard/merge_requests/index.js b/app/assets/javascripts/pages/dashboard/merge_requests/index.js
index 260484726f3..ff758fcb4fe 100644
--- a/app/assets/javascripts/pages/dashboard/merge_requests/index.js
+++ b/app/assets/javascripts/pages/dashboard/merge_requests/index.js
@@ -1,10 +1,11 @@
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';
document.addEventListener('DOMContentLoaded', () => {
- IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests();
+ addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
diff --git a/app/assets/javascripts/pages/groups/clusters/index/index.js b/app/assets/javascripts/pages/groups/clusters/index/index.js
index 21efc4f6d00..30d519d0e37 100644
--- a/app/assets/javascripts/pages/groups/clusters/index/index.js
+++ b/app/assets/javascripts/pages/groups/clusters/index/index.js
@@ -2,6 +2,5 @@ import PersistentUserCallout from '~/persistent_user_callout';
document.addEventListener('DOMContentLoaded', () => {
const callout = document.querySelector('.gcp-signup-offer');
-
- if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new
+ PersistentUserCallout.factory(callout);
});
diff --git a/app/assets/javascripts/pages/groups/details/index.js b/app/assets/javascripts/pages/groups/details/index.js
new file mode 100644
index 00000000000..3bcaa0f0232
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/details/index.js
@@ -0,0 +1,5 @@
+import initGroupDetails from '../shared/group_details';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initGroupDetails('details');
+});
diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js
index 01ef445c901..d036ff07d89 100644
--- a/app/assets/javascripts/pages/groups/edit/index.js
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -1,4 +1,4 @@
-import groupAvatar from '~/group_avatar';
+import initAvatarPicker from '~/avatar_picker';
import TransferDropdown from '~/groups/transfer_dropdown';
import initConfirmDangerModal from '~/confirm_danger_modal';
import initSettingsPanels from '~/settings_panels';
@@ -9,7 +9,7 @@ import groupsSelect from '~/groups_select';
import projectSelect from '~/project_select';
document.addEventListener('DOMContentLoaded', () => {
- groupAvatar();
+ initAvatarPicker();
new TransferDropdown(); // eslint-disable-line no-new
initConfirmDangerModal();
initSettingsPanels();
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 c22a164cd4e..e4f4c3b574e 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 '~/members';
+import Members from 'ee_else_ce/members';
import UsersSelect from '~/users_select';
document.addEventListener('DOMContentLoaded', () => {
diff --git a/app/assets/javascripts/pages/groups/index.js b/app/assets/javascripts/pages/groups/index.js
index a63a0dbc6b1..451be6497de 100644
--- a/app/assets/javascripts/pages/groups/index.js
+++ b/app/assets/javascripts/pages/groups/index.js
@@ -3,8 +3,7 @@ import initGkeDropdowns from '~/projects/gke_cluster_dropdowns';
function initGcpSignupCallout() {
const callout = document.querySelector('.gcp-signup-offer');
-
- if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new
+ PersistentUserCallout.factory(callout);
}
document.addEventListener('DOMContentLoaded', () => {
diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js
index 21ec3f9f9ba..35d4b034654 100644
--- a/app/assets/javascripts/pages/groups/issues/index.js
+++ b/app/assets/javascripts/pages/groups/issues/index.js
@@ -1,7 +1,7 @@
import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import { FILTERED_SEARCH } from '~/pages/constants';
-import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
+import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
diff --git a/app/assets/javascripts/pages/groups/labels/edit/index.js b/app/assets/javascripts/pages/groups/labels/edit/index.js
index fa81ad914ba..83d6ac9fd14 100644
--- a/app/assets/javascripts/pages/groups/labels/edit/index.js
+++ b/app/assets/javascripts/pages/groups/labels/edit/index.js
@@ -1,3 +1,3 @@
-import Labels from '~/labels';
+import Labels from 'ee_else_ce/labels';
document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/groups/labels/new/index.js b/app/assets/javascripts/pages/groups/labels/new/index.js
index fa81ad914ba..83d6ac9fd14 100644
--- a/app/assets/javascripts/pages/groups/labels/new/index.js
+++ b/app/assets/javascripts/pages/groups/labels/new/index.js
@@ -1,3 +1,3 @@
-import Labels from '~/labels';
+import Labels from 'ee_else_ce/labels';
document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js
index 339ce67438a..12a26fd88fa 100644
--- a/app/assets/javascripts/pages/groups/merge_requests/index.js
+++ b/app/assets/javascripts/pages/groups/merge_requests/index.js
@@ -1,10 +1,11 @@
import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
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';
document.addEventListener('DOMContentLoaded', () => {
- IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests();
+ addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js
index b2f275dc5ea..57b53eb9e5d 100644
--- a/app/assets/javascripts/pages/groups/new/index.js
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -1,9 +1,9 @@
import BindInOut from '~/behaviors/bind_in_out';
import Group from '~/group';
-import groupAvatar from '~/group_avatar';
+import initAvatarPicker from '~/avatar_picker';
document.addEventListener('DOMContentLoaded', () => {
BindInOut.initAll();
new Group(); // eslint-disable-line no-new
- groupAvatar();
+ initAvatarPicker();
});
diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
index ae0a8c74964..8a5300c9266 100644
--- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
@@ -12,5 +12,6 @@ document.addEventListener('DOMContentLoaded', () => {
saveButton: variableListEl.querySelector('.js-ci-variables-save-button'),
errorBox: variableListEl.querySelector('.js-ci-variable-error-box'),
saveEndpoint: variableListEl.dataset.saveEndpoint,
+ maskableRegex: variableListEl.dataset.maskableRegex,
});
});
diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js
new file mode 100644
index 00000000000..01ef3f1db2b
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/shared/group_details.js
@@ -0,0 +1,31 @@
+/* eslint-disable no-new */
+
+import { getPagePath } from '~/lib/utils/common_utils';
+import { ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED } from '~/groups/constants';
+import NewGroupChild from '~/groups/new_group_child';
+import notificationsDropdown from '~/notifications_dropdown';
+import NotificationsForm from '~/notifications_form';
+import ProjectsList from '~/projects_list';
+import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
+import GroupTabs from './group_tabs';
+
+export default function initGroupDetails(actionName = 'show') {
+ const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
+ const loadableActions = [ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED];
+ const paths = window.location.pathname.split('/');
+ const subpath = paths[paths.length - 1];
+ let action = loadableActions.includes(subpath) ? subpath : getPagePath(1);
+ if (actionName && action === actionName) {
+ action = 'show'; // 'show' resets GroupTabs to default action through base class
+ }
+
+ new GroupTabs({ parentEl: '.groups-listing', action });
+ new ShortcutsNavigation();
+ new NotificationsForm();
+ notificationsDropdown();
+ new ProjectsList();
+
+ if (newGroupChildWrapper) {
+ new NewGroupChild(newGroupChildWrapper);
+ }
+}
diff --git a/app/assets/javascripts/pages/groups/show/group_tabs.js b/app/assets/javascripts/pages/groups/shared/group_tabs.js
index c6fe61d2bd9..c6fe61d2bd9 100644
--- a/app/assets/javascripts/pages/groups/show/group_tabs.js
+++ b/app/assets/javascripts/pages/groups/shared/group_tabs.js
diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js
index 3a45fd70d02..82ee5ead83d 100644
--- a/app/assets/javascripts/pages/groups/show/index.js
+++ b/app/assets/javascripts/pages/groups/show/index.js
@@ -1,28 +1,7 @@
-/* eslint-disable no-new */
-
-import { getPagePath } from '~/lib/utils/common_utils';
-import { ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED } from '~/groups/constants';
-import NewGroupChild from '~/groups/new_group_child';
-import notificationsDropdown from '~/notifications_dropdown';
-import NotificationsForm from '~/notifications_form';
-import ProjectsList from '~/projects_list';
-import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
-import GroupTabs from './group_tabs';
+import leaveByUrl from '~/namespaces/leave_by_url';
+import initGroupDetails from '../shared/group_details';
document.addEventListener('DOMContentLoaded', () => {
- const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
- const loadableActions = [ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED];
- const paths = window.location.pathname.split('/');
- const subpath = paths[paths.length - 1];
- const action = loadableActions.includes(subpath) ? subpath : getPagePath(1);
-
- new GroupTabs({ parentEl: '.groups-listing', action });
- new ShortcutsNavigation();
- new NotificationsForm();
- notificationsDropdown();
- new ProjectsList();
-
- if (newGroupChildWrapper) {
- new NewGroupChild(newGroupChildWrapper);
- }
+ leaveByUrl('group');
+ initGroupDetails();
});
diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
index a79ef07f1c5..c563514d36b 100644
--- a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
+++ b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
@@ -33,8 +33,7 @@ export default {
text() {
return sprintf(
s__(`Milestones|Promoting %{milestoneTitle} will make it available for all projects inside %{groupName}.
- Existing project milestones with the same title will be merged.
- This action cannot be reversed.`),
+ Existing project milestones with the same title will be merged.`),
{ milestoneTitle: this.milestoneTitle, groupName: this.groupName },
);
},
@@ -72,6 +71,9 @@ export default {
<template slot="title">
{{ title }}
</template>
- {{ text }}
+ <div>
+ <p>{{ text }}</p>
+ <p>{{ s__('Milestones|This action cannot be reversed.') }}</p>
+ </div>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/pages/profiles/keys/index.js b/app/assets/javascripts/pages/profiles/keys/index.js
index 1cd3ee1dfdb..d3dcd21f456 100644
--- a/app/assets/javascripts/pages/profiles/keys/index.js
+++ b/app/assets/javascripts/pages/profiles/keys/index.js
@@ -2,6 +2,8 @@ import AddSshKeyValidation from '~/profile/add_ssh_key_validation';
document.addEventListener('DOMContentLoaded', () => {
const input = document.querySelector('.js-add-ssh-key-validation-input');
+ if (!input) return;
+
const warning = document.querySelector('.js-add-ssh-key-validation-warning');
const originalSubmit = input.form.querySelector('.js-add-ssh-key-validation-original-submit');
const confirmSubmit = warning.querySelector('.js-add-ssh-key-validation-confirm-submit');
diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js
index 0dd0d5336fc..13cb0d6f74b 100644
--- a/app/assets/javascripts/pages/profiles/show/index.js
+++ b/app/assets/javascripts/pages/profiles/show/index.js
@@ -1,8 +1,9 @@
import $ from 'jquery';
import createFlash from '~/flash';
-import GfmAutoComplete from '~/gfm_auto_complete';
+import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
import emojiRegex from 'emoji-regex';
import EmojiMenu from './emoji_menu';
+import { __ } from '~/locale';
const defaultStatusEmoji = 'speech_balloon';
@@ -48,7 +49,7 @@ document.addEventListener('DOMContentLoaded', () => {
const EMOJI_REGEX = emojiRegex();
if (EMOJI_REGEX.test(userNameInput.value)) {
// set field to invalid so it gets detected by GlFieldErrors
- userNameInput.setCustomValidity('Invalid field');
+ userNameInput.setCustomValidity(__('Invalid field'));
} else {
userNameInput.setCustomValidity('');
}
@@ -81,5 +82,5 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
})
- .catch(() => createFlash('Failed to load emoji list.'));
+ .catch(() => createFlash(__('Failed to load emoji list.')));
});
diff --git a/app/assets/javascripts/pages/projects/clusters/index/index.js b/app/assets/javascripts/pages/projects/clusters/index/index.js
index 21efc4f6d00..30d519d0e37 100644
--- a/app/assets/javascripts/pages/projects/clusters/index/index.js
+++ b/app/assets/javascripts/pages/projects/clusters/index/index.js
@@ -2,6 +2,5 @@ import PersistentUserCallout from '~/persistent_user_callout';
document.addEventListener('DOMContentLoaded', () => {
const callout = document.querySelector('.gcp-signup-offer');
-
- if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new
+ PersistentUserCallout.factory(callout);
});
diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js
index 899d5925956..92ed6a652d7 100644
--- a/app/assets/javascripts/pages/projects/edit/index.js
+++ b/app/assets/javascripts/pages/projects/edit/index.js
@@ -3,17 +3,24 @@ import initSettingsPanels from '~/settings_panels';
import setupProjectEdit from '~/project_edit';
import initConfirmDangerModal from '~/confirm_danger_modal';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
-import fileUpload from '~/lib/utils/file_upload';
+import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
+import initAvatarPicker from '~/avatar_picker';
import initProjectLoadingSpinner from '../shared/save_project_loader';
import initProjectPermissionsSettings from '../shared/permissions';
document.addEventListener('DOMContentLoaded', () => {
- initProjectLoadingSpinner();
- setupProjectEdit();
- // Initialize expandable settings panels
- initSettingsPanels();
- fileUpload('.js-choose-project-avatar-button', '.js-project-avatar-input');
- initProjectPermissionsSettings();
+ initAvatarPicker();
initConfirmDangerModal();
+ initSettingsPanels();
mountBadgeSettings(PROJECT_BADGE);
+
+ initProjectLoadingSpinner();
+ initProjectPermissionsSettings();
+ setupProjectEdit();
+
+ dirtySubmitFactory(
+ document.querySelectorAll(
+ '.js-general-settings-form, .js-mr-settings-form, .js-mr-approvals-form',
+ ),
+ );
});
diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js
index b0345b4e50d..d4bd02c14e9 100644
--- a/app/assets/javascripts/pages/projects/index.js
+++ b/app/assets/javascripts/pages/projects/index.js
@@ -13,7 +13,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (newClusterViews.indexOf(page) > -1) {
const callout = document.querySelector('.gcp-signup-offer');
- if (callout) new PersistentUserCallout(callout); // eslint-disable-line no-new
+ PersistentUserCallout.factory(callout);
initGkeDropdowns();
}
diff --git a/app/assets/javascripts/pages/projects/issues/edit/index.js b/app/assets/javascripts/pages/projects/issues/edit/index.js
index ffc84dc106b..aecc6484b26 100644
--- a/app/assets/javascripts/pages/projects/issues/edit/index.js
+++ b/app/assets/javascripts/pages/projects/issues/edit/index.js
@@ -1,3 +1,3 @@
-import initForm from '../form';
+import initForm from 'ee_else_ce/pages/projects/issues/form';
document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js
index f99023ad8e7..941c4552579 100644
--- a/app/assets/javascripts/pages/projects/issues/form.js
+++ b/app/assets/javascripts/pages/projects/issues/form.js
@@ -2,7 +2,7 @@
import $ from 'jquery';
import GLForm from '~/gl_form';
-import IssuableForm from '~/issuable_form';
+import IssuableForm from 'ee_else_ce/issuable_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 bb91e38cb64..c34aff02111 100644
--- a/app/assets/javascripts/pages/projects/issues/index/index.js
+++ b/app/assets/javascripts/pages/projects/issues/index/index.js
@@ -4,9 +4,9 @@ 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 IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
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';
document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js
index ffc84dc106b..aecc6484b26 100644
--- a/app/assets/javascripts/pages/projects/issues/new/index.js
+++ b/app/assets/javascripts/pages/projects/issues/new/index.js
@@ -1,3 +1,3 @@
-import initForm from '../form';
+import initForm from 'ee_else_ce/pages/projects/issues/form';
document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
index 8987c8e3f47..0447d1f79fb 100644
--- a/app/assets/javascripts/pages/projects/issues/show.js
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -4,9 +4,11 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import ZenMode from '~/zen_mode';
import '~/notes/index';
import initIssueableApp from '~/issue_show';
+import initRelatedMergeRequestsApp from '~/related_merge_requests';
export default function() {
initIssueableApp();
+ initRelatedMergeRequestsApp();
new Issue(); // eslint-disable-line no-new
new ShortcutsIssuable(); // eslint-disable-line no-new
new ZenMode(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/labels/edit/index.js b/app/assets/javascripts/pages/projects/labels/edit/index.js
index fa81ad914ba..83d6ac9fd14 100644
--- a/app/assets/javascripts/pages/projects/labels/edit/index.js
+++ b/app/assets/javascripts/pages/projects/labels/edit/index.js
@@ -1,3 +1,3 @@
-import Labels from '~/labels';
+import Labels from 'ee_else_ce/labels';
document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/projects/labels/new/index.js b/app/assets/javascripts/pages/projects/labels/new/index.js
index fa81ad914ba..83d6ac9fd14 100644
--- a/app/assets/javascripts/pages/projects/labels/new/index.js
+++ b/app/assets/javascripts/pages/projects/labels/new/index.js
@@ -1,3 +1,3 @@
-import Labels from '~/labels';
+import Labels from 'ee_else_ce/labels';
document.addEventListener('DOMContentLoaded', () => new Labels());
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 ec39db12e74..0bcca22e40f 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
@@ -2,12 +2,13 @@ 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';
document.addEventListener('DOMContentLoaded', () => {
- IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests();
+ addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
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 e3971618da5..8f0dc8554e2 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
@@ -4,7 +4,7 @@ import $ from 'jquery';
import Diff from '~/diff';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import GLForm from '~/gl_form';
-import IssuableForm from '~/issuable_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/pages_domains/edit/index.js b/app/assets/javascripts/pages/projects/pages_domains/edit/index.js
new file mode 100644
index 00000000000..27e4433ad4d
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pages_domains/edit/index.js
@@ -0,0 +1,3 @@
+import initForm from '~/pages/projects/pages_domains/form';
+
+document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/pages_domains/form.js b/app/assets/javascripts/pages/projects/pages_domains/form.js
new file mode 100644
index 00000000000..1d0dbfe0406
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pages_domains/form.js
@@ -0,0 +1,43 @@
+import setupToggleButtons from '~/toggle_buttons';
+
+export default () => {
+ const toggleContainer = document.querySelector('.js-auto-ssl-toggle-container');
+
+ if (toggleContainer) {
+ const onToggleButtonClicked = isAutoSslEnabled => {
+ Array.from(document.querySelectorAll('.js-shown-if-auto-ssl')).forEach(el => {
+ if (isAutoSslEnabled) {
+ el.classList.remove('d-none');
+ } else {
+ el.classList.add('d-none');
+ }
+ });
+
+ Array.from(document.querySelectorAll('.js-shown-unless-auto-ssl')).forEach(el => {
+ if (isAutoSslEnabled) {
+ el.classList.add('d-none');
+ } else {
+ el.classList.remove('d-none');
+ }
+ });
+
+ Array.from(document.querySelectorAll('.js-enabled-if-auto-ssl')).forEach(el => {
+ if (isAutoSslEnabled) {
+ el.removeAttribute('disabled');
+ } else {
+ el.setAttribute('disabled', 'disabled');
+ }
+ });
+
+ Array.from(document.querySelectorAll('.js-enabled-unless-auto-ssl')).forEach(el => {
+ if (isAutoSslEnabled) {
+ el.setAttribute('disabled', 'disabled');
+ } else {
+ el.removeAttribute('disabled');
+ }
+ });
+ };
+
+ setupToggleButtons(toggleContainer, onToggleButtonClicked);
+ }
+};
diff --git a/app/assets/javascripts/pages/projects/pages_domains/new/index.js b/app/assets/javascripts/pages/projects/pages_domains/new/index.js
new file mode 100644
index 00000000000..27e4433ad4d
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pages_domains/new/index.js
@@ -0,0 +1,3 @@
+import initForm from '~/pages/projects/pages_domains/form';
+
+document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
index bd4309e47ad..bb490919a9a 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
@@ -29,7 +29,7 @@ export default {
// The text input is editable when there's a custom interval, or when it's
// a preset interval and the user clicks the 'custom' radio button
isEditable() {
- return !!(this.customInputEnabled || !this.intervalIsPreset);
+ return Boolean(this.customInputEnabled || !this.intervalIsPreset);
},
},
watch: {
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js
index 95b57d5e048..a20a0526f12 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js
@@ -1,15 +1,42 @@
-/* eslint-disable class-methods-use-this */
+const defaultTimezone = { name: 'UTC', offset: 0 };
+const defaults = {
+ $inputEl: null,
+ $dropdownEl: null,
+ onSelectTimezone: null,
+ displayFormat: item => item.name,
+};
-import $ from 'jquery';
+export const formatUtcOffset = offset => {
+ const parsed = parseInt(offset, 10);
+ if (Number.isNaN(parsed) || parsed === 0) {
+ return `0`;
+ }
+ const prefix = offset > 0 ? '+' : '-';
+ return `${prefix} ${Math.abs(offset / 3600)}`;
+};
+
+export const formatTimezone = item => `[UTC ${formatUtcOffset(item.offset)}] ${item.name}`;
-const defaultTimezone = 'UTC';
+export const findTimezoneByIdentifier = (tzList = [], identifier = null) => {
+ if (tzList && tzList.length && identifier && identifier.length) {
+ return tzList.find(tz => tz.identifier === identifier) || null;
+ }
+ return null;
+};
export default class TimezoneDropdown {
- constructor() {
- this.$dropdown = $('.js-timezone-dropdown');
+ constructor({ $dropdownEl, $inputEl, onSelectTimezone, displayFormat } = defaults) {
+ this.$dropdown = $dropdownEl;
this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text');
- this.$input = $('#schedule_cron_timezone');
+ this.$input = $inputEl;
this.timezoneData = this.$dropdown.data('data');
+
+ this.onSelectTimezone = onSelectTimezone;
+ this.displayFormat = displayFormat || defaults.displayFormat;
+
+ this.initialTimezone =
+ findTimezoneByIdentifier(this.timezoneData, this.$input.val()) || defaultTimezone;
+
this.initDefaultTimezone();
this.initDropdown();
}
@@ -19,50 +46,32 @@ export default class TimezoneDropdown {
data: this.timezoneData,
filterable: true,
selectable: true,
- toggleLabel: item => item.name,
+ toggleLabel: this.displayFormat,
search: {
fields: ['name'],
},
clicked: cfg => this.updateInputValue(cfg),
- text: item => this.formatTimezone(item),
+ text: item => formatTimezone(item),
});
- this.setDropdownToggle();
- }
-
- formatUtcOffset(offset) {
- let prefix = '';
-
- if (offset > 0) {
- prefix = '+';
- } else if (offset < 0) {
- prefix = '-';
- }
-
- return `${prefix} ${Math.abs(offset / 3600)}`;
- }
-
- formatTimezone(item) {
- return `[UTC ${this.formatUtcOffset(item.offset)}] ${item.name}`;
+ this.setDropdownToggle(this.displayFormat(this.initialTimezone));
}
initDefaultTimezone() {
- const initialValue = this.$input.val();
-
- if (!initialValue) {
- this.$input.val(defaultTimezone);
+ if (!this.$input.val()) {
+ this.$input.val(defaultTimezone.name);
}
}
- setDropdownToggle() {
- const initialValue = this.$input.val();
-
- this.$dropdownToggle.text(initialValue);
+ setDropdownToggle(dropdownText) {
+ this.$dropdownToggle.text(dropdownText);
}
updateInputValue({ selectedObj, e }) {
e.preventDefault();
this.$input.val(selectedObj.identifier);
- gl.pipelineScheduleFieldErrors.updateFormValidityState();
+ if (this.onSelectTimezone) {
+ this.onSelectTimezone({ selectedObj, e });
+ }
}
}
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
index 4d494efef6c..dc6df27f1c7 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
@@ -41,7 +41,13 @@ export default () => {
const formElement = document.getElementById('new-pipeline-schedule-form');
- gl.timezoneDropdown = new TimezoneDropdown();
+ gl.timezoneDropdown = new TimezoneDropdown({
+ $dropdownEl: $('.js-timezone-dropdown'),
+ $inputEl: $('#schedule_cron_timezone'),
+ onSelectTimezone: () => {
+ gl.pipelineScheduleFieldErrors.updateFormValidityState();
+ },
+ });
gl.targetBranchDropdown = new TargetBranchDropdown();
gl.pipelineScheduleFieldErrors = new GlFieldErrors(formElement);
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index b288989b252..f0d529758d5 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -39,6 +39,11 @@ export default class Project {
$label.text(activeText);
});
+ $('#modal-geo-info').data({
+ cloneUrlSecondary: $this.attr('href'),
+ cloneUrlPrimary: $this.data('primaryUrl') || '',
+ });
+
if (mobileCloneField) {
mobileCloneField.dataset.clipboardText = url;
} else {
@@ -67,6 +72,13 @@ export default class Project {
.remove();
return e.preventDefault();
});
+ $('.hide-shared-runner-limit-message').on('click', function(e) {
+ var $alert = $(this).parents('.shared-runner-quota-message');
+ var scope = $alert.data('scope');
+ Cookies.set('hide_shared_runner_quota_message', 'false', { path: scope });
+ $alert.remove();
+ e.preventDefault();
+ });
$('.hide-auto-devops-implicitly-enabled-banner').on('click', function(e) {
const projectId = $(this).data('project-id');
const cookieKey = `hide_auto_devops_implicitly_enabled_banner_${projectId}`;
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index adbe744290a..f39765818e7 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -1,7 +1,7 @@
+import Members from 'ee_else_ce/members';
import memberExpirationDate from '../../../member_expiration_date';
import UsersSelect from '../../../users_select';
import groupsSelect from '../../../groups_select';
-import Members from '../../../members';
document.addEventListener('DOMContentLoaded', () => {
memberExpirationDate('.js-access-expiration-date-groups');
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 15c6fb550c1..885247335a4 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
@@ -21,6 +21,7 @@ document.addEventListener('DOMContentLoaded', () => {
saveButton: variableListEl.querySelector('.js-ci-variables-save-button'),
errorBox: variableListEl.querySelector('.js-ci-variable-error-box'),
saveEndpoint: variableListEl.dataset.saveEndpoint,
+ maskableRegex: variableListEl.dataset.maskableRegex,
});
// hide extra auto devops settings based checkbox state
diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
new file mode 100644
index 00000000000..98e19705976
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
@@ -0,0 +1,9 @@
+import mountErrorTrackingForm from '~/error_tracking_settings';
+import mountOperationSettings from '~/operation_settings';
+import initSettingsPanels from '~/settings_panels';
+
+document.addEventListener('DOMContentLoaded', () => {
+ mountErrorTrackingForm();
+ mountOperationSettings();
+ initSettingsPanels();
+});
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
index 19d9903c988..dea7c586868 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -175,11 +175,6 @@ export default {
if (value === 0) toggleHiddenClassBySelector('.merge-requests-feature', true);
else if (oldValue === 0) toggleHiddenClassBySelector('.merge-requests-feature', false);
},
-
- buildsAccessLevel(value, oldValue) {
- if (value === 0) toggleHiddenClassBySelector('.builds-feature', true);
- else if (oldValue === 0) toggleHiddenClassBySelector('.builds-feature', false);
- },
},
methods: {
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
index bc5c29d12b5..ac0dca31c37 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/constants.js
+++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export const visibilityOptions = {
PRIVATE: 0,
INTERNAL: 10,
@@ -5,9 +7,11 @@ export const visibilityOptions = {
};
export const visibilityLevelDescriptions = {
- [visibilityOptions.PRIVATE]:
+ [visibilityOptions.PRIVATE]: __(
'The project is accessible only by members of the project. Access must be granted explicitly to each user.',
- [visibilityOptions.INTERNAL]: 'The project can be accessed by any user who is logged in.',
- [visibilityOptions.PUBLIC]:
+ ),
+ [visibilityOptions.INTERNAL]: __('The project can be accessed by any user who is logged in.'),
+ [visibilityOptions.PUBLIC]: __(
'The project can be accessed by anyone, regardless of authentication.',
+ ),
};
diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js
index 7302c1ab202..6aa41d0825b 100644
--- a/app/assets/javascripts/pages/projects/show/index.js
+++ b/app/assets/javascripts/pages/projects/show/index.js
@@ -9,6 +9,7 @@ import Activities from '~/activities';
import { ajaxGet } from '~/lib/utils/common_utils';
import GpgBadges from '~/gpg_badges';
import initReadMore from '~/read_more';
+import leaveByUrl from '~/namespaces/leave_by_url';
import Star from '../../../star';
import notificationsDropdown from '../../../notifications_dropdown';
@@ -44,4 +45,13 @@ document.addEventListener('DOMContentLoaded', () => {
});
GpgBadges.fetch();
+ leaveByUrl('project');
+
+ if (document.getElementById('js-tree-list')) {
+ import('~/repository')
+ .then(m => m.default())
+ .catch(e => {
+ throw e;
+ });
+ }
});
diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js
index 400aed35e32..7b90a3a4f6e 100644
--- a/app/assets/javascripts/pages/projects/tree/show/index.js
+++ b/app/assets/javascripts/pages/projects/tree/show/index.js
@@ -40,4 +40,12 @@ document.addEventListener('DOMContentLoaded', () => {
}
GpgBadges.fetch();
+
+ if (document.getElementById('js-tree-list')) {
+ import('~/repository')
+ .then(m => m.default())
+ .catch(e => {
+ throw e;
+ });
+ }
});
diff --git a/app/assets/javascripts/pages/search/show/search.js b/app/assets/javascripts/pages/search/show/search.js
index 0c896c8599e..d5a8e712d6b 100644
--- a/app/assets/javascripts/pages/search/show/search.js
+++ b/app/assets/javascripts/pages/search/show/search.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import Flash from '~/flash';
import Api from '~/api';
+import { __ } from '~/locale';
export default class Search {
constructor() {
@@ -24,7 +25,7 @@ export default class Search {
data(term, callback) {
return Api.groups(term, {}, data => {
data.unshift({
- full_name: 'Any',
+ full_name: __('Any'),
});
data.splice(1, 0, 'divider');
return callback(data);
@@ -54,14 +55,14 @@ export default class Search {
this.getProjectsData(term)
.then(data => {
data.unshift({
- name_with_namespace: 'Any',
+ name_with_namespace: __('Any'),
});
data.splice(1, 0, 'divider');
return data;
})
.then(data => callback(data))
- .catch(() => new Flash('Error fetching projects'));
+ .catch(() => new Flash(__('Error fetching projects')));
},
id(obj) {
return obj.id;
diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js
index e1a3f42a71f..3f5a3e15c2c 100644
--- a/app/assets/javascripts/pages/sessions/new/index.js
+++ b/app/assets/javascripts/pages/sessions/new/index.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import LengthValidator from './length_validator';
import UsernameValidator from './username_validator';
import NoEmojiValidator from '../../../emoji/no_emoji_validator';
import SigninTabsMemoizer from './signin_tabs_memoizer';
@@ -6,6 +7,7 @@ import OAuthRememberMe from './oauth_remember_me';
import preserveUrlFragment from './preserve_url_fragment';
document.addEventListener('DOMContentLoaded', () => {
+ new LengthValidator(); // eslint-disable-line no-new
new UsernameValidator(); // eslint-disable-line no-new
new SigninTabsMemoizer(); // eslint-disable-line no-new
new NoEmojiValidator(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/sessions/new/length_validator.js b/app/assets/javascripts/pages/sessions/new/length_validator.js
new file mode 100644
index 00000000000..3d687ca08cc
--- /dev/null
+++ b/app/assets/javascripts/pages/sessions/new/length_validator.js
@@ -0,0 +1,32 @@
+import InputValidator from '../../../validators/input_validator';
+
+const errorMessageClass = 'gl-field-error';
+
+export default class LengthValidator extends InputValidator {
+ constructor(opts = {}) {
+ super();
+
+ const container = opts.container || '';
+ const validateLengthElements = document.querySelectorAll(`${container} .js-validate-length`);
+
+ validateLengthElements.forEach(element =>
+ element.addEventListener('input', this.eventHandler.bind(this)),
+ );
+ }
+
+ eventHandler(event) {
+ this.inputDomElement = event.target;
+ this.inputErrorMessage = this.inputDomElement.parentElement.querySelector(
+ `.${errorMessageClass}`,
+ );
+
+ const { value } = this.inputDomElement;
+ const { maxLengthMessage, maxLength } = this.inputDomElement.dataset;
+
+ this.errorMessage = maxLengthMessage;
+
+ this.invalidInput = value.length > parseInt(maxLength, 10);
+
+ this.setValidationStateAndMessage();
+ }
+}
diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
index afa099d0e0b..693125f8a38 100644
--- a/app/assets/javascripts/pages/users/activity_calendar.js
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -6,10 +6,16 @@ import dateFormat from 'dateformat';
import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility';
import axios from '~/lib/utils/axios_utils';
import flash from '~/flash';
-import { __ } from '~/locale';
+import { n__, s__, __ } from '~/locale';
const d3 = { select, scaleLinear, scaleThreshold };
+const firstDayOfWeekChoices = Object.freeze({
+ sunday: 0,
+ monday: 1,
+ saturday: 6,
+});
+
const LOADING_HTML = `
<div class="text-center">
<i class="fa fa-spinner fa-spin user-calendar-activities-loading"></i>
@@ -29,9 +35,9 @@ function formatTooltipText({ date, count }) {
const dateDayName = getDayName(dateObject);
const dateText = dateFormat(dateObject, 'mmm d, yyyy');
- let contribText = 'No contributions';
+ let contribText = __('No contributions');
if (count > 0) {
- contribText = `${count} contribution${count > 1 ? 's' : ''}`;
+ contribText = n__('%d contribution', '%d contributions', count);
}
return `${contribText}<br />${dateDayName} ${dateText}`;
}
@@ -49,7 +55,7 @@ export default class ActivityCalendar {
timestamps,
calendarActivitiesPath,
utcOffset = 0,
- firstDayOfWeek = 0,
+ firstDayOfWeek = firstDayOfWeekChoices.sunday,
monthsAgo = 12,
) {
this.calendarActivitiesPath = calendarActivitiesPath;
@@ -59,18 +65,18 @@ export default class ActivityCalendar {
this.daySize = 15;
this.daySizeWithSpace = this.daySize + this.daySpace * 2;
this.monthNames = [
- 'Jan',
- 'Feb',
- 'Mar',
- 'Apr',
- 'May',
- 'Jun',
- 'Jul',
- 'Aug',
- 'Sep',
- 'Oct',
- 'Nov',
- 'Dec',
+ __('Jan'),
+ __('Feb'),
+ __('Mar'),
+ __('Apr'),
+ __('May'),
+ __('Jun'),
+ __('Jul'),
+ __('Aug'),
+ __('Sep'),
+ __('Oct'),
+ __('Nov'),
+ __('Dec'),
];
this.months = [];
this.firstDayOfWeek = firstDayOfWeek;
@@ -193,24 +199,29 @@ export default class ActivityCalendar {
renderDayTitles() {
const days = [
{
- text: 'M',
+ text: s__('DayTitle|M'),
y: 29 + this.dayYPos(1),
},
{
- text: 'W',
+ text: s__('DayTitle|W'),
y: 29 + this.dayYPos(3),
},
{
- text: 'F',
+ text: s__('DayTitle|F'),
y: 29 + this.dayYPos(5),
},
];
- if (this.firstDayOfWeek === 1) {
+ if (this.firstDayOfWeek === firstDayOfWeekChoices.monday) {
days.push({
- text: 'S',
+ text: s__('DayTitle|S'),
y: 29 + this.dayYPos(7),
});
+ } else if (this.firstDayOfWeek === firstDayOfWeekChoices.saturday) {
+ days.push({
+ text: s__('DayTitle|S'),
+ y: 29 + this.dayYPos(6),
+ });
}
this.svg
@@ -242,11 +253,11 @@ export default class ActivityCalendar {
renderKey() {
const keyValues = [
- 'no contributions',
- '1-9 contributions',
- '10-19 contributions',
- '20-29 contributions',
- '30+ contributions',
+ __('no contributions'),
+ __('1-9 contributions'),
+ __('10-19 contributions'),
+ __('20-29 contributions'),
+ __('30+ contributions'),
];
const keyColors = [
'#ededed',
diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js
index 636308c5401..7f800d20835 100644
--- a/app/assets/javascripts/pages/users/user_tabs.js
+++ b/app/assets/javascripts/pages/users/user_tabs.js
@@ -91,6 +91,7 @@ export default class UserTabs {
this.actions = Object.keys(this.loaded);
this.bindEvents();
+ // TODO: refactor to make this configurable via constructor params with a default value of 'show'
if (this.action === 'show') {
this.action = this.defaultAction;
}
diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue
index cdf1257b4e3..6d39abd4a1f 100644
--- a/app/assets/javascripts/pdf/index.vue
+++ b/app/assets/javascripts/pdf/index.vue
@@ -1,6 +1,6 @@
<script>
-import pdfjsLib from 'vendor/pdf';
-import workerSrc from 'vendor/pdf.worker.min';
+import pdfjsLib from 'pdfjs-dist/build/pdf';
+import workerSrc from 'pdfjs-dist/build/pdf.worker.min';
import page from './page/index.vue';
@@ -28,7 +28,7 @@ export default {
},
watch: { pdf: 'load' },
mounted() {
- pdfjsLib.PDFJS.workerSrc = workerSrc;
+ pdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc;
if (this.hasPDF) this.load();
},
methods: {
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
index c729198c1d3..8f3ba9779fb 100644
--- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -1,9 +1,11 @@
<script>
import GlModal from '~/vue_shared/components/gl_modal.vue';
+import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
GlModal,
+ Icon,
},
props: {
currentRequest: {
@@ -38,7 +40,11 @@ export default {
};
</script>
<template>
- <div v-if="currentRequest.details" :id="`peek-view-${metric}`" class="view">
+ <div
+ v-if="currentRequest.details"
+ :id="`peek-view-${metric}`"
+ class="view qa-performance-bar-detailed-metric"
+ >
<button
:data-target="`#modal-peek-${metric}-details`"
class="btn-blank btn-link bold"
@@ -57,9 +63,31 @@ export default {
<template v-if="detailsList.length">
<tr v-for="(item, index) in detailsList" :key="index">
<td>
- <strong>{{ item.duration }}ms</strong>
+ <span>{{ item.duration }}ms</span>
+ </td>
+ <td>
+ <div class="js-toggle-container">
+ <div
+ v-for="(key, keyIndex) in keys"
+ :key="key"
+ class="break-word"
+ :class="{ 'mb-3 bold': keyIndex == 0 }"
+ >
+ {{ item[key] }}
+ <button
+ v-if="keyIndex == 0 && item.backtrace"
+ class="text-expander js-toggle-button"
+ type="button"
+ :aria-label="__('Toggle backtrace')"
+ >
+ <icon :size="12" name="ellipsis_h" />
+ </button>
+ </div>
+ <pre v-if="item.backtrace" class="backtrace-row js-toggle-content mt-2">{{
+ item.backtrace
+ }}</pre>
+ </div>
</td>
- <td v-for="key in keys" :key="key" class="break-word">{{ item[key] }}</td>
</tr>
</template>
<template v-else>
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 1ec2784cc5a..48515cf785c 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -92,7 +92,7 @@ export default {
</script>
<template>
<div id="js-peek" :class="env">
- <div v-if="currentRequest" class="d-flex container-fluid container-limited">
+ <div v-if="currentRequest" class="d-flex container-fluid container-limited qa-performance-bar">
<div id="peek-view-host" class="view">
<span
v-if="hasHost"
diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue
index fdb5c0d6939..297507b85af 100644
--- a/app/assets/javascripts/performance_bar/components/request_selector.vue
+++ b/app/assets/javascripts/performance_bar/components/request_selector.vue
@@ -37,7 +37,12 @@ export default {
<template>
<div id="peek-request-selector">
<select v-model="currentRequestId">
- <option v-for="request in requests" :key="request.id" :value="request.id">
+ <option
+ v-for="request in requests"
+ :key="request.id"
+ :value="request.id"
+ class="qa-performance-bar-request"
+ >
{{ truncatedUrl(request.url) }}
</option>
</select>
diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js
index 1e34e74a152..4a08e158f6b 100644
--- a/app/assets/javascripts/persistent_user_callout.js
+++ b/app/assets/javascripts/persistent_user_callout.js
@@ -31,4 +31,12 @@ export default class PersistentUserCallout {
Flash(__('An error occurred while dismissing the alert. Refresh the page and try again.'));
});
}
+
+ static factory(container) {
+ if (!container) {
+ return undefined;
+ }
+
+ return new PersistentUserCallout(container);
+ }
}
diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue
index 8ca539351a7..3c85bb61ce8 100644
--- a/app/assets/javascripts/pipelines/components/graph/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltipDirective, GlButton } from '@gitlab/ui';
+import { GlTooltipDirective, GlButton, GlLoadingIcon } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { dasherize } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
@@ -20,6 +20,7 @@ export default {
components: {
Icon,
GlButton,
+ GlLoadingIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -41,6 +42,7 @@ export default {
data() {
return {
isDisabled: false,
+ isLoading: false,
};
},
computed: {
@@ -59,15 +61,19 @@ export default {
onClickAction() {
this.$root.$emit('bv::hide::tooltip', `js-ci-action-${this.link}`);
this.isDisabled = true;
+ this.isLoading = true;
axios
.post(`${this.link}.json`)
.then(() => {
this.isDisabled = false;
+ this.isLoading = false;
+
this.$emit('pipelineActionRequestComplete');
})
.catch(() => {
this.isDisabled = false;
+ this.isLoading = false;
createFlash(__('An error occurred while making the request.'));
});
@@ -82,10 +88,10 @@ export default {
:title="tooltipText"
:class="cssClass"
:disabled="isDisabled"
- class="js-ci-action btn btn-blank
-btn-transparent ci-action-icon-container ci-action-icon-wrapper"
+ class="js-ci-action btn btn-blank btn-transparent ci-action-icon-container ci-action-icon-wrapper"
@click="onClickAction"
>
- <icon :name="actionIcon" />
+ <gl-loading-icon v-if="isLoading" class="js-action-icon-loading" />
+ <icon v-else :name="actionIcon" />
</gl-button>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index a49dc311bd0..ba0dea626dc 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -24,6 +24,7 @@ export default {
:groups="stage.groups"
:stage-connector-class="stageConnectorClass(index, stage)"
:is-first-column="isFirstColumn(index)"
+ :action="stage.status.action"
@refreshPipelineGraph="refreshPipelineGraph"
/>
</ul>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
index 482898b80c4..ebd7a17040a 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -69,7 +69,9 @@ export default {
>
<ci-icon :status="group.status" />
- <span class="ci-status-text"> {{ group.name }} </span>
+ <span class="ci-status-text text-truncate mw-70p gl-pl-1 d-inline-block align-bottom">
+ {{ group.name }}
+ </span>
<span class="dropdown-counter-badge"> {{ group.size }} </span>
</button>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 2b32a6e4a98..0d5afe04e8e 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -57,6 +57,9 @@ export default {
},
},
computed: {
+ boundary() {
+ return this.dropdownLength === 1 ? 'viewport' : 'scrollParent';
+ },
status() {
return this.job && this.job.status ? this.job.status : {};
},
@@ -104,7 +107,7 @@ export default {
<div class="ci-job-component">
<gl-link
v-if="status.has_details"
- v-gl-tooltip
+ v-gl-tooltip="{ boundary, placement: 'bottom' }"
:href="status.details_path"
:title="tooltipText"
:class="cssClassJobName"
@@ -115,7 +118,7 @@ export default {
<div
v-else
- v-gl-tooltip
+ v-gl-tooltip="{ boundary, placement: 'bottom' }"
:title="tooltipText"
:class="cssClassJobName"
class="js-job-component-tooltip non-details-job-component"
diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
index 1bfab2a7fc0..02451839330 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
@@ -27,7 +27,8 @@ export default {
<template>
<span class="ci-job-name-component">
<ci-icon :status="status" />
-
- <span class="ci-status-text"> {{ name }} </span>
+ <span class="ci-status-text text-truncate mw-70p gl-pl-1 d-inline-block align-bottom">
+ {{ name }}
+ </span>
</span>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
index 09a50d25020..d5c124dc0ca 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -1,13 +1,17 @@
<script>
import _ from 'underscore';
+import stageColumnMixin from 'ee_else_ce/pipelines/mixins/stage_column_mixin';
import JobItem from './job_item.vue';
import JobGroupDropdown from './job_group_dropdown.vue';
+import ActionComponent from './action_component.vue';
export default {
components: {
JobItem,
JobGroupDropdown,
+ ActionComponent,
},
+ mixins: [stageColumnMixin],
props: {
title: {
type: String,
@@ -27,14 +31,21 @@ export default {
required: false,
default: '',
},
+ action: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ computed: {
+ hasAction() {
+ return !_.isEmpty(this.action);
+ },
},
methods: {
groupId(group) {
return `ci-badge-${_.escape(group.name)}`;
},
- buildConnnectorClass(index) {
- return index === 0 && !this.isFirstColumn ? 'left-connector' : '';
- },
pipelineActionRequestComplete() {
this.$emit('refreshPipelineGraph');
},
@@ -43,7 +54,18 @@ export default {
</script>
<template>
<li :class="stageConnectorClass" class="stage-column">
- <div class="stage-name">{{ title }}</div>
+ <div class="stage-name position-relative">
+ {{ title }}
+ <action-component
+ v-if="hasAction"
+ :action-icon="action.icon"
+ :tooltip-text="action.title"
+ :link="action.path"
+ class="js-stage-action stage-action position-absolute position-top-0 rounded"
+ @pipelineActionRequestComplete="pipelineActionRequestComplete"
+ />
+ </div>
+
<div class="builds-container">
<ul>
<li
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index b2e365e5cde..f3a71ee434c 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -83,6 +83,8 @@ export default {
v-if="shouldRenderContent"
:status="status"
:item-id="pipeline.id"
+ :item-iid="pipeline.iid"
+ :item-id-tooltip="__('Pipeline ID (IID)')"
:time="pipeline.created_at"
:user="pipeline.user"
:actions="actions"
diff --git a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue
new file mode 100644
index 00000000000..4cafd147511
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue
@@ -0,0 +1,97 @@
+<script>
+import _ from 'underscore';
+import GlModal from '~/vue_shared/components/gl_modal.vue';
+import { GlLink } from '@gitlab/ui';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import { s__, sprintf } from '~/locale';
+
+/**
+ * Pipeline Stop Modal.
+ *
+ * Renders the modal used to confirm stopping a pipeline.
+ */
+export default {
+ components: {
+ GlModal,
+ GlLink,
+ ClipboardButton,
+ CiIcon,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ deep: true,
+ },
+ },
+ computed: {
+ modalTitle() {
+ return sprintf(
+ s__('Pipeline|Stop pipeline #%{pipelineId}?'),
+ {
+ pipelineId: `${this.pipeline.id}`,
+ },
+ false,
+ );
+ },
+ modalText() {
+ return sprintf(
+ s__(`Pipeline|You’re about to stop pipeline %{pipelineId}.`),
+ {
+ pipelineId: `<strong>#${this.pipeline.id}</strong>`,
+ },
+ false,
+ );
+ },
+ hasRef() {
+ return !_.isEmpty(this.pipeline.ref);
+ },
+ },
+ methods: {
+ emitSubmit(event) {
+ this.$emit('submit', event);
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ id="confirmation-modal"
+ :header-title-text="modalTitle"
+ :footer-primary-button-text="s__('Pipeline|Stop pipeline')"
+ footer-primary-button-variant="danger"
+ @submit="emitSubmit($event)"
+ >
+ <p v-html="modalText"></p>
+
+ <p v-if="pipeline">
+ <ci-icon
+ v-if="pipeline.details"
+ :status="pipeline.details.status"
+ class="vertical-align-middle"
+ />
+
+ <span class="font-weight-bold">{{ __('Pipeline') }}</span>
+
+ <a :href="pipeline.path" class="js-pipeline-path link-commit qa-pipeline-path"
+ >#{{ pipeline.id }}</a
+ >
+ <template v-if="hasRef">
+ {{ __('from') }}
+ <a :href="pipeline.ref.path" class="link-commit ref-name">{{ pipeline.ref.name }}</a>
+ </template>
+ </p>
+
+ <template v-if="pipeline.commit">
+ <p>
+ <span class="font-weight-bold">{{ __('Commit') }}</span>
+
+ <gl-link :href="pipeline.commit.commit_path" class="js-commit-sha commit-sha link-commit">
+ {{ pipeline.commit.short_id }}
+ </gl-link>
+ </p>
+ <p>{{ pipeline.commit.title }}</p>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_triggerer.vue b/app/assets/javascripts/pipelines/components/pipeline_triggerer.vue
new file mode 100644
index 00000000000..740b54cd8e0
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_triggerer.vue
@@ -0,0 +1,35 @@
+<script>
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+
+export default {
+ components: {
+ UserAvatarLink,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ user() {
+ return this.pipeline.user;
+ },
+ },
+};
+</script>
+<template>
+ <div class="table-section section-10 d-none d-sm-none d-md-block pipeline-triggerer">
+ <user-avatar-link
+ v-if="user"
+ :link-href="user.path"
+ :img-src="user.avatar_url"
+ :img-size="26"
+ :tooltip-text="user.name"
+ class="prepend-left-default js-pipeline-url-user"
+ />
+ <span v-else class="prepend-left-default js-pipeline-url-api api">
+ {{ s__('Pipelines|API') }}
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue
index 918622ef8dc..00c02e15562 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue
@@ -2,6 +2,7 @@
import { GlLink, GlTooltipDirective } from '@gitlab/ui';
import _ from 'underscore';
import { __, sprintf } from '~/locale';
+import PipelineLink from '~/vue_shared/components/ci_pipeline_link.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import popover from '~/vue_shared/directives/popover';
@@ -19,6 +20,7 @@ export default {
components: {
UserAvatarLink,
GlLink,
+ PipelineLink,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -59,19 +61,13 @@ export default {
};
</script>
<template>
- <div class="table-section section-15 d-none d-sm-none d-md-block pipeline-tags">
- <gl-link :href="pipeline.path" class="js-pipeline-url-link">
- <span class="pipeline-id">#{{ pipeline.id }}</span>
- </gl-link>
- <span>by</span>
- <user-avatar-link
- v-if="user"
- :link-href="user.path"
- :img-src="user.avatar_url"
- :tooltip-text="user.name"
- class="js-pipeline-url-user"
+ <div class="table-section section-10 d-none d-sm-none d-md-block pipeline-tags section-wrap">
+ <pipeline-link
+ :href="pipeline.path"
+ :pipeline-id="pipeline.id"
+ :pipeline-iid="pipeline.iid"
+ class="js-pipeline-url-link"
/>
- <span v-if="!user" class="js-pipeline-url-api api"> API </span>
<div class="label-container">
<span
v-if="pipeline.flags.latest"
@@ -110,12 +106,12 @@ export default {
{{ __('stuck') }}
</span>
<span
- v-if="pipeline.flags.merge_request"
+ v-if="pipeline.flags.detached_merge_request_pipeline"
v-gl-tooltip
- :title="__('This pipeline is run in a merge request context')"
- class="js-pipeline-url-mergerequest badge badge-info"
+ :title="__('This pipeline is run on the source branch')"
+ class="js-pipeline-url-detached badge badge-info"
>
- {{ __('merge request') }}
+ {{ __('detached') }}
</span>
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_table.vue
index 1c60ae6a152..03d332cd430 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_table.vue
@@ -1,7 +1,7 @@
<script>
-import Modal from '~/vue_shared/components/gl_modal.vue';
-import { s__, sprintf } from '~/locale';
+import { GlTooltipDirective } from '@gitlab/ui';
import PipelinesTableRowComponent from './pipelines_table_row.vue';
+import PipelineStopModal from './pipeline_stop_modal.vue';
import eventHub from '../event_hub';
/**
@@ -12,7 +12,10 @@ import eventHub from '../event_hub';
export default {
components: {
PipelinesTableRowComponent,
- Modal,
+ PipelineStopModal,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
props: {
pipelines: {
@@ -36,30 +39,11 @@ export default {
data() {
return {
pipelineId: 0,
+ pipeline: {},
endpoint: '',
cancelingPipeline: null,
};
},
- computed: {
- modalTitle() {
- return sprintf(
- s__('Pipeline|Stop pipeline #%{pipelineId}?'),
- {
- pipelineId: `${this.pipelineId}`,
- },
- false,
- );
- },
- modalText() {
- return sprintf(
- s__('Pipeline|You’re about to stop pipeline %{pipelineId}.'),
- {
- pipelineId: `<strong>#${this.pipelineId}</strong>`,
- },
- false,
- );
- },
- },
created() {
eventHub.$on('openConfirmationModal', this.setModalData);
},
@@ -68,7 +52,8 @@ export default {
},
methods: {
setModalData(data) {
- this.pipelineId = data.pipelineId;
+ this.pipelineId = data.pipeline.id;
+ this.pipeline = data.pipeline;
this.endpoint = data.endpoint;
},
onSubmit() {
@@ -81,16 +66,19 @@ export default {
<template>
<div class="ci-table">
<div class="gl-responsive-table-row table-row-header" role="row">
- <div class="table-section section-10 js-pipeline-status pipeline-status" role="rowheader">
+ <div class="table-section section-10 js-pipeline-status" role="rowheader">
{{ s__('Pipeline|Status') }}
</div>
- <div class="table-section section-15 js-pipeline-info pipeline-info" role="rowheader">
+ <div class="table-section section-10 js-pipeline-info pipeline-info" role="rowheader">
{{ s__('Pipeline|Pipeline') }}
</div>
+ <div class="table-section section-10 js-triggerer-info triggerer-info" role="rowheader">
+ {{ s__('Pipeline|Triggerer') }}
+ </div>
<div class="table-section section-20 js-pipeline-commit pipeline-commit" role="rowheader">
{{ s__('Pipeline|Commit') }}
</div>
- <div class="table-section section-20 js-pipeline-stages pipeline-stages" role="rowheader">
+ <div class="table-section section-15 js-pipeline-stages pipeline-stages" role="rowheader">
{{ s__('Pipeline|Stages') }}
</div>
</div>
@@ -103,15 +91,6 @@ export default {
:view-type="viewType"
:canceling-pipeline="cancelingPipeline"
/>
-
- <modal
- id="confirmation-modal"
- :header-title-text="modalTitle"
- :footer-primary-button-text="s__('Pipeline|Stop pipeline')"
- footer-primary-button-variant="danger"
- @submit="onSubmit"
- >
- <span v-html="modalText"></span>
- </modal>
+ <pipeline-stop-modal :pipeline="pipeline" @submit="onSubmit" />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
index da42698c255..e32e2f785bd 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
@@ -5,6 +5,7 @@ import PipelinesArtifactsComponent from './pipelines_artifacts.vue';
import CiBadge from '../../vue_shared/components/ci_badge_link.vue';
import PipelineStage from './stage.vue';
import PipelineUrl from './pipeline_url.vue';
+import PipelineTriggerer from './pipeline_triggerer.vue';
import PipelinesTimeago from './time_ago.vue';
import CommitComponent from '../../vue_shared/components/commit.vue';
import LoadingButton from '../../vue_shared/components/loading_button.vue';
@@ -23,6 +24,7 @@ export default {
CommitComponent,
PipelineStage,
PipelineUrl,
+ PipelineTriggerer,
CiBadge,
PipelinesTimeago,
LoadingButton,
@@ -243,7 +245,7 @@ export default {
methods: {
handleCancelClick() {
eventHub.$emit('openConfirmationModal', {
- pipelineId: this.pipeline.id,
+ pipeline: this.pipeline,
endpoint: this.pipeline.cancel_path,
});
},
@@ -264,23 +266,25 @@ export default {
</div>
<pipeline-url :pipeline="pipeline" :auto-devops-help-path="autoDevopsHelpPath" />
+ <pipeline-triggerer :pipeline="pipeline" />
- <div class="table-section section-20">
+ <div class="table-section section-wrap section-20">
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Commit') }}</div>
<div class="table-mobile-content">
<commit-component
:tag="commitTag"
:commit-ref="commitRef"
:commit-url="commitUrl"
+ :merge-request-ref="pipeline.merge_request"
:short-sha="commitShortSha"
:title="commitTitle"
:author="commitAuthor"
- :show-branch="!isChildView"
+ :show-ref-info="!isChildView"
/>
</div>
</div>
- <div class="table-section section-wrap section-20 stage-cell">
+ <div class="table-section section-wrap section-15 stage-cell">
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Stages') }}</div>
<div class="table-mobile-content">
<template v-if="pipeline.details.stages.length > 0">
diff --git a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js
new file mode 100644
index 00000000000..dd79ade5bc9
--- /dev/null
+++ b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js
@@ -0,0 +1,16 @@
+import Flash from '~/flash';
+import { __ } from '~/locale';
+
+export default {
+ methods: {
+ clickTriggeredByPipeline() {},
+ clickTriggeredPipeline() {},
+ requestRefreshPipelineGraph() {
+ // When an action is clicked
+ // (wether in the dropdown or in the main nodes, we refresh the big graph)
+ this.mediator
+ .refreshPipeline()
+ .catch(() => Flash(__('An error occurred while making the request.')));
+ },
+ },
+};
diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js
index 74ca3071364..3cc9d0a3a4e 100644
--- a/app/assets/javascripts/pipelines/mixins/pipelines.js
+++ b/app/assets/javascripts/pipelines/mixins/pipelines.js
@@ -27,11 +27,7 @@ export default {
},
computed: {
shouldRenderPagination() {
- return (
- !this.isLoading &&
- this.state.pipelines.length &&
- this.state.pageInfo.total > this.state.pageInfo.perPage
- );
+ return !this.isLoading;
},
},
beforeMount() {
diff --git a/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js b/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js
new file mode 100644
index 00000000000..64283ed0e58
--- /dev/null
+++ b/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js
@@ -0,0 +1,7 @@
+export default {
+ methods: {
+ buildConnnectorClass(index) {
+ return index === 0 && !this.isFirstColumn ? 'left-connector' : '';
+ },
+ },
+};
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index dc9befe6349..b8976f77bac 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -2,8 +2,9 @@ import Vue from 'vue';
import Flash from '~/flash';
import Translate from '~/vue_shared/translate';
import { __ } from '~/locale';
+import pipelineGraph from 'ee_else_ce/pipelines/components/graph/graph_component.vue';
+import GraphEEMixin from 'ee_else_ce/pipelines/mixins/graph_pipeline_bundle_mixin';
import PipelinesMediator from './pipeline_details_mediator';
-import pipelineGraph from './components/graph/graph_component.vue';
import pipelineHeader from './components/header_component.vue';
import eventHub from './event_hub';
@@ -22,28 +23,25 @@ export default () => {
components: {
pipelineGraph,
},
+ mixins: [GraphEEMixin],
data() {
return {
mediator,
};
},
- methods: {
- requestRefreshPipelineGraph() {
- // When an action is clicked
- // (wether in the dropdown or in the main nodes, we refresh the big graph)
- this.mediator
- .refreshPipeline()
- .catch(() => Flash(__('An error occurred while making the request.')));
- },
- },
render(createElement) {
return createElement('pipeline-graph', {
props: {
isLoading: this.mediator.state.isLoading,
pipeline: this.mediator.store.state.pipeline,
+ mediator: this.mediator,
},
on: {
refreshPipelineGraph: this.requestRefreshPipelineGraph,
+ onClickTriggeredBy: (parentPipeline, pipeline) =>
+ this.clickTriggeredByPipeline(parentPipeline, pipeline),
+ onClickTriggered: (parentPipeline, pipeline) =>
+ this.clickTriggeredPipeline(parentPipeline, pipeline),
},
});
},
diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js
index bd1e1895660..d67d88c4dba 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js
@@ -19,6 +19,7 @@ export default class pipelinesMediator {
this.poll = new Poll({
resource: this.service,
method: 'getPipeline',
+ data: this.store.state.expandedPipelines ? this.getExpandedParameters() : undefined,
successCallback: this.successCallback.bind(this),
errorCallback: this.errorCallback.bind(this),
});
@@ -56,6 +57,19 @@ export default class pipelinesMediator {
.getPipeline()
.then(response => this.successCallback(response))
.catch(() => this.errorCallback())
- .finally(() => this.poll.restart());
+ .finally(() =>
+ this.poll.restart(
+ this.store.state.expandedPipelines ? this.getExpandedParameters() : undefined,
+ ),
+ );
+ }
+
+ /**
+ * Backend expects paramets in the following format: `expanded[]=id&expanded[]=id`
+ */
+ getExpandedParameters() {
+ return {
+ expanded: this.store.state.expandedPipelines,
+ };
}
}
diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js
index a53a9cc8365..e44eb9cdfd1 100644
--- a/app/assets/javascripts/pipelines/services/pipeline_service.js
+++ b/app/assets/javascripts/pipelines/services/pipeline_service.js
@@ -5,8 +5,8 @@ export default class PipelineService {
this.pipeline = endpoint;
}
- getPipeline() {
- return axios.get(this.pipeline);
+ getPipeline(params) {
+ return axios.get(this.pipeline, { params });
}
// eslint-disable-next-line class-methods-use-this
diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js
index 052e34a8aef..259278b6410 100644
--- a/app/assets/javascripts/pipelines/stores/pipeline_store.js
+++ b/app/assets/javascripts/pipelines/stores/pipeline_store.js
@@ -1,7 +1,6 @@
export default class PipelineStore {
constructor() {
this.state = {};
-
this.state.pipeline = {};
}
diff --git a/app/assets/javascripts/profile/account/index.js b/app/assets/javascripts/profile/account/index.js
index 59c13e1a042..f0d9642a2b2 100644
--- a/app/assets/javascripts/profile/account/index.js
+++ b/app/assets/javascripts/profile/account/index.js
@@ -35,7 +35,7 @@ export default () => {
return createElement('delete-account-modal', {
props: {
actionUrl: deleteAccountModalEl.dataset.actionUrl,
- confirmWithPassword: !!deleteAccountModalEl.dataset.confirmWithPassword,
+ confirmWithPassword: Boolean(deleteAccountModalEl.dataset.confirmWithPassword),
username: deleteAccountModalEl.dataset.username,
},
});
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index deacff5abe7..8dd37aee7e1 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -2,6 +2,9 @@ import $ from 'jquery';
import axios from '~/lib/utils/axios_utils';
import flash from '../flash';
import { parseBoolean } from '~/lib/utils/common_utils';
+import TimezoneDropdown, {
+ formatTimezone,
+} from '~/pages/projects/pipeline_schedules/shared/components/timezone_dropdown';
export default class Profile {
constructor({ form } = {}) {
@@ -10,6 +13,14 @@ export default class Profile {
this.setRepoRadio();
this.bindEvents();
this.initAvatarGlCrop();
+
+ this.$inputEl = $('#user_timezone');
+
+ this.timezoneDropdown = new TimezoneDropdown({
+ $inputEl: this.$inputEl,
+ $dropdownEl: $('.js-timezone-dropdown'),
+ displayFormat: selectedItem => formatTimezone(selectedItem),
+ });
}
initAvatarGlCrop() {
@@ -28,6 +39,7 @@ export default class Profile {
bindEvents() {
$('.js-preferences-form').on('change.preference', 'input[type=radio]', this.submitForm);
+ $('.js-group-notification-email').on('change', this.submitForm);
$('#user_notification_email').on('change', this.submitForm);
$('#user_notified_of_own_activity').on('change', this.submitForm);
this.form.on('submit', this.onSubmitForm);
diff --git a/app/assets/javascripts/project_label_subscription.js b/app/assets/javascripts/project_label_subscription.js
index d3c604dcee1..5395e14cc79 100644
--- a/app/assets/javascripts/project_label_subscription.js
+++ b/app/assets/javascripts/project_label_subscription.js
@@ -38,9 +38,9 @@ export default class ProjectLabelSubscription {
let newAction;
if (oldStatus === 'unsubscribed') {
- [newStatus, newAction] = ['subscribed', 'Unsubscribe'];
+ [newStatus, newAction] = ['subscribed', __('Unsubscribe')];
} else {
- [newStatus, newAction] = ['unsubscribed', 'Subscribe'];
+ [newStatus, newAction] = ['unsubscribed', __('Subscribe')];
}
$btn.removeClass('disabled');
diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js
index 5ee510eb11d..dbe354a547b 100644
--- a/app/assets/javascripts/project_select.js
+++ b/app/assets/javascripts/project_select.js
@@ -3,6 +3,7 @@
import $ from 'jquery';
import Api from './api';
import ProjectSelectComboButton from './project_select_combo_button';
+import { s__ } from './locale';
export default function projectSelect() {
import(/* webpackChunkName: 'select2' */ 'select2/select2')
@@ -21,9 +22,9 @@ export default function projectSelect() {
this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false;
this.allowClear = $(select).data('allowClear') || false;
- placeholder = 'Search for project';
+ placeholder = s__('ProjectSelect|Search for project');
if (this.includeGroups) {
- placeholder += ' or group';
+ placeholder += s__('ProjectSelect| or group');
}
$(select).select2({
diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue
index 5f8a4946f4a..fd5d5f86401 100644
--- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue
+++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue
@@ -34,7 +34,7 @@ export default {
},
errorMessage() {
return sprintf(
- s__('ClusterIntegration|An error occured while trying to fetch project zones: %{error}'),
+ s__('ClusterIntegration|An error occurred while trying to fetch project zones: %{error}'),
{ error: this.gapiError },
);
},
diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js
index 4834a856271..f05ad7773a2 100644
--- a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js
+++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js
@@ -57,7 +57,7 @@ export const validateProjectBilling = ({ dispatch, commit, state }) =>
resp => {
const { billingEnabled } = resp.result;
- commit(types.SET_PROJECT_BILLING_STATUS, !!billingEnabled);
+ commit(types.SET_PROJECT_BILLING_STATUS, Boolean(billingEnabled));
dispatch('setIsValidatingProjectBilling', false);
resolve();
},
diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js
index e39f02d0894..f9e2e2f74fb 100644
--- a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js
+++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js
@@ -1,3 +1,3 @@
-export const hasProject = state => !!state.selectedProject.projectId;
-export const hasZone = state => !!state.selectedZone;
-export const hasMachineType = state => !!state.selectedMachineType;
+export const hasProject = state => Boolean(state.selectedProject.projectId);
+export const hasZone = state => Boolean(state.selectedZone);
+export const hasMachineType = state => Boolean(state.selectedMachineType);
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index 6fb25622a05..ea82ff4e340 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils';
import { slugifyWithHyphens } from '../lib/utils/text_utility';
+import { s__ } from '~/locale';
let hasUserDefinedProjectPath = false;
@@ -114,59 +115,71 @@ const bindEvents = () => {
const value = $(this).val();
const templates = {
rails: {
- text: 'Ruby on Rails',
+ text: s__('ProjectTemplates|Ruby on Rails'),
icon: '.template-option .icon-rails',
},
express: {
- text: 'NodeJS Express',
+ text: s__('ProjectTemplates|NodeJS Express'),
icon: '.template-option .icon-express',
},
spring: {
- text: 'Spring',
+ text: s__('ProjectTemplates|Spring'),
icon: '.template-option .icon-spring',
},
+ iosswift: {
+ text: s__('ProjectTemplates|iOS (Swift)'),
+ icon: '.template-option svg.icon-gitlab',
+ },
dotnetcore: {
- text: '.NET Core',
+ text: s__('ProjectTemplates|.NET Core'),
icon: '.template-option .icon-dotnet',
},
+ android: {
+ text: s__('ProjectTemplates|Android'),
+ icon: '.template-option svg.icon-android',
+ },
+ gomicro: {
+ text: s__('ProjectTemplates|Go Micro'),
+ icon: '.template-option .icon-gomicro',
+ },
hugo: {
- text: 'Pages/Hugo',
+ text: s__('ProjectTemplates|Pages/Hugo'),
icon: '.template-option .icon-hugo',
},
jekyll: {
- text: 'Pages/Jekyll',
+ text: s__('ProjectTemplates|Pages/Jekyll'),
icon: '.template-option .icon-jekyll',
},
plainhtml: {
- text: 'Pages/Plain HTML',
+ text: s__('ProjectTemplates|Pages/Plain HTML'),
icon: '.template-option .icon-plainhtml',
},
gitbook: {
- text: 'Pages/GitBook',
+ text: s__('ProjectTemplates|Pages/GitBook'),
icon: '.template-option .icon-gitbook',
},
hexo: {
- text: 'Pages/Hexo',
+ text: s__('ProjectTemplates|Pages/Hexo'),
icon: '.template-option .icon-hexo',
},
nfhugo: {
- text: 'Netlify/Hugo',
+ text: s__('ProjectTemplates|Netlify/Hugo'),
icon: '.template-option .icon-netlify',
},
nfjekyll: {
- text: 'Netlify/Jekyll',
+ text: s__('ProjectTemplates|Netlify/Jekyll'),
icon: '.template-option .icon-netlify',
},
nfplainhtml: {
- text: 'Netlify/Plain HTML',
+ text: s__('ProjectTemplates|Netlify/Plain HTML'),
icon: '.template-option .icon-netlify',
},
nfgitbook: {
- text: 'Netlify/GitBook',
+ text: s__('ProjectTemplates|Netlify/GitBook'),
icon: '.template-option .icon-netlify',
},
nfhexo: {
- text: 'Netlify/Hexo',
+ text: s__('ProjectTemplates|Netlify/Hexo'),
icon: '.template-option .icon-netlify',
},
};
@@ -205,6 +218,12 @@ const bindEvents = () => {
$projectImportUrl.keyup(() => deriveProjectPathFromUrl($projectImportUrl));
+ $('.js-import-git-toggle-button').on('click', () => {
+ const $projectMirror = $('#project_mirror');
+
+ $projectMirror.attr('disabled', !$projectMirror.attr('disabled'));
+ });
+
$projectName.on('keyup change', () => {
onProjectNameChange($projectName, $projectPath);
hasUserDefinedProjectPath = $projectPath.val().trim().length > 0;
diff --git a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
index 40a873833e1..41e295387ae 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export default class ProtectedBranchAccessDropdown {
constructor(options) {
this.options = options;
@@ -15,7 +17,7 @@ export default class ProtectedBranchAccessDropdown {
if ($el.is('.is-active')) {
return item.text;
}
- return 'Select';
+ return __('Select');
},
clicked(options) {
options.e.preventDefault();
diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js
index 48343c8ba0a..16ecd5523d6 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_create.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_create.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown';
import CreateItemDropdown from '../create_item_dropdown';
import AccessorUtilities from '../lib/utils/accessor';
+import { __ } from '~/locale';
export default class ProtectedBranchCreate {
constructor() {
@@ -35,7 +36,7 @@ export default class ProtectedBranchCreate {
this.createItemDropdown = new CreateItemDropdown({
$dropdown: $protectedBranchDropdown,
- defaultToggleLabel: 'Protected Branch',
+ defaultToggleLabel: __('Protected Branch'),
fieldName: 'protected_branch[name]',
onSelect: this.onSelectCallback,
getData: ProtectedBranchCreate.getProtectedBranches,
diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js
index 5bc08f60d16..08d8c9919dd 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_edit.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js
@@ -1,6 +1,7 @@
import flash from '../flash';
import axios from '../lib/utils/axios_utils';
import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown';
+import { __ } from '~/locale';
export default class ProtectedBranchEdit {
constructor(options) {
@@ -68,7 +69,7 @@ export default class ProtectedBranchEdit {
this.$allowedToPushDropdown.enable();
flash(
- 'Failed to update branch!',
+ __('Failed to update branch!'),
'alert',
document.querySelector('.js-protected-branches-list'),
);
diff --git a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
index b803da798d5..def2f091947 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export default class ProtectedTagAccessDropdown {
constructor(options) {
this.options = options;
@@ -15,7 +17,7 @@ export default class ProtectedTagAccessDropdown {
if ($el.is('.is-active')) {
return item.text;
}
- return 'Select';
+ return __('Select');
},
clicked(options) {
options.e.preventDefault();
diff --git a/app/assets/javascripts/protected_tags/protected_tag_create.js b/app/assets/javascripts/protected_tags/protected_tag_create.js
index fddf2674cbb..03a5fe6b353 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_create.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_create.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
import CreateItemDropdown from '../create_item_dropdown';
+import { __ } from '~/locale';
export default class ProtectedTagCreate {
constructor() {
@@ -27,7 +28,7 @@ export default class ProtectedTagCreate {
// Protected tag dropdown
this.createItemDropdown = new CreateItemDropdown({
$dropdown: this.$form.find('.js-protected-tag-select'),
- defaultToggleLabel: 'Protected Tag',
+ defaultToggleLabel: __('Protected Tag'),
fieldName: 'protected_tag[name]',
onSelect: this.onSelectCallback,
getData: ProtectedTagCreate.getProtectedTags,
diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.js b/app/assets/javascripts/protected_tags/protected_tag_edit.js
index c52497e62f2..70bfd71abce 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_edit.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_edit.js
@@ -1,6 +1,7 @@
import flash from '../flash';
import axios from '../lib/utils/axios_utils';
import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
+import { __ } from '~/locale';
export default class ProtectedTagEdit {
constructor(options) {
@@ -47,7 +48,11 @@ export default class ProtectedTagEdit {
.catch(() => {
this.$allowedToCreateDropdownButton.enable();
- flash('Failed to update tag!', 'alert', document.querySelector('.js-protected-tags-list'));
+ flash(
+ __('Failed to update tag!'),
+ 'alert',
+ document.querySelector('.js-protected-tags-list'),
+ );
});
}
}
diff --git a/app/assets/javascripts/raven/index.js b/app/assets/javascripts/raven/index.js
index edc2293915f..4dd0175e528 100644
--- a/app/assets/javascripts/raven/index.js
+++ b/app/assets/javascripts/raven/index.js
@@ -4,8 +4,11 @@ const index = function index() {
RavenConfig.init({
sentryDsn: gon.sentry_dsn,
currentUserId: gon.current_user_id,
- whitelistUrls: [gon.gitlab_url],
- isProduction: process.env.NODE_ENV,
+ whitelistUrls:
+ process.env.NODE_ENV === 'production'
+ ? [gon.gitlab_url]
+ : [gon.gitlab_url, 'webpack-internal://'],
+ environment: gon.sentry_environment,
release: gon.revision,
tags: {
revision: gon.revision,
diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/raven/raven_config.js
index 338006ce2b9..7259e0df104 100644
--- a/app/assets/javascripts/raven/raven_config.js
+++ b/app/assets/javascripts/raven/raven_config.js
@@ -1,5 +1,6 @@
import Raven from 'raven-js';
import $ from 'jquery';
+import { __ } from '~/locale';
const IGNORE_ERRORS = [
// Random plugins/extensions
@@ -9,9 +10,9 @@ const IGNORE_ERRORS = [
'canvas.contentDocument',
'MyApp_RemoveAllHighlights',
'http://tt.epicplay.com',
- "Can't find variable: ZiteReader",
- 'jigsaw is not defined',
- 'ComboSearch is not defined',
+ __("Can't find variable: ZiteReader"),
+ __('jigsaw is not defined'),
+ __('ComboSearch is not defined'),
'http://loading.retry.widdit.com/',
'atomicFindClose',
// Facebook borked
@@ -61,7 +62,7 @@ const RavenConfig = {
release: this.options.release,
tags: this.options.tags,
whitelistUrls: this.options.whitelistUrls,
- environment: this.options.isProduction ? 'production' : 'development',
+ environment: this.options.environment,
ignoreErrors: this.IGNORE_ERRORS,
ignoreUrls: this.IGNORE_URLS,
shouldSendCallback: this.shouldSendSample.bind(this),
@@ -80,7 +81,7 @@ const RavenConfig = {
handleRavenErrors(event, req, config, err) {
const error = err || req.statusText;
- const responseText = req.responseText || 'Unknown response text';
+ const responseText = req.responseText || __('Unknown response text');
Raven.captureMessage(error, {
extra: {
diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/stores/mutations.js
index 1ac699c538f..8ace6657ad1 100644
--- a/app/assets/javascripts/registry/stores/mutations.js
+++ b/app/assets/javascripts/registry/stores/mutations.js
@@ -9,7 +9,7 @@ export default {
[types.SET_REPOS_LIST](state, list) {
Object.assign(state, {
repos: list.map(el => ({
- canDelete: !!el.destroy_path,
+ canDelete: Boolean(el.destroy_path),
destroyPath: el.destroy_path,
id: el.id,
isLoading: false,
@@ -42,7 +42,7 @@ export default {
location: element.location,
createdAt: element.created_at,
destroyPath: element.destroy_path,
- canDelete: !!element.destroy_path,
+ canDelete: Boolean(element.destroy_path),
}));
},
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
new file mode 100644
index 00000000000..6d908524da9
--- /dev/null
+++ b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
@@ -0,0 +1,118 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { sprintf, n__, s__ } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
+import { parseIssuableData } from '../../issue_show/utils/parse_data';
+
+export default {
+ name: 'RelatedMergeRequests',
+ components: {
+ Icon,
+ GlLoadingIcon,
+ RelatedIssuableItem,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ projectNamespace: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['isFetchingMergeRequests', 'mergeRequests', 'totalCount']),
+ closingMergeRequestsText() {
+ if (!this.hasClosingMergeRequest) {
+ return '';
+ }
+
+ const mrText = n__(
+ 'When this merge request is accepted',
+ 'When these merge requests are accepted',
+ this.totalCount,
+ );
+
+ return sprintf(s__('%{mrText}, this issue will be closed automatically.'), { mrText });
+ },
+ },
+ mounted() {
+ this.setInitialState({ apiEndpoint: this.endpoint });
+ this.fetchMergeRequests();
+ },
+ created() {
+ this.hasClosingMergeRequest = parseIssuableData().hasClosingMergeRequest;
+ },
+ methods: {
+ ...mapActions(['setInitialState', 'fetchMergeRequests']),
+ getAssignees(mr) {
+ if (mr.assignees) {
+ return mr.assignees;
+ }
+
+ return mr.assignee ? [mr.assignee] : [];
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)">
+ <div id="merge-requests" class="card-slim mt-3">
+ <div class="card-header">
+ <div class="card-title mt-0 mb-0 h5 merge-requests-title">
+ <span class="mr-1">
+ {{ __('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-count">
+ <svg class="s16 mr-1 text-secondary">
+ <icon name="merge-request" class="mr-1 text-secondary" />
+ </svg>
+ <span class="js-items-count">{{ totalCount }}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div v-if="isFetchingMergeRequests" class="qa-related-merge-requests-loading-icon">
+ <gl-loading-icon label="Fetching related merge requests" class="py-2" />
+ </div>
+ <ul v-else class="content-list related-items-list">
+ <li v-for="mr in mergeRequests" :key="mr.id" class="list-item pt-0 pb-0">
+ <related-issuable-item
+ :id-key="mr.id"
+ :display-reference="mr.reference"
+ :title="mr.title"
+ :milestone="mr.milestone"
+ :assignees="getAssignees(mr)"
+ :created-at="mr.created_at"
+ :closed-at="mr.closed_at"
+ :merged-at="mr.merged_at"
+ :path="mr.web_url"
+ :state="mr.state"
+ :is-merge-request="true"
+ :pipeline-status="mr.head_pipeline && mr.head_pipeline.detailed_status"
+ path-id-separator="!"
+ />
+ </li>
+ </ul>
+ </div>
+ </div>
+ <div
+ v-if="hasClosingMergeRequest && !isFetchingMergeRequests"
+ class="issue-closed-by-widget second-block"
+ >
+ {{ closingMergeRequestsText }}
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/related_merge_requests/index.js b/app/assets/javascripts/related_merge_requests/index.js
new file mode 100644
index 00000000000..092ff1df00f
--- /dev/null
+++ b/app/assets/javascripts/related_merge_requests/index.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import RelatedMergeRequests from './components/related_merge_requests.vue';
+import createStore from './store';
+
+export default function initRelatedMergeRequests() {
+ const relatedMergeRequestsElement = document.querySelector('#js-related-merge-requests');
+
+ if (relatedMergeRequestsElement) {
+ const { endpoint, projectPath, projectNamespace } = relatedMergeRequestsElement.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: relatedMergeRequestsElement,
+ components: {
+ RelatedMergeRequests,
+ },
+ store: createStore(),
+ render: createElement =>
+ createElement('related-merge-requests', {
+ props: { endpoint, projectNamespace, projectPath },
+ }),
+ });
+ }
+}
diff --git a/app/assets/javascripts/related_merge_requests/store/actions.js b/app/assets/javascripts/related_merge_requests/store/actions.js
new file mode 100644
index 00000000000..69abeaaf7db
--- /dev/null
+++ b/app/assets/javascripts/related_merge_requests/store/actions.js
@@ -0,0 +1,37 @@
+import axios from '~/lib/utils/axios_utils';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import { normalizeHeaders } from '~/lib/utils/common_utils';
+import * as types from './mutation_types';
+
+const REQUEST_PAGE_COUNT = 100;
+
+export const setInitialState = ({ commit }, props) => {
+ commit(types.SET_INITIAL_STATE, props);
+};
+
+export const requestData = ({ commit }) => commit(types.REQUEST_DATA);
+
+export const receiveDataSuccess = ({ commit }, data) => commit(types.RECEIVE_DATA_SUCCESS, data);
+
+export const receiveDataError = ({ commit }) => commit(types.RECEIVE_DATA_ERROR);
+
+export const fetchMergeRequests = ({ state, dispatch }) => {
+ dispatch('requestData');
+
+ return axios
+ .get(`${state.apiEndpoint}?per_page=${REQUEST_PAGE_COUNT}`)
+ .then(res => {
+ const { headers, data } = res;
+ const total = Number(normalizeHeaders(headers)['X-TOTAL']) || 0;
+
+ dispatch('receiveDataSuccess', { data, total });
+ })
+ .catch(() => {
+ dispatch('receiveDataError');
+ createFlash(s__('Something went wrong while fetching related merge requests.'));
+ });
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/related_merge_requests/store/index.js b/app/assets/javascripts/related_merge_requests/store/index.js
new file mode 100644
index 00000000000..dcb70c22bcb
--- /dev/null
+++ b/app/assets/javascripts/related_merge_requests/store/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import createState from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+export default () =>
+ new Vuex.Store({
+ state: createState(),
+ actions,
+ mutations,
+ });
diff --git a/app/assets/javascripts/related_merge_requests/store/mutation_types.js b/app/assets/javascripts/related_merge_requests/store/mutation_types.js
new file mode 100644
index 00000000000..31d4fe032e1
--- /dev/null
+++ b/app/assets/javascripts/related_merge_requests/store/mutation_types.js
@@ -0,0 +1,4 @@
+export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
+export const REQUEST_DATA = 'REQUEST_DATA';
+export const RECEIVE_DATA_SUCCESS = 'RECEIVE_DATA_SUCCESS';
+export const RECEIVE_DATA_ERROR = 'RECEIVE_DATA_ERROR';
diff --git a/app/assets/javascripts/related_merge_requests/store/mutations.js b/app/assets/javascripts/related_merge_requests/store/mutations.js
new file mode 100644
index 00000000000..11ca28a5fb9
--- /dev/null
+++ b/app/assets/javascripts/related_merge_requests/store/mutations.js
@@ -0,0 +1,19 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_INITIAL_STATE](state, { apiEndpoint }) {
+ state.apiEndpoint = apiEndpoint;
+ },
+ [types.REQUEST_DATA](state) {
+ state.isFetchingMergeRequests = true;
+ },
+ [types.RECEIVE_DATA_SUCCESS](state, { data, total }) {
+ state.isFetchingMergeRequests = false;
+ state.mergeRequests = data;
+ state.totalCount = total;
+ },
+ [types.RECEIVE_DATA_ERROR](state) {
+ state.isFetchingMergeRequests = false;
+ state.hasErrorFetchingMergeRequests = true;
+ },
+};
diff --git a/app/assets/javascripts/related_merge_requests/store/state.js b/app/assets/javascripts/related_merge_requests/store/state.js
new file mode 100644
index 00000000000..bc3468a025b
--- /dev/null
+++ b/app/assets/javascripts/related_merge_requests/store/state.js
@@ -0,0 +1,7 @@
+export default () => ({
+ apiEndpoint: '',
+ isFetchingMergeRequests: false,
+ hasErrorFetchingMergeRequests: false,
+ mergeRequests: [],
+ totalCount: 0,
+});
diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue
index 7ed1b407ddd..0958b9fa926 100644
--- a/app/assets/javascripts/releases/components/release_block.vue
+++ b/app/assets/javascripts/releases/components/release_block.vue
@@ -86,7 +86,7 @@ export default {
</div>
<div
- v-if="assets.links.length || assets.sources.length"
+ v-if="assets.links.length || (assets.sources && assets.sources.length)"
class="card-text prepend-top-default"
>
<b>
@@ -103,7 +103,7 @@ export default {
</li>
</ul>
- <div v-if="assets.sources.length" class="dropdown">
+ <div v-if="assets.sources && assets.sources.length" class="dropdown">
<button
type="button"
class="btn btn-link"
diff --git a/app/assets/javascripts/releases/store/actions.js b/app/assets/javascripts/releases/store/actions.js
index b5c4d54ac33..e0a922d5ef6 100644
--- a/app/assets/javascripts/releases/store/actions.js
+++ b/app/assets/javascripts/releases/store/actions.js
@@ -30,7 +30,7 @@ export const receiveReleasesSuccess = ({ commit }, data) =>
export const receiveReleasesError = ({ commit }) => {
commit(types.RECEIVE_RELEASES_ERROR);
- createFlash(__('An error occured while fetching the releases. Please try again.'));
+ createFlash(__('An error occurred while fetching the releases. Please try again.'));
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
diff --git a/app/assets/javascripts/reports/components/issue_status_icon.vue b/app/assets/javascripts/reports/components/issue_status_icon.vue
index 2946fbc6a1f..04fba43b2f3 100644
--- a/app/assets/javascripts/reports/components/issue_status_icon.vue
+++ b/app/assets/javascripts/reports/components/issue_status_icon.vue
@@ -13,6 +13,11 @@ export default {
type: String,
required: true,
},
+ statusIconSize: {
+ type: Number,
+ required: false,
+ default: 32,
+ },
},
computed: {
iconName() {
@@ -45,6 +50,6 @@ export default {
}"
class="report-block-list-icon"
>
- <icon :name="iconName" :size="32" />
+ <icon :name="iconName" :size="statusIconSize" />
</div>
</template>
diff --git a/app/assets/javascripts/reports/components/issues_list.vue b/app/assets/javascripts/reports/components/issues_list.vue
index f4243522ef8..ee07efea3b0 100644
--- a/app/assets/javascripts/reports/components/issues_list.vue
+++ b/app/assets/javascripts/reports/components/issues_list.vue
@@ -52,6 +52,21 @@ export default {
required: false,
default: '',
},
+ showReportSectionStatusIcon: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ issuesUlElementClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ issueItemClass: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
computed: {
issuesWithState() {
@@ -62,6 +77,9 @@ export default {
...this.resolvedIssues.map(wrapIssueWithState(STATUS_SUCCESS)),
];
},
+ wclass() {
+ return `report-block-list ${this.issuesUlElementClass}`;
+ },
},
};
</script>
@@ -72,7 +90,7 @@ export default {
:size="$options.typicalReportItemHeight"
class="report-block-container"
wtag="ul"
- wclass="report-block-list"
+ :wclass="wclass"
>
<report-item
v-for="(wrapped, index) in issuesWithState"
@@ -81,6 +99,8 @@ export default {
:status="wrapped.status"
:component="component"
:is-new="wrapped.isNew"
+ :show-report-section-status-icon="showReportSectionStatusIcon"
+ :class="issueItemClass"
/>
</smart-virtual-list>
</template>
diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/reports/components/report_item.vue
index 839e86bdf17..01a30809e1a 100644
--- a/app/assets/javascripts/reports/components/report_item.vue
+++ b/app/assets/javascripts/reports/components/report_item.vue
@@ -24,17 +24,32 @@ export default {
type: String,
required: true,
},
+ statusIconSize: {
+ type: Number,
+ required: false,
+ default: 32,
+ },
isNew: {
type: Boolean,
required: false,
default: false,
},
+ showReportSectionStatusIcon: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
};
</script>
<template>
<li :class="{ 'is-dismissed': issue.isDismissed }" class="report-block-list-issue">
- <issue-status-icon :status="status" class="append-right-5" />
+ <issue-status-icon
+ v-if="showReportSectionStatusIcon"
+ :status="status"
+ :status-icon-size="statusIconSize"
+ class="append-right-5"
+ />
<component :is="component" v-if="component" :issue="issue" :status="status" :is-new="isNew" />
</li>
diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue
index d6483e95278..3d576caaf8f 100644
--- a/app/assets/javascripts/reports/components/report_section.vue
+++ b/app/assets/javascripts/reports/components/report_section.vue
@@ -3,10 +3,7 @@ import { __ } from '~/locale';
import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue';
import Popover from '~/vue_shared/components/help_popover.vue';
import IssuesList from './issues_list.vue';
-
-const LOADING = 'LOADING';
-const ERROR = 'ERROR';
-const SUCCESS = 'SUCCESS';
+import { status } from '../constants';
export default {
name: 'ReportSection',
@@ -42,7 +39,8 @@ export default {
},
successText: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
unresolvedIssues: {
type: Array,
@@ -73,6 +71,26 @@ export default {
default: () => ({}),
required: false,
},
+ showReportSectionStatusIcon: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ issuesUlElementClass: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ issuesListContainerClass: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ issueItemClass: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
},
data() {
@@ -86,13 +104,13 @@ export default {
return this.isCollapsed ? __('Expand') : __('Collapse');
},
isLoading() {
- return this.status === LOADING;
+ return this.status === status.LOADING;
},
loadingFailed() {
- return this.status === ERROR;
+ return this.status === status.ERROR;
},
isSuccess() {
- return this.status === SUCCESS;
+ return this.status === status.SUCCESS;
},
isCollapsible() {
return !this.alwaysOpen && this.hasIssues;
@@ -127,6 +145,15 @@ export default {
hasPopover() {
return Object.keys(this.popoverOptions).length > 0;
},
+ slotName() {
+ if (this.isSuccess) {
+ return 'success';
+ } else if (this.isLoading) {
+ return 'loading';
+ }
+
+ return 'error';
+ },
},
methods: {
toggleCollapsed() {
@@ -142,6 +169,7 @@ export default {
<div class="media-body d-flex flex-align-self-center">
<span class="js-code-text code-text">
{{ headerText }}
+ <slot :name="slotName"></slot>
<popover v-if="hasPopover" :options="popoverOptions" class="prepend-left-5" />
</span>
@@ -151,7 +179,7 @@ export default {
<button
v-if="isCollapsible"
type="button"
- class="js-collapse-btn btn float-right btn-sm"
+ class="js-collapse-btn btn float-right btn-sm qa-expand-report-button"
@click="toggleCollapsed"
>
{{ collapseText }}
@@ -166,6 +194,10 @@ export default {
:resolved-issues="resolvedIssues"
:neutral-issues="neutralIssues"
:component="component"
+ :show-report-section-status-icon="showReportSectionStatusIcon"
+ :issues-ul-element-class="issuesUlElementClass"
+ :class="issuesListContainerClass"
+ :issue-item-class="issueItemClass"
/>
</slot>
</div>
diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js
index c323dc543f3..66ac1af062b 100644
--- a/app/assets/javascripts/reports/constants.js
+++ b/app/assets/javascripts/reports/constants.js
@@ -16,3 +16,9 @@ export const STATUS_NEUTRAL = 'neutral';
export const ICON_WARNING = 'warning';
export const ICON_SUCCESS = 'success';
export const ICON_NOTFOUND = 'notfound';
+
+export const status = {
+ LOADING: 'LOADING',
+ ERROR: 'ERROR',
+ SUCCESS: 'SUCCESS',
+};
diff --git a/app/assets/javascripts/reports/store/state.js b/app/assets/javascripts/reports/store/state.js
index 5484900276c..25f9f70d095 100644
--- a/app/assets/javascripts/reports/store/state.js
+++ b/app/assets/javascripts/reports/store/state.js
@@ -40,6 +40,11 @@ export default () => ({
text: s__('Reports|Class'),
type: fieldTypes.link,
},
+ classname: {
+ value: null,
+ text: s__('Reports|Classname'),
+ type: fieldTypes.text,
+ },
execution_time: {
value: null,
text: s__('Reports|Execution time'),
diff --git a/app/assets/javascripts/reports/store/utils.js b/app/assets/javascripts/reports/store/utils.js
index 35632218269..10560d0ae8e 100644
--- a/app/assets/javascripts/reports/store/utils.js
+++ b/app/assets/javascripts/reports/store/utils.js
@@ -1,4 +1,4 @@
-import { sprintf, n__, s__ } from '~/locale';
+import { sprintf, n__, s__, __ } from '~/locale';
import {
STATUS_FAILED,
STATUS_SUCCESS,
@@ -38,12 +38,12 @@ const textBuilder = results => {
export const summaryTextBuilder = (name = '', results = {}) => {
const resultsString = textBuilder(results);
- return `${name} contained ${resultsString}`;
+ return sprintf(__('%{name} contained %{resultsString}'), { name, resultsString });
};
export const reportTextBuilder = (name = '', results = {}) => {
const resultsString = textBuilder(results);
- return `${name} found ${resultsString}`;
+ return sprintf(__('%{name} found %{resultsString}'), { name, resultsString });
};
export const statusIcon = status => {
diff --git a/app/assets/javascripts/repository/components/app.vue b/app/assets/javascripts/repository/components/app.vue
new file mode 100644
index 00000000000..98240aef810
--- /dev/null
+++ b/app/assets/javascripts/repository/components/app.vue
@@ -0,0 +1,3 @@
+<template>
+ <router-view />
+</template>
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
new file mode 100644
index 00000000000..6eca015036f
--- /dev/null
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -0,0 +1,61 @@
+<script>
+import getRefMixin from '../mixins/get_ref';
+import getProjectShortPath from '../queries/getProjectShortPath.graphql';
+
+export default {
+ apollo: {
+ projectShortPath: {
+ query: getProjectShortPath,
+ },
+ },
+ mixins: [getRefMixin],
+ props: {
+ currentPath: {
+ type: String,
+ required: false,
+ default: '/',
+ },
+ },
+ data() {
+ return {
+ projectShortPath: '',
+ };
+ },
+ computed: {
+ pathLinks() {
+ return this.currentPath
+ .split('/')
+ .filter(p => p !== '')
+ .reduce(
+ (acc, name, i) => {
+ const path = `${i > 0 ? acc[i].path : ''}/${name}`;
+
+ return acc.concat({
+ name,
+ path,
+ to: `/tree/${this.ref}${path}`,
+ });
+ },
+ [{ name: this.projectShortPath, path: '/', to: `/tree/${this.ref}` }],
+ );
+ },
+ },
+ methods: {
+ isLast(i) {
+ return i === this.pathLinks.length - 1;
+ },
+ },
+};
+</script>
+
+<template>
+ <nav :aria-label="__('Files breadcrumb')">
+ <ol class="breadcrumb repo-breadcrumb">
+ <li v-for="(link, i) in pathLinks" :key="i" class="breadcrumb-item">
+ <router-link :to="link.to" :aria-current="isLast(i) ? 'page' : null">
+ {{ link.name }}
+ </router-link>
+ </li>
+ </ol>
+ </nav>
+</template>
diff --git a/app/assets/javascripts/repository/components/table/header.vue b/app/assets/javascripts/repository/components/table/header.vue
new file mode 100644
index 00000000000..9d30aa88155
--- /dev/null
+++ b/app/assets/javascripts/repository/components/table/header.vue
@@ -0,0 +1,9 @@
+<template>
+ <thead>
+ <tr>
+ <th id="name" scope="col">{{ s__('ProjectFileTree|Name') }}</th>
+ <th id="last-commit" scope="col" class="d-none d-sm-table-cell">{{ __('Last commit') }}</th>
+ <th id="last-update" scope="col" class="text-right">{{ __('Last update') }}</th>
+ </tr>
+ </thead>
+</template>
diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue
new file mode 100644
index 00000000000..d2198bcccfe
--- /dev/null
+++ b/app/assets/javascripts/repository/components/table/index.vue
@@ -0,0 +1,145 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { sprintf, __ } from '../../../locale';
+import getRefMixin from '../../mixins/get_ref';
+import getFiles from '../../queries/getFiles.graphql';
+import getProjectPath from '../../queries/getProjectPath.graphql';
+import TableHeader from './header.vue';
+import TableRow from './row.vue';
+import ParentRow from './parent_row.vue';
+
+const PAGE_SIZE = 100;
+
+export default {
+ components: {
+ GlLoadingIcon,
+ TableHeader,
+ TableRow,
+ ParentRow,
+ },
+ mixins: [getRefMixin],
+ apollo: {
+ projectPath: {
+ query: getProjectPath,
+ },
+ },
+ props: {
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ projectPath: '',
+ nextPageCursor: '',
+ entries: {
+ trees: [],
+ submodules: [],
+ blobs: [],
+ },
+ isLoadingFiles: false,
+ };
+ },
+ computed: {
+ tableCaption() {
+ return sprintf(
+ __('Files, directories, and submodules in the path %{path} for commit reference %{ref}'),
+ { path: this.path, ref: this.ref },
+ );
+ },
+ showParentRow() {
+ return !this.isLoadingFiles && ['', '/'].indexOf(this.path) === -1;
+ },
+ },
+ watch: {
+ $route: function routeChange() {
+ this.entries.trees = [];
+ this.entries.submodules = [];
+ this.entries.blobs = [];
+ this.nextPageCursor = '';
+ this.fetchFiles();
+ },
+ },
+ mounted() {
+ // We need to wait for `ref` and `projectPath` to be set
+ this.$nextTick(() => this.fetchFiles());
+ },
+ methods: {
+ fetchFiles() {
+ this.isLoadingFiles = true;
+
+ return this.$apollo
+ .query({
+ query: getFiles,
+ variables: {
+ projectPath: this.projectPath,
+ ref: this.ref,
+ path: this.path,
+ nextPageCursor: this.nextPageCursor,
+ pageSize: PAGE_SIZE,
+ },
+ })
+ .then(({ data }) => {
+ if (!data) return;
+
+ const pageInfo = this.hasNextPage(data.project.repository.tree);
+
+ this.isLoadingFiles = false;
+ this.entries = Object.keys(this.entries).reduce(
+ (acc, key) => ({
+ ...acc,
+ [key]: this.normalizeData(key, data.project.repository.tree[key].edges),
+ }),
+ {},
+ );
+
+ if (pageInfo && pageInfo.hasNextPage) {
+ this.nextPageCursor = pageInfo.endCursor;
+ this.fetchFiles();
+ }
+ })
+ .catch(() => createFlash(__('An error occurred while fetching folder content.')));
+ },
+ normalizeData(key, data) {
+ return this.entries[key].concat(data.map(({ node }) => node));
+ },
+ hasNextPage(data) {
+ return []
+ .concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo)
+ .find(({ hasNextPage }) => hasNextPage);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="tree-content-holder">
+ <div class="table-holder bordered-box">
+ <table class="table tree-table qa-file-tree" aria-live="polite">
+ <caption class="sr-only">
+ {{
+ tableCaption
+ }}
+ </caption>
+ <table-header v-once />
+ <tbody>
+ <parent-row v-show="showParentRow" :commit-ref="ref" :path="path" />
+ <template v-for="val in entries">
+ <table-row
+ v-for="entry in val"
+ :id="entry.id"
+ :key="`${entry.flatPath}-${entry.id}`"
+ :current-path="path"
+ :path="entry.flatPath"
+ :type="entry.type"
+ :url="entry.webUrl"
+ />
+ </template>
+ </tbody>
+ </table>
+ <gl-loading-icon v-show="isLoadingFiles" class="my-3" size="md" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue
new file mode 100644
index 00000000000..3c39f404226
--- /dev/null
+++ b/app/assets/javascripts/repository/components/table/parent_row.vue
@@ -0,0 +1,37 @@
+<script>
+export default {
+ props: {
+ commitRef: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ parentRoute() {
+ const splitArray = this.path.split('/');
+ splitArray.pop();
+
+ return { path: `/tree/${this.commitRef}/${splitArray.join('/')}` };
+ },
+ },
+ methods: {
+ clickRow() {
+ this.$router.push(this.parentRoute);
+ },
+ },
+};
+</script>
+
+<template>
+ <tr class="tree-item">
+ <td colspan="3" class="tree-item-file-name" @click.self="clickRow">
+ <router-link :to="parentRoute" :aria-label="__('Go to parent')">
+ ..
+ </router-link>
+ </td>
+ </tr>
+</template>
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
new file mode 100644
index 00000000000..764882a7936
--- /dev/null
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -0,0 +1,77 @@
+<script>
+import { getIconName } from '../../utils/icon';
+import getRefMixin from '../../mixins/get_ref';
+
+export default {
+ mixins: [getRefMixin],
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ currentPath: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ type: {
+ type: String,
+ required: true,
+ },
+ url: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ routerLinkTo() {
+ return this.isFolder ? { path: `/tree/${this.ref}/${this.path}` } : null;
+ },
+ iconName() {
+ return `fa-${getIconName(this.type, this.path)}`;
+ },
+ isFolder() {
+ return this.type === 'tree';
+ },
+ isSubmodule() {
+ return this.type === 'commit';
+ },
+ linkComponent() {
+ return this.isFolder ? 'router-link' : 'a';
+ },
+ fullPath() {
+ return this.path.replace(new RegExp(`^${this.currentPath}/`), '');
+ },
+ shortSha() {
+ return this.id.slice(0, 8);
+ },
+ },
+ methods: {
+ openRow() {
+ if (this.isFolder) {
+ this.$router.push(this.routerLinkTo);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <tr v-once :class="`file_${id}`" class="tree-item" @click="openRow">
+ <td class="tree-item-file-name">
+ <i :aria-label="type" role="img" :class="iconName" class="fa fa-fw"></i>
+ <component :is="linkComponent" :to="routerLinkTo" :href="url" class="str-truncated">
+ {{ fullPath }}
+ </component>
+ <template v-if="isSubmodule">
+ @ <a href="#" class="commit-sha">{{ shortSha }}</a>
+ </template>
+ </td>
+ <td class="d-none d-sm-table-cell tree-commit"></td>
+ <td class="tree-time-ago text-right"></td>
+ </tr>
+</template>
diff --git a/app/assets/javascripts/repository/fragmentTypes.json b/app/assets/javascripts/repository/fragmentTypes.json
new file mode 100644
index 00000000000..949ebca432b
--- /dev/null
+++ b/app/assets/javascripts/repository/fragmentTypes.json
@@ -0,0 +1 @@
+{"__schema":{"types":[{"kind":"INTERFACE","name":"Entry","possibleTypes":[{"name":"Blob"},{"name":"Submodule"},{"name":"TreeEntry"}]}]}}
diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js
new file mode 100644
index 00000000000..c64d16ef02a
--- /dev/null
+++ b/app/assets/javascripts/repository/graphql.js
@@ -0,0 +1,43 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
+import createDefaultClient from '~/lib/graphql';
+import introspectionQueryResultData from './fragmentTypes.json';
+
+Vue.use(VueApollo);
+
+// We create a fragment matcher so that we can create a fragment from an interface
+// Without this, Apollo throws a heuristic fragment matcher warning
+const fragmentMatcher = new IntrospectionFragmentMatcher({
+ introspectionQueryResultData,
+});
+
+const defaultClient = createDefaultClient(
+ {},
+ {
+ cacheConfig: {
+ fragmentMatcher,
+ dataIdFromObject: obj => {
+ // eslint-disable-next-line no-underscore-dangle
+ switch (obj.__typename) {
+ // We need to create a dynamic ID for each entry
+ // Each entry can have the same ID as the ID is a commit ID
+ // So we create a unique cache ID with the path and the ID
+ case 'TreeEntry':
+ case 'Submodule':
+ case 'Blob':
+ return `${obj.flatPath}-${obj.id}`;
+ default:
+ // If the type doesn't match any of the above we fallback
+ // to using the default Apollo ID
+ // eslint-disable-next-line no-underscore-dangle
+ return obj.id || obj._id;
+ }
+ },
+ },
+ },
+);
+
+export default new VueApollo({
+ defaultClient,
+});
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
new file mode 100644
index 00000000000..52f53be045b
--- /dev/null
+++ b/app/assets/javascripts/repository/index.js
@@ -0,0 +1,59 @@
+import Vue from 'vue';
+import createRouter from './router';
+import App from './components/app.vue';
+import Breadcrumbs from './components/breadcrumbs.vue';
+import apolloProvider from './graphql';
+import { setTitle } from './utils/title';
+
+export default function setupVueRepositoryList() {
+ const el = document.getElementById('js-tree-list');
+ const { projectPath, projectShortPath, ref, fullName } = el.dataset;
+ const router = createRouter(projectPath, ref);
+
+ apolloProvider.clients.defaultClient.cache.writeData({
+ data: {
+ projectPath,
+ projectShortPath,
+ ref,
+ },
+ });
+
+ router.afterEach(({ params: { pathMatch } }) => {
+ const isRoot = pathMatch === undefined || pathMatch === '/';
+
+ setTitle(pathMatch, ref, fullName);
+
+ if (!isRoot) {
+ document
+ .querySelectorAll('.js-keep-hidden-on-navigation')
+ .forEach(elem => elem.classList.add('hidden'));
+ }
+
+ document
+ .querySelectorAll('.js-hide-on-navigation')
+ .forEach(elem => elem.classList.toggle('hidden', !isRoot));
+ });
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: document.getElementById('js-repo-breadcrumb'),
+ router,
+ apolloProvider,
+ render(h) {
+ return h(Breadcrumbs, {
+ props: {
+ currentPath: this.$route.params.pathMatch,
+ },
+ });
+ },
+ });
+
+ return new Vue({
+ el,
+ router,
+ apolloProvider,
+ render(h) {
+ return h(App);
+ },
+ });
+}
diff --git a/app/assets/javascripts/repository/mixins/get_ref.js b/app/assets/javascripts/repository/mixins/get_ref.js
new file mode 100644
index 00000000000..b06087d6f42
--- /dev/null
+++ b/app/assets/javascripts/repository/mixins/get_ref.js
@@ -0,0 +1,14 @@
+import getRef from '../queries/getRef.graphql';
+
+export default {
+ apollo: {
+ ref: {
+ query: getRef,
+ },
+ },
+ data() {
+ return {
+ ref: '',
+ };
+ },
+};
diff --git a/app/assets/javascripts/repository/pages/index.vue b/app/assets/javascripts/repository/pages/index.vue
new file mode 100644
index 00000000000..2d92e9174ca
--- /dev/null
+++ b/app/assets/javascripts/repository/pages/index.vue
@@ -0,0 +1,18 @@
+<script>
+import FileTable from '../components/table/index.vue';
+
+export default {
+ components: {
+ FileTable,
+ },
+ data() {
+ return {
+ ref: '',
+ };
+ },
+};
+</script>
+
+<template>
+ <file-table path="/" />
+</template>
diff --git a/app/assets/javascripts/repository/pages/tree.vue b/app/assets/javascripts/repository/pages/tree.vue
new file mode 100644
index 00000000000..3b898d1aa91
--- /dev/null
+++ b/app/assets/javascripts/repository/pages/tree.vue
@@ -0,0 +1,20 @@
+<script>
+import FileTable from '../components/table/index.vue';
+
+export default {
+ components: {
+ FileTable,
+ },
+ props: {
+ path: {
+ type: String,
+ required: false,
+ default: '/',
+ },
+ },
+};
+</script>
+
+<template>
+ <file-table :path="path" />
+</template>
diff --git a/app/assets/javascripts/repository/queries/getFiles.graphql b/app/assets/javascripts/repository/queries/getFiles.graphql
new file mode 100644
index 00000000000..7d92bc46455
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/getFiles.graphql
@@ -0,0 +1,57 @@
+fragment TreeEntry on Entry {
+ id
+ flatPath
+ type
+}
+
+fragment PageInfo on PageInfo {
+ hasNextPage
+ endCursor
+}
+
+query getFiles(
+ $projectPath: ID!
+ $path: String
+ $ref: String!
+ $pageSize: Int!
+ $nextPageCursor: String
+) {
+ project(fullPath: $projectPath) {
+ repository {
+ tree(path: $path, ref: $ref) {
+ trees(first: $pageSize, after: $nextPageCursor) {
+ edges {
+ node {
+ ...TreeEntry
+ webUrl
+ }
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ submodules(first: $pageSize, after: $nextPageCursor) {
+ edges {
+ node {
+ ...TreeEntry
+ }
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ blobs(first: $pageSize, after: $nextPageCursor) {
+ edges {
+ node {
+ ...TreeEntry
+ webUrl
+ }
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/repository/queries/getProjectPath.graphql b/app/assets/javascripts/repository/queries/getProjectPath.graphql
new file mode 100644
index 00000000000..74e73e07577
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/getProjectPath.graphql
@@ -0,0 +1,3 @@
+query getProjectPath {
+ projectPath
+}
diff --git a/app/assets/javascripts/repository/queries/getProjectShortPath.graphql b/app/assets/javascripts/repository/queries/getProjectShortPath.graphql
new file mode 100644
index 00000000000..34eb26598c2
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/getProjectShortPath.graphql
@@ -0,0 +1,3 @@
+query getProjectShortPath {
+ projectShortPath @client
+}
diff --git a/app/assets/javascripts/repository/queries/getRef.graphql b/app/assets/javascripts/repository/queries/getRef.graphql
new file mode 100644
index 00000000000..58c09844c3f
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/getRef.graphql
@@ -0,0 +1,3 @@
+query getRef {
+ ref @client
+}
diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js
new file mode 100644
index 00000000000..9322c81ab97
--- /dev/null
+++ b/app/assets/javascripts/repository/router.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import { joinPaths } from '../lib/utils/url_utility';
+import IndexPage from './pages/index.vue';
+import TreePage from './pages/tree.vue';
+
+Vue.use(VueRouter);
+
+export default function createRouter(base, baseRef) {
+ return new VueRouter({
+ mode: 'history',
+ base: joinPaths(gon.relative_url_root || '', base),
+ routes: [
+ {
+ path: `/tree/${baseRef}(/.*)?`,
+ name: 'treePath',
+ component: TreePage,
+ props: route => ({
+ path: route.params.pathMatch && route.params.pathMatch.replace(/^\//, ''),
+ }),
+ },
+ {
+ path: '/',
+ name: 'projectRoot',
+ component: IndexPage,
+ },
+ ],
+ });
+}
diff --git a/app/assets/javascripts/repository/utils/icon.js b/app/assets/javascripts/repository/utils/icon.js
new file mode 100644
index 00000000000..661ebb6edfc
--- /dev/null
+++ b/app/assets/javascripts/repository/utils/icon.js
@@ -0,0 +1,99 @@
+const entryTypeIcons = {
+ tree: 'folder',
+ commit: 'archive',
+};
+
+const fileTypeIcons = [
+ { extensions: ['pdf'], name: 'file-pdf-o' },
+ {
+ extensions: [
+ 'jpg',
+ 'jpeg',
+ 'jif',
+ 'jfif',
+ 'jp2',
+ 'jpx',
+ 'j2k',
+ 'j2c',
+ 'png',
+ 'gif',
+ 'tif',
+ 'tiff',
+ 'svg',
+ 'ico',
+ 'bmp',
+ ],
+ name: 'file-image-o',
+ },
+ {
+ extensions: ['zip', 'zipx', 'tar', 'gz', 'bz', 'bzip', 'xz', 'rar', '7z'],
+ name: 'file-archive-o',
+ },
+ { extensions: ['mp3', 'wma', 'ogg', 'oga', 'wav', 'flac', 'aac'], name: 'file-audio-o' },
+ {
+ extensions: [
+ 'mp4',
+ 'm4p',
+ 'm4v',
+ 'mpg',
+ 'mp2',
+ 'mpeg',
+ 'mpe',
+ 'mpv',
+ 'm2v',
+ 'avi',
+ 'mkv',
+ 'flv',
+ 'ogv',
+ 'mov',
+ '3gp',
+ '3g2',
+ ],
+ name: 'file-video-o',
+ },
+ { extensions: ['doc', 'dot', 'docx', 'docm', 'dotx', 'dotm', 'docb'], name: 'file-word-o' },
+ {
+ extensions: [
+ 'xls',
+ 'xlt',
+ 'xlm',
+ 'xlsx',
+ 'xlsm',
+ 'xltx',
+ 'xltm',
+ 'xlsb',
+ 'xla',
+ 'xlam',
+ 'xll',
+ 'xlw',
+ ],
+ name: 'file-excel-o',
+ },
+ {
+ extensions: [
+ 'ppt',
+ 'pot',
+ 'pps',
+ 'pptx',
+ 'pptm',
+ 'potx',
+ 'potm',
+ 'ppam',
+ 'ppsx',
+ 'ppsm',
+ 'sldx',
+ 'sldm',
+ ],
+ name: 'file-powerpoint-o',
+ },
+];
+
+// eslint-disable-next-line import/prefer-default-export
+export const getIconName = (type, path) => {
+ if (entryTypeIcons[type]) return entryTypeIcons[type];
+
+ const extension = path.split('.').pop();
+ const file = fileTypeIcons.find(t => t.extensions.some(ext => ext === extension));
+
+ return file ? file.name : 'file-text-o';
+};
diff --git a/app/assets/javascripts/repository/utils/title.js b/app/assets/javascripts/repository/utils/title.js
new file mode 100644
index 00000000000..4e194640e92
--- /dev/null
+++ b/app/assets/javascripts/repository/utils/title.js
@@ -0,0 +1,9 @@
+// eslint-disable-next-line import/prefer-default-export
+export const setTitle = (pathMatch, ref, project) => {
+ if (!pathMatch) return;
+
+ const path = pathMatch.replace(/^\//, '');
+ const isEmpty = path === '';
+
+ document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project}`;
+};
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 9a0cdc02952..930c0d5e958 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -5,7 +5,7 @@ import _ from 'underscore';
import Cookies from 'js-cookie';
import flash from './flash';
import axios from './lib/utils/axios_utils';
-import { __ } from './locale';
+import { sprintf, s__, __ } from './locale';
function Sidebar(currentUser) {
this.toggleTodo = this.toggleTodo.bind(this);
@@ -82,9 +82,9 @@ Sidebar.prototype.toggleTodo = function(e) {
ajaxType = $this.data('deletePath') ? 'delete' : 'post';
if ($this.data('deletePath')) {
- url = '' + $this.data('deletePath');
+ url = String($this.data('deletePath'));
} else {
- url = '' + $this.data('createPath');
+ url = String($this.data('createPath'));
}
$this.tooltip('hide');
@@ -101,7 +101,10 @@ Sidebar.prototype.toggleTodo = function(e) {
this.todoUpdateDone(data);
})
.catch(() =>
- flash(`There was an error ${ajaxType === 'post' ? 'adding a' : 'deleting the'} todo.`),
+ flash(sprintf(__('There was an error %{message} todo.')), {
+ message:
+ ajaxType === 'post' ? s__('RightSidebar|adding a') : s__('RightSidebar|deleting the'),
+ }),
);
};
diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
index 0a4583b5861..6aca4067ba7 100644
--- a/app/assets/javascripts/search_autocomplete.js
+++ b/app/assets/javascripts/search_autocomplete.js
@@ -2,7 +2,7 @@
import $ from 'jquery';
import { escape, throttle } from 'underscore';
-import { s__, sprintf } from '~/locale';
+import { s__, __, sprintf } from '~/locale';
import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper';
import axios from './lib/utils/axios_utils';
import DropdownUtils from './filtered_search/dropdown_utils';
@@ -379,7 +379,7 @@ export class SearchAutocomplete {
}
}
}
- this.wrap.toggleClass('has-value', !!e.target.value);
+ this.wrap.toggleClass('has-value', Boolean(e.target.value));
}
onSearchInputFocus() {
@@ -396,7 +396,7 @@ export class SearchAutocomplete {
onClearInputClick(e) {
e.preventDefault();
- this.wrap.toggleClass('has-value', !!e.target.value);
+ this.wrap.toggleClass('has-value', Boolean(e.target.value));
return this.searchInput.val('').focus();
}
@@ -405,8 +405,9 @@ export class SearchAutocomplete {
this.wrap.removeClass('search-active');
// If input is blank then restore state
if (this.searchInput.val() === '') {
- return this.restoreOriginalState();
+ this.restoreOriginalState();
}
+ this.dropdownMenu.removeClass('show');
}
restoreOriginalState() {
@@ -439,7 +440,7 @@ export class SearchAutocomplete {
restoreMenu() {
var html;
- html = '<ul><li class="dropdown-menu-empty-item"><a>Loading...</a></li></ul>';
+ html = `<ul><li class="dropdown-menu-empty-item"><a>${__('Loading...')}</a></li></ul>`;
return this.dropdownContent.html(html);
}
diff --git a/app/assets/javascripts/serverless/components/area.vue b/app/assets/javascripts/serverless/components/area.vue
new file mode 100644
index 00000000000..32c9d6eccb8
--- /dev/null
+++ b/app/assets/javascripts/serverless/components/area.vue
@@ -0,0 +1,146 @@
+<script>
+import { GlAreaChart } from '@gitlab/ui/dist/charts';
+import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
+import dateFormat from 'dateformat';
+import { X_INTERVAL } from '../constants';
+import { validateGraphData } from '../utils';
+
+let debouncedResize;
+
+export default {
+ components: {
+ GlAreaChart,
+ },
+ inheritAttrs: false,
+ props: {
+ graphData: {
+ type: Object,
+ required: true,
+ validator: validateGraphData,
+ },
+ containerWidth: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ tooltipPopoverTitle: '',
+ tooltipPopoverContent: '',
+ width: this.containerWidth,
+ };
+ },
+ computed: {
+ chartData() {
+ return this.graphData.queries.reduce((accumulator, query) => {
+ accumulator[query.unit] = query.result.reduce((acc, res) => acc.concat(res.values), []);
+ return accumulator;
+ }, {});
+ },
+ extractTimeData() {
+ return this.chartData.requests.map(data => data.time);
+ },
+ generateSeries() {
+ return {
+ name: 'Invocations',
+ type: 'line',
+ data: this.chartData.requests.map(data => [data.time, data.value]),
+ symbolSize: 0,
+ };
+ },
+ getInterval() {
+ const { result } = this.graphData.queries[0];
+
+ if (result.length === 0) {
+ return 1;
+ }
+
+ const split = result[0].values.reduce(
+ (acc, pair) => (pair.value > acc ? pair.value : acc),
+ 1,
+ );
+
+ return split < X_INTERVAL ? split : X_INTERVAL;
+ },
+ chartOptions() {
+ return {
+ xAxis: {
+ name: 'time',
+ type: 'time',
+ axisLabel: {
+ formatter: date => dateFormat(date, 'h:MM TT'),
+ },
+ data: this.extractTimeData,
+ nameTextStyle: {
+ padding: [18, 0, 0, 0],
+ },
+ },
+ yAxis: {
+ name: this.yAxisLabel,
+ nameTextStyle: {
+ padding: [0, 0, 36, 0],
+ },
+ splitNumber: this.getInterval,
+ },
+ legend: {
+ formatter: this.xAxisLabel,
+ },
+ series: this.generateSeries,
+ };
+ },
+ xAxisLabel() {
+ return this.graphData.queries.map(query => query.label).join(', ');
+ },
+ yAxisLabel() {
+ const [query] = this.graphData.queries;
+ return `${this.graphData.y_label} (${query.unit})`;
+ },
+ },
+ watch: {
+ containerWidth: 'onResize',
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', debouncedResize);
+ },
+ created() {
+ debouncedResize = debounceByAnimationFrame(this.onResize);
+ window.addEventListener('resize', debouncedResize);
+ },
+ methods: {
+ formatTooltipText(params) {
+ const [seriesData] = params.seriesData;
+ this.tooltipPopoverTitle = dateFormat(params.value, 'dd mmm yyyy, h:MMTT');
+ this.tooltipPopoverContent = `${this.yAxisLabel}: ${seriesData.value[1]}`;
+ },
+ onResize() {
+ const { width } = this.$refs.areaChart.$el.getBoundingClientRect();
+ this.width = width;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="prometheus-graph">
+ <div class="prometheus-graph-header">
+ <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5>
+ <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div>
+ </div>
+ <gl-area-chart
+ ref="areaChart"
+ v-bind="$attrs"
+ :data="[]"
+ :option="chartOptions"
+ :format-tooltip-text="formatTooltipText"
+ :width="width"
+ :include-legend-avg-max="false"
+ >
+ <template slot="tooltipTitle">
+ {{ tooltipPopoverTitle }}
+ </template>
+ <template slot="tooltipContent">
+ {{ tooltipPopoverContent }}
+ </template>
+ </gl-area-chart>
+ </div>
+</template>
diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue
index 4f89ad69129..b8906cfca4e 100644
--- a/app/assets/javascripts/serverless/components/function_details.vue
+++ b/app/assets/javascripts/serverless/components/function_details.vue
@@ -1,39 +1,77 @@
<script>
+import _ from 'underscore';
+import { mapState, mapActions, mapGetters } from 'vuex';
import PodBox from './pod_box.vue';
import Url from './url.vue';
+import AreaChart from './area.vue';
+import MissingPrometheus from './missing_prometheus.vue';
export default {
components: {
PodBox,
Url,
+ AreaChart,
+ MissingPrometheus,
},
props: {
func: {
type: Object,
required: true,
},
+ hasPrometheus: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ clustersPath: {
+ type: String,
+ required: true,
+ },
+ helpPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ elWidth: 0,
+ };
},
computed: {
name() {
return this.func.name;
},
description() {
- return this.func.description;
+ return _.isString(this.func.description) ? this.func.description : '';
},
funcUrl() {
return this.func.url;
},
podCount() {
- return this.func.podcount || 0;
+ return Number(this.func.podcount) || 0;
},
+ ...mapState(['graphData', 'hasPrometheusData']),
+ ...mapGetters(['hasPrometheusMissingData']),
+ },
+ created() {
+ this.fetchMetrics({
+ metricsPath: this.func.metricsUrl,
+ hasPrometheus: this.hasPrometheus,
+ });
+ },
+ mounted() {
+ this.elWidth = this.$el.clientWidth;
+ },
+ methods: {
+ ...mapActions(['fetchMetrics']),
},
};
</script>
<template>
<section id="serverless-function-details">
- <h3>{{ name }}</h3>
- <div class="append-bottom-default">
+ <h3 class="serverless-function-name">{{ name }}</h3>
+ <div class="append-bottom-default serverless-function-description">
<div v-for="(line, index) in description.split('\n')" :key="index">{{ line }}</div>
</div>
<url :uri="funcUrl" />
@@ -52,5 +90,13 @@ export default {
</p>
</div>
<div v-else><p>No pods loaded at this time.</p></div>
+
+ <area-chart v-if="hasPrometheusData" :graph-data="graphData" :container-width="elWidth" />
+ <missing-prometheus
+ v-if="!hasPrometheus || hasPrometheusMissingData"
+ :help-path="helpPath"
+ :clusters-path="clustersPath"
+ :missing-data="hasPrometheusMissingData"
+ />
</section>
</template>
diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue
index 773d18781fd..4b3bb078eae 100644
--- a/app/assets/javascripts/serverless/components/function_row.vue
+++ b/app/assets/javascripts/serverless/components/function_row.vue
@@ -1,4 +1,5 @@
<script>
+import _ from 'underscore';
import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
import Url from './url.vue';
import { visitUrl } from '~/lib/utils/url_utility';
@@ -19,6 +20,10 @@ export default {
return this.func.name;
},
description() {
+ if (!_.isString(this.func.description)) {
+ return '';
+ }
+
const desc = this.func.description.split('\n');
if (desc.length > 1) {
return desc[1];
diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue
index 4bde409f906..94341050b86 100644
--- a/app/assets/javascripts/serverless/components/functions.vue
+++ b/app/assets/javascripts/serverless/components/functions.vue
@@ -1,26 +1,19 @@
<script>
-import { GlSkeletonLoading } from '@gitlab/ui';
+import { mapState, mapActions, mapGetters } from 'vuex';
+import { GlLoadingIcon } from '@gitlab/ui';
import FunctionRow from './function_row.vue';
import EnvironmentRow from './environment_row.vue';
import EmptyState from './empty_state.vue';
+import { CHECKING_INSTALLED } from '../constants';
export default {
components: {
EnvironmentRow,
FunctionRow,
EmptyState,
- GlSkeletonLoading,
+ GlLoadingIcon,
},
props: {
- functions: {
- type: Object,
- required: true,
- default: () => ({}),
- },
- installed: {
- type: Boolean,
- required: true,
- },
clustersPath: {
type: String,
required: true,
@@ -29,32 +22,48 @@ export default {
type: String,
required: true,
},
- loadingData: {
- type: Boolean,
- required: false,
- default: true,
+ statusPath: {
+ type: String,
+ required: true,
},
- hasFunctionData: {
- type: Boolean,
- required: false,
- default: true,
+ },
+ computed: {
+ ...mapState(['installed', 'isLoading', 'hasFunctionData']),
+ ...mapGetters(['getFunctions']),
+
+ checkingInstalled() {
+ return this.installed === CHECKING_INSTALLED;
+ },
+ isInstalled() {
+ return this.installed === true;
},
},
+ created() {
+ this.fetchFunctions({
+ functionsPath: this.statusPath,
+ });
+ },
+ methods: {
+ ...mapActions(['fetchFunctions']),
+ },
};
</script>
<template>
<section id="serverless-functions">
- <div v-if="installed">
+ <gl-loading-icon
+ v-if="checkingInstalled"
+ :size="2"
+ class="prepend-top-default append-bottom-default"
+ />
+
+ <div v-else-if="isInstalled">
<div v-if="hasFunctionData">
- <template v-if="loadingData">
- <div v-for="j in 3" :key="j" class="gl-responsive-table-row"><gl-skeleton-loading /></div>
- </template>
- <template v-else>
- <div class="groups-list-tree-container">
+ <template>
+ <div class="groups-list-tree-container js-functions-wrapper">
<ul class="content-list group-list-tree">
<environment-row
- v-for="(env, index) in functions"
+ v-for="(env, index) in getFunctions"
:key="index"
:env="env"
:env-name="index"
@@ -62,6 +71,11 @@ export default {
</ul>
</div>
</template>
+ <gl-loading-icon
+ v-if="isLoading"
+ :size="2"
+ class="prepend-top-default append-bottom-default js-functions-loader"
+ />
</div>
<div v-else class="empty-state js-empty-state">
<div class="text-content">
diff --git a/app/assets/javascripts/serverless/components/missing_prometheus.vue b/app/assets/javascripts/serverless/components/missing_prometheus.vue
new file mode 100644
index 00000000000..6c19434f202
--- /dev/null
+++ b/app/assets/javascripts/serverless/components/missing_prometheus.vue
@@ -0,0 +1,63 @@
+<script>
+import { GlButton, GlLink } from '@gitlab/ui';
+import { s__ } from '../../locale';
+
+export default {
+ components: {
+ GlButton,
+ GlLink,
+ },
+ props: {
+ clustersPath: {
+ type: String,
+ required: true,
+ },
+ helpPath: {
+ type: String,
+ required: true,
+ },
+ missingData: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ missingStateClass() {
+ return this.missingData ? 'missing-prometheus-state' : 'empty-prometheus-state';
+ },
+ prometheusHelpPath() {
+ return `${this.helpPath}#prometheus-support`;
+ },
+ description() {
+ return this.missingData
+ ? s__(`ServerlessDetails|Invocation metrics loading or not available at this time.`)
+ : s__(
+ `ServerlessDetails|Function invocation metrics require Prometheus to be installed first.`,
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="row" :class="missingStateClass">
+ <div class="col-12">
+ <div class="text-content">
+ <h4 class="state-title text-left">{{ s__(`ServerlessDetails|Invocations`) }}</h4>
+ <p class="state-description">
+ {{ description }}
+ <gl-link :href="prometheusHelpPath">{{
+ s__(`ServerlessDetails|More information`)
+ }}</gl-link
+ >.
+ </p>
+
+ <div v-if="!missingData" class="text-left">
+ <gl-button :href="clustersPath" variant="success">
+ {{ s__('ServerlessDetails|Install Prometheus') }}
+ </gl-button>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/serverless/components/url.vue b/app/assets/javascripts/serverless/components/url.vue
index ca53bf6c52a..e47a03f1939 100644
--- a/app/assets/javascripts/serverless/components/url.vue
+++ b/app/assets/javascripts/serverless/components/url.vue
@@ -20,7 +20,7 @@ export default {
<template>
<div class="clipboard-group">
- <div class="url-text-field label label-monospace">{{ uri }}</div>
+ <div class="url-text-field label label-monospace monospace">{{ uri }}</div>
<clipboard-button
:text="uri"
:title="s__('ServerlessURL|Copy URL to clipboard')"
diff --git a/app/assets/javascripts/serverless/constants.js b/app/assets/javascripts/serverless/constants.js
new file mode 100644
index 00000000000..2fa15e56ccb
--- /dev/null
+++ b/app/assets/javascripts/serverless/constants.js
@@ -0,0 +1,7 @@
+export const MAX_REQUESTS = 3; // max number of times to retry
+
+export const X_INTERVAL = 5; // Reflects the number of verticle bars on the x-axis
+
+export const CHECKING_INSTALLED = 'checking'; // The backend is still determining whether or not Knative is installed
+
+export const TIMEOUT = 'timeout';
diff --git a/app/assets/javascripts/serverless/serverless_bundle.js b/app/assets/javascripts/serverless/serverless_bundle.js
index 47a510d5fb5..ed3b633d766 100644
--- a/app/assets/javascripts/serverless/serverless_bundle.js
+++ b/app/assets/javascripts/serverless/serverless_bundle.js
@@ -1,13 +1,7 @@
-import Visibility from 'visibilityjs';
import Vue from 'vue';
-import { s__ } from '../locale';
-import Flash from '../flash';
-import Poll from '../lib/utils/poll';
-import ServerlessStore from './stores/serverless_store';
-import ServerlessDetailsStore from './stores/serverless_details_store';
-import GetFunctionsService from './services/get_functions_service';
import Functions from './components/functions.vue';
import FunctionDetails from './components/function_details.vue';
+import { createStore } from './store';
export default class Serverless {
constructor() {
@@ -19,10 +13,12 @@ export default class Serverless {
serviceUrl,
serviceNamespace,
servicePodcount,
+ serviceMetricsUrl,
+ prometheus,
+ clustersPath,
+ helpPath,
} = document.querySelector('.js-serverless-function-details-page').dataset;
const el = document.querySelector('#js-serverless-function-details');
- this.store = new ServerlessDetailsStore();
- const { store } = this;
const service = {
name: serviceName,
@@ -31,118 +27,48 @@ export default class Serverless {
url: serviceUrl,
namespace: serviceNamespace,
podcount: servicePodcount,
+ metricsUrl: serviceMetricsUrl,
};
- this.store.updateDetailedFunction(service);
this.functionDetails = new Vue({
el,
- data() {
- return {
- state: store.state,
- };
- },
+ store: createStore(),
render(createElement) {
return createElement(FunctionDetails, {
props: {
- func: this.state.functionDetail,
+ func: service,
+ hasPrometheus: prometheus !== undefined,
+ clustersPath,
+ helpPath,
},
});
},
});
} else {
- const { statusPath, clustersPath, helpPath, installed } = document.querySelector(
+ const { statusPath, clustersPath, helpPath } = document.querySelector(
'.js-serverless-functions-page',
).dataset;
- this.service = new GetFunctionsService(statusPath);
- this.knativeInstalled = installed !== undefined;
- this.store = new ServerlessStore(this.knativeInstalled, clustersPath, helpPath);
- this.initServerless();
- this.functionLoadCount = 0;
-
- if (statusPath && this.knativeInstalled) {
- this.initPolling();
- }
- }
- }
-
- initServerless() {
- const { store } = this;
- const el = document.querySelector('#js-serverless-functions');
-
- this.functions = new Vue({
- el,
- data() {
- return {
- state: store.state,
- };
- },
- render(createElement) {
- return createElement(Functions, {
- props: {
- functions: this.state.functions,
- installed: this.state.installed,
- clustersPath: this.state.clustersPath,
- helpPath: this.state.helpPath,
- loadingData: this.state.loadingData,
- hasFunctionData: this.state.hasFunctionData,
- },
- });
- },
- });
- }
-
- initPolling() {
- this.poll = new Poll({
- resource: this.service,
- method: 'fetchData',
- successCallback: data => this.handleSuccess(data),
- errorCallback: () => Serverless.handleError(),
- });
-
- if (!Visibility.hidden()) {
- this.poll.makeRequest();
- } else {
- this.service
- .fetchData()
- .then(data => this.handleSuccess(data))
- .catch(() => Serverless.handleError());
- }
-
- Visibility.change(() => {
- if (!Visibility.hidden() && !this.destroyed) {
- this.poll.restart();
- } else {
- this.poll.stop();
- }
- });
- }
-
- handleSuccess(data) {
- if (data.status === 200) {
- this.store.updateFunctionsFromServer(data.data);
- this.store.updateLoadingState(false);
- } else if (data.status === 204) {
- /* Time out after 3 attempts to retrieve data */
- this.functionLoadCount += 1;
- if (this.functionLoadCount === 3) {
- this.poll.stop();
- this.store.toggleNoFunctionData();
- }
+ const el = document.querySelector('#js-serverless-functions');
+ this.functions = new Vue({
+ el,
+ store: createStore(),
+ render(createElement) {
+ return createElement(Functions, {
+ props: {
+ clustersPath,
+ helpPath,
+ statusPath,
+ },
+ });
+ },
+ });
}
}
- static handleError() {
- Flash(s__('Serverless|An error occurred while retrieving serverless components'));
- }
-
destroy() {
this.destroyed = true;
- if (this.poll) {
- this.poll.stop();
- }
-
this.functions.$destroy();
this.functionDetails.$destroy();
}
diff --git a/app/assets/javascripts/serverless/services/get_functions_service.js b/app/assets/javascripts/serverless/services/get_functions_service.js
deleted file mode 100644
index 303b42dc66c..00000000000
--- a/app/assets/javascripts/serverless/services/get_functions_service.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import axios from '~/lib/utils/axios_utils';
-
-export default class GetFunctionsService {
- constructor(endpoint) {
- this.endpoint = endpoint;
- }
-
- fetchData() {
- return axios.get(this.endpoint);
- }
-}
diff --git a/app/assets/javascripts/serverless/store/actions.js b/app/assets/javascripts/serverless/store/actions.js
new file mode 100644
index 00000000000..a0a9fdf7ace
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/actions.js
@@ -0,0 +1,128 @@
+import * as types from './mutation_types';
+import axios from '~/lib/utils/axios_utils';
+import statusCodes from '~/lib/utils/http_status';
+import { backOff } from '~/lib/utils/common_utils';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+import { MAX_REQUESTS, CHECKING_INSTALLED, TIMEOUT } from '../constants';
+
+export const requestFunctionsLoading = ({ commit }) => commit(types.REQUEST_FUNCTIONS_LOADING);
+export const receiveFunctionsSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_FUNCTIONS_SUCCESS, data);
+export const receiveFunctionsPartial = ({ commit }, data) =>
+ commit(types.RECEIVE_FUNCTIONS_PARTIAL, data);
+export const receiveFunctionsTimeout = ({ commit }, data) =>
+ commit(types.RECEIVE_FUNCTIONS_TIMEOUT, data);
+export const receiveFunctionsNoDataSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_FUNCTIONS_NODATA_SUCCESS, data);
+export const receiveFunctionsError = ({ commit }, error) =>
+ commit(types.RECEIVE_FUNCTIONS_ERROR, error);
+
+export const receiveMetricsSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_METRICS_SUCCESS, data);
+export const receiveMetricsNoPrometheus = ({ commit }) =>
+ commit(types.RECEIVE_METRICS_NO_PROMETHEUS);
+export const receiveMetricsNoDataSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_METRICS_NODATA_SUCCESS, data);
+export const receiveMetricsError = ({ commit }, error) =>
+ commit(types.RECEIVE_METRICS_ERROR, error);
+
+export const fetchFunctions = ({ dispatch }, { functionsPath }) => {
+ let retryCount = 0;
+
+ const functionsPartiallyFetched = data => {
+ if (data.functions !== null && data.functions.length) {
+ dispatch('receiveFunctionsPartial', data);
+ }
+ };
+
+ dispatch('requestFunctionsLoading');
+
+ backOff((next, stop) => {
+ axios
+ .get(functionsPath)
+ .then(response => {
+ if (response.data.knative_installed === CHECKING_INSTALLED) {
+ retryCount += 1;
+ if (retryCount < MAX_REQUESTS) {
+ functionsPartiallyFetched(response.data);
+ next();
+ } else {
+ stop(TIMEOUT);
+ }
+ } else {
+ stop(response.data);
+ }
+ })
+ .catch(stop);
+ })
+ .then(data => {
+ if (data === TIMEOUT) {
+ dispatch('receiveFunctionsTimeout');
+ createFlash(__('Loading functions timed out. Please reload the page to try again.'));
+ } else if (data.functions !== null && data.functions.length) {
+ dispatch('receiveFunctionsSuccess', data);
+ } else {
+ dispatch('receiveFunctionsNoDataSuccess', data);
+ }
+ })
+ .catch(error => {
+ dispatch('receiveFunctionsError', error);
+ createFlash(error);
+ });
+};
+
+export const fetchMetrics = ({ dispatch }, { metricsPath, hasPrometheus }) => {
+ let retryCount = 0;
+
+ if (!hasPrometheus) {
+ dispatch('receiveMetricsNoPrometheus');
+ return;
+ }
+
+ backOff((next, stop) => {
+ axios
+ .get(metricsPath)
+ .then(response => {
+ if (response.status === statusCodes.NO_CONTENT) {
+ retryCount += 1;
+ if (retryCount < MAX_REQUESTS) {
+ next();
+ } else {
+ dispatch('receiveMetricsNoDataSuccess');
+ stop(null);
+ }
+ } else {
+ stop(response.data);
+ }
+ })
+ .catch(stop);
+ })
+ .then(data => {
+ if (data === null) {
+ return;
+ }
+
+ const updatedMetric = data.metrics;
+ const queries = data.metrics.queries.map(query => ({
+ ...query,
+ result: query.result.map(result => ({
+ ...result,
+ values: result.values.map(([timestamp, value]) => ({
+ time: new Date(timestamp * 1000).toISOString(),
+ value: Number(value),
+ })),
+ })),
+ }));
+
+ updatedMetric.queries = queries;
+ dispatch('receiveMetricsSuccess', updatedMetric);
+ })
+ .catch(error => {
+ dispatch('receiveMetricsError', error);
+ createFlash(error);
+ });
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/serverless/store/getters.js b/app/assets/javascripts/serverless/store/getters.js
new file mode 100644
index 00000000000..071f663d9d2
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/getters.js
@@ -0,0 +1,10 @@
+import { translate } from '../utils';
+
+export const hasPrometheusMissingData = state => state.hasPrometheus && !state.hasPrometheusData;
+
+// Convert the function list into a k/v grouping based on the environment scope
+
+export const getFunctions = state => translate(state.functions);
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/serverless/store/index.js b/app/assets/javascripts/serverless/store/index.js
new file mode 100644
index 00000000000..5f72060633e
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/index.js
@@ -0,0 +1,18 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+import createState from './state';
+
+Vue.use(Vuex);
+
+export const createStore = () =>
+ new Vuex.Store({
+ actions,
+ getters,
+ mutations,
+ state: createState(),
+ });
+
+export default createStore();
diff --git a/app/assets/javascripts/serverless/store/mutation_types.js b/app/assets/javascripts/serverless/store/mutation_types.js
new file mode 100644
index 00000000000..b8fa9ea1a01
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/mutation_types.js
@@ -0,0 +1,11 @@
+export const REQUEST_FUNCTIONS_LOADING = 'REQUEST_FUNCTIONS_LOADING';
+export const RECEIVE_FUNCTIONS_SUCCESS = 'RECEIVE_FUNCTIONS_SUCCESS';
+export const RECEIVE_FUNCTIONS_PARTIAL = 'RECEIVE_FUNCTIONS_PARTIAL';
+export const RECEIVE_FUNCTIONS_TIMEOUT = 'RECEIVE_FUNCTIONS_TIMEOUT';
+export const RECEIVE_FUNCTIONS_NODATA_SUCCESS = 'RECEIVE_FUNCTIONS_NODATA_SUCCESS';
+export const RECEIVE_FUNCTIONS_ERROR = 'RECEIVE_FUNCTIONS_ERROR';
+
+export const RECEIVE_METRICS_NO_PROMETHEUS = 'RECEIVE_METRICS_NO_PROMETHEUS';
+export const RECEIVE_METRICS_SUCCESS = 'RECEIVE_METRICS_SUCCESS';
+export const RECEIVE_METRICS_NODATA_SUCCESS = 'RECEIVE_METRICS_NODATA_SUCCESS';
+export const RECEIVE_METRICS_ERROR = 'RECEIVE_METRICS_ERROR';
diff --git a/app/assets/javascripts/serverless/store/mutations.js b/app/assets/javascripts/serverless/store/mutations.js
new file mode 100644
index 00000000000..2685a5b11ff
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/mutations.js
@@ -0,0 +1,49 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.REQUEST_FUNCTIONS_LOADING](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_FUNCTIONS_SUCCESS](state, data) {
+ state.functions = data.functions;
+ state.installed = data.knative_installed;
+ state.isLoading = false;
+ state.hasFunctionData = true;
+ },
+ [types.RECEIVE_FUNCTIONS_PARTIAL](state, data) {
+ state.functions = data.functions;
+ state.installed = true;
+ state.isLoading = true;
+ state.hasFunctionData = true;
+ },
+ [types.RECEIVE_FUNCTIONS_TIMEOUT](state) {
+ state.isLoading = false;
+ },
+ [types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state, data) {
+ state.isLoading = false;
+ state.installed = data.knative_installed;
+ state.hasFunctionData = false;
+ },
+ [types.RECEIVE_FUNCTIONS_ERROR](state, error) {
+ state.error = error;
+ state.hasFunctionData = false;
+ state.isLoading = false;
+ },
+ [types.RECEIVE_METRICS_SUCCESS](state, data) {
+ state.isLoading = false;
+ state.hasPrometheusData = true;
+ state.graphData = data;
+ },
+ [types.RECEIVE_METRICS_NODATA_SUCCESS](state) {
+ state.isLoading = false;
+ state.hasPrometheusData = false;
+ },
+ [types.RECEIVE_METRICS_ERROR](state, error) {
+ state.hasPrometheusData = false;
+ state.error = error;
+ },
+ [types.RECEIVE_METRICS_NO_PROMETHEUS](state) {
+ state.hasPrometheusData = false;
+ state.hasPrometheus = false;
+ },
+};
diff --git a/app/assets/javascripts/serverless/store/state.js b/app/assets/javascripts/serverless/store/state.js
new file mode 100644
index 00000000000..fdd29299749
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/state.js
@@ -0,0 +1,14 @@
+export default () => ({
+ error: null,
+ installed: 'checking',
+ isLoading: true,
+
+ // functions
+ functions: [],
+ hasFunctionData: true,
+
+ // function_details
+ hasPrometheus: true,
+ hasPrometheusData: false,
+ graphData: {},
+});
diff --git a/app/assets/javascripts/serverless/stores/serverless_details_store.js b/app/assets/javascripts/serverless/stores/serverless_details_store.js
deleted file mode 100644
index 5394d2cded1..00000000000
--- a/app/assets/javascripts/serverless/stores/serverless_details_store.js
+++ /dev/null
@@ -1,11 +0,0 @@
-export default class ServerlessDetailsStore {
- constructor() {
- this.state = {
- functionDetail: {},
- };
- }
-
- updateDetailedFunction(func) {
- this.state.functionDetail = func;
- }
-}
diff --git a/app/assets/javascripts/serverless/stores/serverless_store.js b/app/assets/javascripts/serverless/stores/serverless_store.js
deleted file mode 100644
index 816d55a03f9..00000000000
--- a/app/assets/javascripts/serverless/stores/serverless_store.js
+++ /dev/null
@@ -1,29 +0,0 @@
-export default class ServerlessStore {
- constructor(knativeInstalled = false, clustersPath, helpPath) {
- this.state = {
- functions: {},
- hasFunctionData: true,
- loadingData: true,
- installed: knativeInstalled,
- clustersPath,
- helpPath,
- };
- }
-
- updateFunctionsFromServer(upstreamFunctions = []) {
- this.state.functions = upstreamFunctions.reduce((rv, func) => {
- const envs = rv;
- envs[func.environment_scope] = (rv[func.environment_scope] || []).concat([func]);
-
- return envs;
- }, {});
- }
-
- updateLoadingState(loadingData) {
- this.state.loadingData = loadingData;
- }
-
- toggleNoFunctionData() {
- this.state.hasFunctionData = false;
- }
-}
diff --git a/app/assets/javascripts/serverless/utils.js b/app/assets/javascripts/serverless/utils.js
new file mode 100644
index 00000000000..8b9e96ce9aa
--- /dev/null
+++ b/app/assets/javascripts/serverless/utils.js
@@ -0,0 +1,23 @@
+// Validate that the object coming in has valid query details and results
+export const validateGraphData = data =>
+ data.queries &&
+ Array.isArray(data.queries) &&
+ data.queries.filter(query => {
+ if (Array.isArray(query.result)) {
+ return query.result.filter(res => Array.isArray(res.values)).length === query.result.length;
+ }
+
+ return false;
+ }).length === data.queries.length;
+
+export const translate = functions =>
+ functions.reduce(
+ (acc, func) =>
+ Object.assign(acc, {
+ [func.environment_scope]: (acc[func.environment_scope] || []).concat([func]),
+ }),
+ {},
+ );
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
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 7f86741ed29..35eba266625 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
@@ -2,7 +2,7 @@
import $ from 'jquery';
import createFlash from '~/flash';
import Icon from '~/vue_shared/components/icon.vue';
-import GfmAutoComplete from '~/gfm_auto_complete';
+import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
import { __, s__ } from '~/locale';
import Api from '~/api';
import { GlModal, GlTooltipDirective } from '@gitlab/ui';
@@ -178,7 +178,7 @@ export default {
/>
<div ref="userStatusForm" class="form-group position-relative m-0">
<div class="input-group">
- <span class="input-group-btn">
+ <span class="input-group-prepend">
<button
ref="toggleEmojiMenuButton"
v-gl-tooltip.bottom
@@ -194,9 +194,9 @@ export default {
v-show="noEmoji"
class="js-no-emoji-placeholder no-emoji-placeholder position-relative"
>
- <icon name="emoji_slightly_smiling_face" css-classes="award-control-icon-neutral" />
- <icon name="emoji_smiley" css-classes="award-control-icon-positive" />
- <icon name="emoji_smile" css-classes="award-control-icon-super-positive" />
+ <icon name="slight-smile" css-classes="award-control-icon-neutral" />
+ <icon name="smiley" css-classes="award-control-icon-positive" />
+ <icon name="smile" css-classes="award-control-icon-super-positive" />
</span>
</button>
</span>
@@ -211,7 +211,7 @@ export default {
@keyup.enter.prevent
@click="hideEmojiMenu"
/>
- <span v-show="isDirty" class="input-group-btn">
+ <span v-show="isDirty" class="input-group-append">
<button
v-gl-tooltip.bottom
:title="s__('SetStatusModal|Clear status')"
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
index d1a396182b3..0074d7099dc 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
@@ -74,8 +74,7 @@ export default {
}
if (!this.users.length) {
- const emptyTooltipLabel =
- this.issuableType === 'issue' ? __('Assignee(s)') : __('Assignee');
+ const emptyTooltipLabel = __('Assignee(s)');
names.push(emptyTooltipLabel);
}
@@ -90,6 +89,27 @@ export default {
return counter;
},
+ mergeNotAllowedTooltipMessage() {
+ const assigneesCount = this.users.length;
+
+ if (this.issuableType !== 'merge_request' || assigneesCount === 0) {
+ return null;
+ }
+
+ const cannotMergeCount = this.users.filter(u => u.can_merge === false).length;
+ const canMergeCount = assigneesCount - cannotMergeCount;
+
+ if (canMergeCount === assigneesCount) {
+ // Everyone can merge
+ return null;
+ } else if (cannotMergeCount === assigneesCount && assigneesCount > 1) {
+ return 'No one can merge';
+ } else if (assigneesCount === 1) {
+ return 'Cannot merge';
+ }
+
+ return `${canMergeCount}/${assigneesCount} can merge`;
+ },
},
methods: {
assignSelf() {
@@ -133,7 +153,7 @@ export default {
data-placement="left"
data-boundary="viewport"
>
- <i v-if="hasNoUsers" aria-label="No Assignee" class="fa fa-user"> </i>
+ <i v-if="hasNoUsers" aria-label="None" class="fa fa-user"> </i>
<button
v-for="(user, index) in users"
v-if="shouldRenderCollapsedAssignee(index)"
@@ -154,9 +174,18 @@ export default {
</button>
</div>
<div class="value hide-collapsed">
+ <span
+ v-if="mergeNotAllowedTooltipMessage"
+ v-tooltip
+ :title="mergeNotAllowedTooltipMessage"
+ data-placement="left"
+ class="float-right cannot-be-merged"
+ >
+ <i aria-hidden="true" data-hidden="true" class="fa fa-exclamation-triangle"></i>
+ </span>
<template v-if="hasNoUsers">
- <span class="assign-yourself no-value">
- No assignee
+ <span class="assign-yourself no-value qa-assign-yourself">
+ None
<template v-if="editable">
- <button type="button" class="btn-link" @click="assignSelf">assign yourself</button>
</template>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
index c03b2a68c78..d84d5344935 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -49,10 +49,10 @@ export default {
},
computed: {
hasTimeSpent() {
- return !!this.timeSpent;
+ return Boolean(this.timeSpent);
},
hasTimeEstimate() {
- return !!this.timeEstimate;
+ return Boolean(this.timeEstimate);
},
showComparisonState() {
return this.hasTimeEstimate && this.hasTimeSpent;
@@ -67,7 +67,7 @@ export default {
return !this.hasTimeEstimate && !this.hasTimeSpent;
},
showHelpState() {
- return !!this.showHelp;
+ return Boolean(this.showHelp);
},
},
created() {
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
index 706e6ca19c3..57125c78cf6 100644
--- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
@@ -50,6 +50,9 @@ export default {
buttonLabel() {
return this.isTodo ? MARK_TEXT : TODO_TEXT;
},
+ buttonTooltip() {
+ return !this.collapsed ? undefined : this.buttonLabel;
+ },
collapsedButtonIconClasses() {
return this.isTodo ? 'todo-undone' : '';
},
@@ -69,7 +72,7 @@ export default {
<button
v-tooltip
:class="buttonClasses"
- :title="buttonLabel"
+ :title="buttonTooltip"
:aria-label="buttonLabel"
:data-issuable-id="issuableId"
:data-issuable-type="issuableType"
diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
index 225ebb61195..110175a6779 100644
--- a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
+++ b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import _ from 'underscore';
+import { __ } from '~/locale';
function isValidProjectId(id) {
return id > 0;
@@ -40,7 +41,9 @@ class SidebarMoveIssue {
this.mediator
.fetchAutocompleteProjects(searchTerm)
.then(callback)
- .catch(() => new window.Flash('An error occurred while fetching projects autocomplete.'));
+ .catch(
+ () => new window.Flash(__('An error occurred while fetching projects autocomplete.')),
+ );
},
renderRow: project => `
<li>
@@ -72,7 +75,7 @@ class SidebarMoveIssue {
this.$confirmButton.disable().addClass('is-loading');
this.mediator.moveIssue().catch(() => {
- window.Flash('An error occurred while moving the issue.');
+ window.Flash(__('An error occurred while moving the issue.'));
this.$confirmButton.enable().removeClass('is-loading');
});
}
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index 3e040ec8428..22ac8df9699 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -2,6 +2,7 @@ import { visitUrl } from '../lib/utils/url_utility';
import Flash from '../flash';
import Service from './services/sidebar_service';
import Store from './stores/sidebar_store';
+import { __ } from '~/locale';
export default class SidebarMediator {
constructor(options) {
@@ -45,7 +46,7 @@ export default class SidebarMediator {
.then(data => {
this.processFetchedData(data);
})
- .catch(() => new Flash('Error occurred when fetching sidebar data'));
+ .catch(() => new Flash(__('Error occurred when fetching sidebar data')));
}
processFetchedData(data) {
diff --git a/app/assets/javascripts/snippet/snippet_embed.js b/app/assets/javascripts/snippet/snippet_embed.js
index 873a506a92f..fe08d2c7ebb 100644
--- a/app/assets/javascripts/snippet/snippet_embed.js
+++ b/app/assets/javascripts/snippet/snippet_embed.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export default () => {
const { protocol, host, pathname } = window.location;
const shareBtn = document.querySelector('.js-share-btn');
@@ -10,7 +12,7 @@ export default () => {
shareBtn.classList.add('is-active');
embedBtn.classList.remove('is-active');
snippetUrlArea.value = url;
- embedAction.innerText = 'Share';
+ embedAction.innerText = __('Share');
});
embedBtn.addEventListener('click', () => {
@@ -18,6 +20,6 @@ export default () => {
shareBtn.classList.remove('is-active');
const scriptTag = `<script src="${url}.js"></script>`;
snippetUrlArea.value = scriptTag;
- embedAction.innerText = 'Embed';
+ embedAction.innerText = __('Embed');
});
};
diff --git a/app/assets/javascripts/star.js b/app/assets/javascripts/star.js
index 7404dfbf22a..70f89152f70 100644
--- a/app/assets/javascripts/star.js
+++ b/app/assets/javascripts/star.js
@@ -31,7 +31,7 @@ export default class Star {
$this.prepend(spriteIcon('star', iconClasses));
}
})
- .catch(() => Flash('Star toggle failed. Try again later.'));
+ .catch(() => Flash(__('Star toggle failed. Try again later.')));
});
}
}
diff --git a/app/assets/javascripts/subscription_select.js b/app/assets/javascripts/subscription_select.js
index ebe1c6dd02d..7206bbd7109 100644
--- a/app/assets/javascripts/subscription_select.js
+++ b/app/assets/javascripts/subscription_select.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { __ } from './locale';
export default function subscriptionSelect() {
$('.js-subscription-event').each((i, element) => {
@@ -8,7 +9,7 @@ export default function subscriptionSelect() {
selectable: true,
fieldName,
toggleLabel(selected, el, instance) {
- let label = 'Subscription';
+ let label = __('Subscription');
const $item = instance.dropdown.find('.is-active');
if ($item.length) {
label = $item.text();
diff --git a/app/assets/javascripts/templates/issuable_template_selector.js b/app/assets/javascripts/templates/issuable_template_selector.js
index 6065770e68d..78609ce0610 100644
--- a/app/assets/javascripts/templates/issuable_template_selector.js
+++ b/app/assets/javascripts/templates/issuable_template_selector.js
@@ -3,6 +3,7 @@
import $ from 'jquery';
import Api from '../api';
import TemplateSelector from '../blob/template_selector';
+import { __ } from '~/locale';
export default class IssuableTemplateSelector extends TemplateSelector {
constructor(...args) {
@@ -25,7 +26,7 @@ export default class IssuableTemplateSelector extends TemplateSelector {
$('.no-template', this.dropdown.parent()).on('click', () => {
this.currentTemplate.content = '';
this.setInputValueToTemplateContent();
- $('.dropdown-toggle-text', this.dropdown).text('Choose a template');
+ $('.dropdown-toggle-text', this.dropdown).text(__('Choose a template'));
});
}
diff --git a/app/assets/javascripts/terminal/terminal.js b/app/assets/javascripts/terminal/terminal.js
index e5dd7a465ea..9c7c10d9864 100644
--- a/app/assets/javascripts/terminal/terminal.js
+++ b/app/assets/javascripts/terminal/terminal.js
@@ -4,6 +4,7 @@ import { Terminal } from 'xterm';
import * as fit from 'xterm/lib/addons/fit/fit';
import * as webLinks from 'xterm/lib/addons/webLinks/webLinks';
import { canScrollUp, canScrollDown } from '~/lib/utils/dom_utils';
+import { __ } from '~/locale';
const SCROLL_MARGIN = 5;
@@ -78,7 +79,8 @@ export default class GLTerminal {
}
handleSocketFailure() {
- this.terminal.write('\r\nConnection failure');
+ this.terminal.write('\r\n');
+ this.terminal.write(__('Connection failure'));
}
addScrollListener(onScrollLimit) {
diff --git a/app/assets/javascripts/test_utils/index.js b/app/assets/javascripts/test_utils/index.js
index a55a338eea8..1e75ee60671 100644
--- a/app/assets/javascripts/test_utils/index.js
+++ b/app/assets/javascripts/test_utils/index.js
@@ -1,5 +1,5 @@
-import 'core-js/es6/map';
-import 'core-js/es6/set';
+import 'core-js/es/map';
+import 'core-js/es/set';
import simulateDrag from './simulate_drag';
import simulateInput from './simulate_input';
diff --git a/app/assets/javascripts/u2f/error.js b/app/assets/javascripts/u2f/error.js
index 1a98564ff55..ca0fc0700ad 100644
--- a/app/assets/javascripts/u2f/error.js
+++ b/app/assets/javascripts/u2f/error.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export default class U2FError {
constructor(errorCode, u2fFlowType) {
this.errorCode = errorCode;
@@ -8,15 +10,17 @@ export default class U2FError {
message() {
if (this.errorCode === window.u2f.ErrorCodes.BAD_REQUEST && this.httpsDisabled) {
- return 'U2F only works with HTTPS-enabled websites. Contact your administrator for more details.';
+ return __(
+ 'U2F only works with HTTPS-enabled websites. Contact your administrator for more details.',
+ );
} else if (this.errorCode === window.u2f.ErrorCodes.DEVICE_INELIGIBLE) {
if (this.u2fFlowType === 'authenticate') {
- return 'This device has not been registered with us.';
+ return __('This device has not been registered with us.');
}
if (this.u2fFlowType === 'register') {
- return 'This device has already been registered with us.';
+ return __('This device has already been registered with us.');
}
}
- return 'There was a problem communicating with your device.';
+ return __('There was a problem communicating with your device.');
}
}
diff --git a/app/assets/javascripts/usage_ping_consent.js b/app/assets/javascripts/usage_ping_consent.js
index d3d745a3c11..1e7a5fb19c2 100644
--- a/app/assets/javascripts/usage_ping_consent.js
+++ b/app/assets/javascripts/usage_ping_consent.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import axios from './lib/utils/axios_utils';
import Flash, { hideFlash } from './flash';
import { parseBoolean } from './lib/utils/common_utils';
+import { __ } from './locale';
export default () => {
$('body').on('click', '.js-usage-consent-action', e => {
@@ -25,7 +26,7 @@ export default () => {
})
.catch(() => {
hideConsentMessage();
- Flash('Something went wrong. Try again later.');
+ Flash(__('Something went wrong. Try again later.'));
});
});
};
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index 4017630d6ef..7e6f02b10af 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -5,7 +5,7 @@
import $ from 'jquery';
import _ from 'underscore';
import axios from './lib/utils/axios_utils';
-import { __ } from './locale';
+import { s__, __, sprintf } from './locale';
import ModalStore from './boards/stores/modal_store';
// TODO: remove eventHub hack after code splitting refactor
@@ -93,23 +93,22 @@ function UsersSelect(currentUser, els, options = {}) {
}
// Save current selected user to the DOM
- const input = document.createElement('input');
- input.type = 'hidden';
- input.name = $dropdown.data('fieldName');
-
- const currentUserInfo = $dropdown.data('currentUserInfo');
-
- if (currentUserInfo) {
- input.value = currentUserInfo.id;
- input.dataset.meta = _.escape(currentUserInfo.name);
- } else if (_this.currentUser) {
- input.value = _this.currentUser.id;
- }
+ const currentUserInfo = $dropdown.data('currentUserInfo') || {};
+ const currentUser = _this.currentUser || {};
+ const fieldName = $dropdown.data('fieldName');
+ const userName = currentUserInfo.name;
+ const userId = currentUserInfo.id || currentUser.id;
+
+ const inputHtmlString = _.template(`
+ <input type="hidden" name="<%- fieldName %>"
+ data-meta="<%- userName %>"
+ value="<%- userId %>" />
+ `)({ fieldName, userName, userId });
if ($selectbox) {
- $dropdown.parent().before(input);
+ $dropdown.parent().before(inputHtmlString);
} else {
- $dropdown.after(input);
+ $dropdown.after(inputHtmlString);
}
};
@@ -158,14 +157,20 @@ function UsersSelect(currentUser, els, options = {}) {
.get(0);
if (selectedUsers.length === 0) {
- return 'Unassigned';
+ return s__('UsersSelect|Unassigned');
} else if (selectedUsers.length === 1) {
return firstUser.name;
} else if (isSelected) {
const otherSelected = selectedUsers.filter(s => s !== selectedUser.id);
- return `${selectedUser.name} + ${otherSelected.length} more`;
+ return sprintf(s__('UsersSelect|%{name} + %{length} more'), {
+ name: selectedUser.name,
+ length: otherSelected.length,
+ });
} else {
- return `${firstUser.name} + ${selectedUsers.length - 1} more`;
+ return sprintf(s__('UsersSelect|%{name} + %{length} more'), {
+ name: firstUser.name,
+ length: selectedUsers.length - 1,
+ });
}
};
@@ -219,11 +224,11 @@ function UsersSelect(currentUser, els, options = {}) {
tooltipTitle = _.escape(user.name);
} else {
user = {
- name: 'Unassigned',
+ name: s__('UsersSelect|Unassigned'),
username: '',
avatar: '',
};
- tooltipTitle = __('Assignee');
+ tooltipTitle = s__('UsersSelect|Assignee');
}
$value.html(assigneeTemplate(user));
$collapsedSidebar.attr('title', tooltipTitle).tooltip('_fixTitle');
@@ -234,7 +239,11 @@ function UsersSelect(currentUser, els, options = {}) {
'<% if( avatar ) { %> <a class="author-link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>',
);
assigneeTemplate = _.template(
- '<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> No assignee - <a href="#" class="js-assign-yourself"> assign yourself </a> </span> <% } %>',
+ `<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself">
+ ${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), {
+ openingTag: '<a href="#" class="js-assign-yourself">',
+ closingTag: '</a>',
+ })}</span> <% } %>`,
);
return $dropdown.glDropdown({
showMenuAbove: showMenuAbove,
@@ -303,7 +312,7 @@ function UsersSelect(currentUser, els, options = {}) {
showDivider += 1;
users.unshift({
beforeDivider: true,
- name: 'Unassigned',
+ name: s__('UsersSelect|Unassigned'),
id: 0,
});
}
@@ -311,7 +320,7 @@ function UsersSelect(currentUser, els, options = {}) {
showDivider += 1;
name = showAnyUser;
if (name === true) {
- name = 'Any User';
+ name = s__('UsersSelect|Any User');
}
anyUser = {
beforeDivider: true,
@@ -597,7 +606,7 @@ function UsersSelect(currentUser, els, options = {}) {
showEmailUser = $(select).data('emailUser');
firstUser = $(select).data('firstUser');
return $(select).select2({
- placeholder: 'Search for a user',
+ placeholder: __('Search for a user'),
multiple: $(select).hasClass('multiselect'),
minimumInputLength: 0,
query: function(query) {
@@ -622,7 +631,7 @@ function UsersSelect(currentUser, els, options = {}) {
}
if (showNullUser) {
nullUser = {
- name: 'Unassigned',
+ name: s__('UsersSelect|Unassigned'),
id: 0,
};
data.results.unshift(nullUser);
@@ -630,7 +639,7 @@ function UsersSelect(currentUser, els, options = {}) {
if (showAnyUser) {
name = showAnyUser;
if (name === true) {
- name = 'Any User';
+ name = s__('UsersSelect|Any User');
}
anyUser = {
name: name,
@@ -646,7 +655,7 @@ function UsersSelect(currentUser, els, options = {}) {
) {
var trimmed = query.term.trim();
emailUser = {
- name: 'Invite "' + trimmed + '" by email',
+ name: sprintf(__('Invite "%{trimmed}" by email'), { trimmed }),
username: trimmed,
id: trimmed,
invite: true,
@@ -689,7 +698,7 @@ UsersSelect.prototype.initSelection = function(element, callback) {
id = $(element).val();
if (id === '0') {
nullUser = {
- name: 'Unassigned',
+ name: s__('UsersSelect|Unassigned'),
};
return callback(nullUser);
} else if (id !== '') {
diff --git a/app/assets/javascripts/validators/input_validator.js b/app/assets/javascripts/validators/input_validator.js
new file mode 100644
index 00000000000..f37373977b8
--- /dev/null
+++ b/app/assets/javascripts/validators/input_validator.js
@@ -0,0 +1,34 @@
+const invalidInputClass = 'gl-field-error-outline';
+
+export default class InputValidator {
+ constructor() {
+ this.inputDomElement = {};
+ this.inputErrorMessage = {};
+ this.errorMessage = null;
+ this.invalidInput = null;
+ }
+
+ setValidationStateAndMessage() {
+ this.setValidationMessage();
+
+ const isInvalidInput = !this.inputDomElement.checkValidity();
+ this.inputDomElement.classList.toggle(invalidInputClass, isInvalidInput);
+ this.inputErrorMessage.classList.toggle('hide', !isInvalidInput);
+ }
+
+ setValidationMessage() {
+ if (this.invalidInput) {
+ this.inputDomElement.setCustomValidity(this.errorMessage);
+ this.inputErrorMessage.innerHTML = this.errorMessage;
+ } else {
+ this.resetValidationMessage();
+ }
+ }
+
+ resetValidationMessage() {
+ if (this.inputDomElement.validationMessage === this.errorMessage) {
+ this.inputDomElement.setCustomValidity('');
+ this.inputErrorMessage.innerHTML = this.inputDomElement.title;
+ }
+ }
+}
diff --git a/app/assets/javascripts/visual_review_toolbar/index.js b/app/assets/javascripts/visual_review_toolbar/index.js
new file mode 100644
index 00000000000..91d0382feac
--- /dev/null
+++ b/app/assets/javascripts/visual_review_toolbar/index.js
@@ -0,0 +1,2 @@
+import './styles/toolbar.css';
+import 'vendor/visual_review_toolbar';
diff --git a/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css b/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css
new file mode 100644
index 00000000000..342b3599a44
--- /dev/null
+++ b/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css
@@ -0,0 +1,149 @@
+/*
+ As a standalone script, the toolbar has its own css
+ */
+
+#gitlab-collapse > * {
+ pointer-events: none;
+}
+
+#gitlab-form-wrapper {
+ display: flex;
+ flex-direction: column;
+ width: 100%
+}
+
+#gitlab-review-container {
+ max-width: 22rem;
+ max-height: 22rem;
+ overflow: scroll;
+ position: fixed;
+ bottom: 1rem;
+ right: 1rem;
+ display: flex;
+ flex-direction: row-reverse;
+ padding: 1rem;
+ background-color: #fff;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell,
+ 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
+ 'Noto Color Emoji';
+ font-size: .8rem;
+ font-weight: 400;
+ color: #2e2e2e;
+}
+
+.gitlab-open-wrapper {
+ max-width: 22rem;
+ max-height: 22rem;
+}
+
+.gitlab-closed-wrapper {
+ max-width: 3.4rem;
+ max-height: 3.4rem;
+}
+
+.gitlab-button {
+ cursor: pointer;
+ transition: background-color 100ms linear, border-color 100ms linear, color 100ms linear, box-shadow 100ms linear;
+}
+
+.gitlab-button-secondary {
+ background: none #fff;
+ margin: 0 .5rem;
+ border: 1px solid #e3e3e3;
+}
+
+.gitlab-button-secondary:hover {
+ background-color: #f0f0f0;
+ border-color: #e3e3e3;
+ color: #2e2e2e;
+}
+
+.gitlab-button-secondary:active {
+ color: #2e2e2e;
+ background-color: #e1e1e1;
+ border-color: #dadada;
+}
+
+.gitlab-button-success:hover {
+ color: #fff;
+ background-color: #137e3f;
+ border-color: #127339;
+}
+
+.gitlab-button-success:active {
+ background-color: #168f48;
+ border-color: #12753a;
+ color: #fff;
+}
+
+.gitlab-button-success {
+ background-color: #1aaa55;
+ border: 1px solid #168f48;
+ color: #fff;
+}
+
+.gitlab-button-wide {
+ width: 100%;
+}
+
+.gitlab-button-wrapper {
+ margin-top: 1rem;
+ display: flex;
+ align-items: baseline;
+ justify-content: flex-end;
+}
+
+.gitlab-collapse {
+ width: 2.4rem;
+ height: 2.2rem;
+ margin-left: 1rem;
+ padding: .5rem;
+}
+
+.gitlab-collapse-closed {
+ align-self: center;
+}
+
+.gitlab-checkbox-label {
+ padding: 0 .2rem;
+}
+
+.gitlab-checkbox-wrapper {
+ display: flex;
+ align-items: baseline;
+}
+
+.gitlab-label {
+ font-weight: 600;
+ display: inline-block;
+ width: 100%;
+}
+
+.gitlab-link {
+ color: #1b69b6;
+ text-decoration: none;
+ background-color: transparent;
+ background-image: none;
+}
+
+.gitlab-message {
+ padding: .25rem 0;
+ margin: 0;
+ line-height: 1.2rem;
+}
+
+.gitlab-metadata-note {
+ font-size: .7rem;
+ line-height: 1rem;
+ color: #666;
+ margin-bottom: 0;
+}
+
+.gitlab-input {
+ width: 100%;
+ border: 1px solid #dfdfdf;
+ border-radius: 4px;
+ padding: .1rem .2rem;
+ min-height: 2rem;
+ max-width: 17rem;
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
index da0a9483f8e..abe5bdd2901 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
@@ -23,6 +23,8 @@ export default {
TooltipOnTruncate,
FilteredSearchDropdown,
ReviewAppLink,
+ VisualReviewAppLink: () =>
+ import('ee_component/vue_merge_request_widget/components/visual_review_app_link.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -37,6 +39,20 @@ export default {
type: Boolean,
required: true,
},
+ showVisualReviewApp: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ visualReviewAppMeta: {
+ type: Object,
+ required: false,
+ default: () => ({
+ sourceProjectId: '',
+ mergeRequestId: '',
+ appUrl: '',
+ }),
+ },
},
deployedTextMap: {
running: __('Deploying to'),
@@ -61,16 +77,16 @@ export default {
return this.deployment.external_url;
},
hasExternalUrls() {
- return !!(this.deployment.external_url && this.deployment.external_url_formatted);
+ return Boolean(this.deployment.external_url && this.deployment.external_url_formatted);
},
hasDeploymentTime() {
- return !!(this.deployment.deployed_at && this.deployment.deployed_at_formatted);
+ return Boolean(this.deployment.deployed_at && this.deployment.deployed_at_formatted);
},
hasDeploymentMeta() {
- return !!(this.deployment.url && this.deployment.name);
+ return Boolean(this.deployment.url && this.deployment.name);
},
hasMetrics() {
- return !!this.deployment.metrics_url;
+ return Boolean(this.deployment.metrics_url);
},
deployedText() {
return this.$options.deployedTextMap[this.deployment.status];
@@ -168,6 +184,11 @@ export default {
:link="deploymentExternalUrl"
:css-class="`deploy-link js-deploy-url inline ${slotProps.className}`"
/>
+ <visual-review-app-link
+ v-if="showVisualReviewApp"
+ :link="deploymentExternalUrl"
+ :app-metadata="visualReviewAppMeta"
+ />
</template>
<template slot="result" slot-scope="slotProps">
@@ -187,11 +208,17 @@ export default {
</a>
</template>
</filtered-search-dropdown>
- <review-app-link
- v-else
- :link="deploymentExternalUrl"
- css-class="js-deploy-url js-deploy-url-feature-flag deploy-link btn btn-default btn-sm inlin"
- />
+ <template v-else>
+ <review-app-link
+ :link="deploymentExternalUrl"
+ css-class="js-deploy-url js-deploy-url-feature-flag deploy-link btn btn-default btn-sm inline"
+ />
+ <visual-review-app-link
+ v-if="showVisualReviewApp"
+ :link="deploymentExternalUrl"
+ :app-metadata="visualReviewAppMeta"
+ />
+ </template>
</template>
<span
v-if="deployment.stop_url"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue
new file mode 100644
index 00000000000..19a222462b3
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue
@@ -0,0 +1,46 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+import { WARNING, DANGER, WARNING_MESSAGE_CLASS, DANGER_MESSAGE_CLASS } from '../constants';
+
+export default {
+ name: 'MrWidgetAlertMessage',
+ components: {
+ GlLink,
+ Icon,
+ },
+ props: {
+ type: {
+ type: String,
+ required: false,
+ default: DANGER,
+ validator: value => [WARNING, DANGER].includes(value),
+ },
+ helpPath: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ },
+ computed: {
+ messageClass() {
+ if (this.type === WARNING) {
+ return WARNING_MESSAGE_CLASS;
+ } else if (this.type === DANGER) {
+ return DANGER_MESSAGE_CLASS;
+ }
+
+ return '';
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="m-3 ml-7" :class="messageClass">
+ <slot></slot>
+ <gl-link v-if="helpPath" :href="helpPath" target="_blank">
+ <icon :size="16" name="question-o" class="align-middle" />
+ </gl-link>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
index 3b9fc2661ef..361441640e1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
@@ -109,33 +109,35 @@ export default {
></div>
</div>
- <div v-if="mr.isOpen" class="branch-actions d-flex">
- <a
- v-if="!mr.sourceBranchRemoved"
- v-tooltip
- :href="webIdePath"
- :title="ideButtonTitle"
- :class="{ disabled: !mr.canPushToSourceBranch }"
- class="btn btn-default js-web-ide d-none d-md-inline-block append-right-8"
- data-placement="bottom"
- tabindex="0"
- role="button"
- >
- {{ s__('mrWidget|Open in Web IDE') }}
- </a>
- <button
- :disabled="mr.sourceBranchRemoved"
- data-target="#modal_merge_info"
- data-toggle="modal"
- class="btn btn-default js-check-out-branch append-right-default"
- type="button"
- >
- {{ s__('mrWidget|Check out branch') }}
- </button>
+ <div class="branch-actions d-flex">
+ <template v-if="mr.isOpen">
+ <a
+ v-if="!mr.sourceBranchRemoved"
+ v-tooltip
+ :href="webIdePath"
+ :title="ideButtonTitle"
+ :class="{ disabled: !mr.canPushToSourceBranch }"
+ class="btn btn-default js-web-ide d-none d-md-inline-block append-right-8"
+ data-placement="bottom"
+ tabindex="0"
+ role="button"
+ >
+ {{ s__('mrWidget|Open in Web IDE') }}
+ </a>
+ <button
+ :disabled="mr.sourceBranchRemoved"
+ data-target="#modal_merge_info"
+ data-toggle="modal"
+ class="btn btn-default js-check-out-branch append-right-default"
+ type="button"
+ >
+ {{ s__('mrWidget|Check out branch') }}
+ </button>
+ </template>
<span class="dropdown">
<button
type="button"
- class="btn dropdown-toggle"
+ class="btn dropdown-toggle qa-dropdown-toggle"
data-toggle="dropdown"
aria-label="Download as"
aria-haspopup="true"
@@ -145,12 +147,20 @@ export default {
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li>
- <a :href="mr.emailPatchesPath" class="js-download-email-patches" download>
+ <a
+ :href="mr.emailPatchesPath"
+ class="js-download-email-patches qa-download-email-patches"
+ download
+ >
{{ s__('mrWidget|Email patches') }}
</a>
</li>
<li>
- <a :href="mr.plainDiffPath" class="js-download-plain-diff" download>
+ <a
+ :href="mr.plainDiffPath"
+ class="js-download-plain-diff qa-download-plain-diff"
+ download
+ >
{{ s__('mrWidget|Plain diff') }}
</a>
</li>
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 f11cf21b0ca..c377c16fb13 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,10 +1,13 @@
<script>
/* eslint-disable vue/require-default-prop */
+import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import { sprintf, __ } 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 PipelineLink from '~/vue_shared/components/ci_pipeline_link.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',
@@ -13,7 +16,15 @@ export default {
CiIcon,
Icon,
TooltipOnTruncate,
+ GlLink,
+ PipelineLink,
+ LinkedPipelinesMiniList: () =>
+ import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'),
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [mrWidgetPipelineMixin],
props: {
pipeline: {
type: Object,
@@ -74,16 +85,21 @@ export default {
false,
);
},
+ isTriggeredByMergeRequest() {
+ return Boolean(this.pipeline.merge_request);
+ },
+ isMergeRequestPipeline() {
+ return Boolean(this.pipeline.flags && this.pipeline.flags.merge_request_pipeline);
+ },
},
};
</script>
<template>
- <div v-if="hasPipeline || hasCIError" class="ci-widget media">
- <template v-if="hasCIError">
+ <div class="ci-widget media js-ci-widget">
+ <template v-if="!hasPipeline || hasCIError">
<div
- class="add-border ci-status-icon ci-status-icon-failed ci-error
- js-ci-error append-right-default"
+ class="add-border ci-status-icon ci-status-icon-failed ci-error js-ci-error append-right-default"
>
<icon :size="32" name="status_failed_borderless" />
</div>
@@ -96,24 +112,61 @@ export default {
<div class="ci-widget-container d-flex">
<div class="ci-widget-content">
<div class="media-body">
- <div class="font-weight-bold">
- Pipeline
- <a :href="pipeline.path" class="pipeline-id font-weight-normal pipeline-number"
- >#{{ pipeline.id }}</a
- >
-
+ <div class="font-weight-bold js-pipeline-info-container">
+ {{ s__('Pipeline|Pipeline') }}
+ <pipeline-link
+ :href="pipeline.path"
+ :pipeline-id="pipeline.id"
+ :pipeline-iid="pipeline.iid"
+ class="pipeline-id pipeline-iid font-weight-normal"
+ />
{{ pipeline.details.status.label }}
-
<template v-if="hasCommitInfo">
- for
- <a
+ {{ s__('Pipeline|for') }}
+ <gl-link
:href="pipeline.commit.commit_path"
class="commit-sha js-commit-link font-weight-normal"
+ >{{ pipeline.commit.short_id }}</gl-link
>
- {{ pipeline.commit.short_id }}</a
- >
- on
+ {{ s__('Pipeline|on') }}
+ <template v-if="isTriggeredByMergeRequest">
+ <gl-link
+ v-gl-tooltip
+ :href="pipeline.merge_request.path"
+ :title="pipeline.merge_request.title"
+ class="font-weight-normal"
+ >!{{ pipeline.merge_request.iid }}</gl-link
+ >
+ {{ s__('Pipeline|with') }}
+ <tooltip-on-truncate
+ :title="pipeline.merge_request.source_branch"
+ truncate-target="child"
+ class="label-branch label-truncate"
+ >
+ <gl-link
+ :href="pipeline.merge_request.source_branch_path"
+ class="font-weight-normal"
+ >{{ pipeline.merge_request.source_branch }}</gl-link
+ >
+ </tooltip-on-truncate>
+
+ <template v-if="isMergeRequestPipeline">
+ {{ s__('Pipeline|into') }}
+ <tooltip-on-truncate
+ :title="pipeline.merge_request.target_branch"
+ truncate-target="child"
+ class="label-branch label-truncate"
+ >
+ <gl-link
+ :href="pipeline.merge_request.target_branch_path"
+ class="font-weight-normal"
+ >{{ pipeline.merge_request.target_branch }}</gl-link
+ >
+ </tooltip-on-truncate>
+ </template>
+ </template>
<tooltip-on-truncate
+ v-else
:title="sourceBranch"
truncate-target="child"
class="label-branch label-truncate"
@@ -121,20 +174,29 @@ export default {
/>
</template>
</div>
- <div v-if="pipeline.coverage" class="coverage">Coverage {{ pipeline.coverage }}%</div>
+ <div v-if="pipeline.coverage" class="coverage">
+ {{ s__('Pipeline|Coverage') }} {{ pipeline.coverage }}%
+ </div>
</div>
</div>
<div>
<span class="mr-widget-pipeline-graph">
- <span v-if="hasStages" class="stage-cell">
- <div
- v-for="(stage, i) in pipeline.details.stages"
- :key="i"
- class="stage-container dropdown js-mini-pipeline-graph mr-widget-pipeline-stages"
- >
- <pipeline-stage :stage="stage" />
- </div>
+ <span class="stage-cell">
+ <linked-pipelines-mini-list v-if="triggeredBy.length" :triggered-by="triggeredBy" />
+ <template v-if="hasStages">
+ <div
+ v-for="(stage, i) in pipeline.details.stages"
+ :key="i"
+ :class="{
+ 'has-downstream': hasDownstream(i),
+ }"
+ class="stage-container dropdown js-mini-pipeline-graph mr-widget-pipeline-stages"
+ >
+ <pipeline-stage :stage="stage" />
+ </div>
+ </template>
</span>
+ <linked-pipelines-mini-list v-if="triggered.length" :triggered="triggered" />
</span>
</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 5f5fe67b3c1..03a15ba81ed 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
@@ -30,9 +30,6 @@ export default {
},
},
computed: {
- pipeline() {
- return this.isPostMerge ? this.mr.mergePipeline : this.mr.pipeline;
- },
branch() {
return this.isPostMerge ? this.mr.targetBranch : this.mr.sourceBranch;
},
@@ -48,6 +45,19 @@ export default {
hasDeploymentMetrics() {
return this.isPostMerge;
},
+ visualReviewAppMeta() {
+ return {
+ appUrl: this.mr.appUrl,
+ mergeRequestId: this.mr.iid,
+ sourceProjectId: this.mr.sourceProjectId,
+ };
+ },
+ pipeline() {
+ return this.isPostMerge ? this.mr.mergePipeline : this.mr.pipeline;
+ },
+ showVisualReviewAppLink() {
+ return Boolean(this.mr.visualReviewFF && this.mr.visualReviewAppAvailable);
+ },
},
};
</script>
@@ -61,14 +71,18 @@ export default {
:source-branch-link="branchLink"
:troubleshooting-docs-path="mr.troubleshootingDocsPath"
/>
- <div v-if="deployments.length" slot="footer" class="mr-widget-extension">
- <deployment
- v-for="deployment in deployments"
- :key="deployment.id"
- :class="deploymentClass"
- :deployment="deployment"
- :show-metrics="hasDeploymentMetrics"
- />
- </div>
+ <template v-slot:footer>
+ <div v-if="deployments.length" class="mr-widget-extension">
+ <deployment
+ v-for="deployment in deployments"
+ :key="deployment.id"
+ :class="deploymentClass"
+ :deployment="deployment"
+ :show-metrics="hasDeploymentMetrics"
+ :show-visual-review-app="true"
+ :visual-review-app-meta="visualReviewAppMeta"
+ />
+ </div>
+ </template>
</mr-widget-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
index 780ced4d382..392eb6fb425 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
@@ -33,7 +33,7 @@ export default {
</script>
<template>
<div class="space-children d-flex append-right-10 widget-status-icon">
- <div v-if="isLoading" class="mr-widget-icon"><gl-loading-icon /></div>
+ <div v-if="isLoading" class="mr-widget-icon"><gl-loading-icon size="md" /></div>
<ci-icon v-else :status="statusObj" :size="24" />
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 de9c122f268..457a71cab95 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
@@ -19,6 +19,6 @@ export default {
</script>
<template>
<a :href="link" target="_blank" rel="noopener noreferrer nofollow" :class="cssClass">
- {{ __('View app') }} <icon name="external-link" />
+ {{ __('View app') }} <icon css-classes="fgray" name="external-link" />
</a>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue
index 780ecdcdac4..6aad2a26a53 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue
@@ -14,7 +14,7 @@ export default {
</script>
<template>
- <p v-once class="mr-info-list mr-links source-branch-removal-status append-bottom-0">
+ <p v-once class="mr-info-list mr-links append-bottom-0">
<span class="status-text" v-html="removesBranchText"> </span>
<i v-tooltip :title="tooltipTitle" :aria-label="tooltipTitle" class="fa fa-question-circle">
</i>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
index a38f25cce35..acd8037cfb2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
@@ -30,6 +30,7 @@ export default {
:id="inputId"
:value="value"
class="form-control js-gfm-input append-bottom-default commit-message-edit"
+ dir="auto"
required="required"
rows="7"
@input="$emit('input', $event.target.value)"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue
index b3c1c0e329d..b6722de5277 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue
@@ -20,7 +20,6 @@ export default {
<div>
<gl-dropdown
right
- no-caret
text="Use an existing commit message"
variant="link"
class="mr-commit-dropdown"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
index 33963d5e1e6..0312b147b62 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
@@ -14,6 +14,10 @@ export default {
type: Boolean,
required: true,
},
+ isFastForwardEnabled: {
+ type: Boolean,
+ required: true,
+ },
commitsCount: {
type: Number,
required: false,
@@ -37,16 +41,22 @@ export default {
return n__(__('%d commit'), __('%d commits'), this.isSquashEnabled ? 1 : this.commitsCount);
},
modifyLinkMessage() {
- return this.isSquashEnabled ? __('Modify commit messages') : __('Modify merge commit');
+ if (this.isFastForwardEnabled) return __('Modify commit message');
+ else if (this.isSquashEnabled) return __('Modify commit messages');
+ return __('Modify merge commit');
},
ariaLabel() {
return this.expanded ? __('Collapse') : __('Expand');
},
message() {
+ const message = this.isFastForwardEnabled
+ ? s__('mrWidgetCommitsAdded|%{commitCount} will be added to %{targetBranch}.')
+ : s__(
+ 'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}.',
+ );
+
return sprintf(
- s__(
- 'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}.',
- ),
+ message,
{
commitCount: `<strong class="commits-count-message">${this.commitsCountMessage}</strong>`,
mergeCommitCount: `<strong>${s__('mrWidgetCommitsAdded|1 merge commit')}</strong>`,
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
index a3a44dd8e99..83e7d6db9fa 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
@@ -35,9 +35,7 @@ export default {
<status-icon status="warning" />
<div class="media-body space-children">
<span class="bold">
- <template v-if="mr.mergeError"
- >{{ mr.mergeError }}.</template
- >
+ <template v-if="mr.mergeError">{{ mr.mergeError }}</template>
{{ s__('mrWidget|This merge request failed to be merged automatically') }}
</span>
<button
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
index 2a4dff71d9b..11bc8c73ee9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
@@ -80,7 +80,7 @@ export default {
<status-icon :show-disabled-button="true" status="warning" />
<div class="media-body space-children">
<span class="bold">
- <span v-if="mr.mergeError" class="has-error-message"> {{ mergeError }}. </span>
+ <span v-if="mr.mergeError" class="has-error-message"> {{ mergeError }} </span>
<span v-else> {{ s__('mrWidget|Merge failed.') }} </span>
<span :class="{ 'has-custom-error': mr.mergeError }"> {{ timerText }} </span>
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue
index 1b3af2fccf2..88e1ccbaf35 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue
@@ -57,7 +57,7 @@ export default {
removeSourceBranch() {
const options = {
sha: this.mr.sha,
- merge_when_pipeline_succeeds: true,
+ auto_merge_strategy: 'merge_when_pipeline_succeeds',
should_remove_source_branch: true,
};
@@ -85,7 +85,7 @@ export default {
<h4 class="d-flex align-items-start">
<span class="append-right-10">
{{ s__('mrWidget|Set by') }}
- <mr-widget-author :author="mr.setToMWPSBy" />
+ <mr-widget-author :author="mr.setToAutoMergeBy" />
{{ s__('mrWidget|to be merged automatically when the pipeline succeeds') }}
</span>
<a
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 ce4207864ea..615d59a7b8e 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
@@ -3,6 +3,7 @@ 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 MergeRequest from '../../../merge_request';
import Flash from '../../../flash';
import statusIcon from '../mr_widget_status_icon.vue';
@@ -21,6 +22,7 @@ export default {
CommitEdit,
CommitMessageDropdown,
},
+ mixins: [readyToMergeMixin],
props: {
mr: { type: Object, required: true },
service: { type: Object, required: true },
@@ -29,7 +31,7 @@ export default {
return {
removeSourceBranch: this.mr.shouldRemoveSourceBranch,
mergeWhenBuildSucceeds: false,
- setToMergeWhenPipelineSucceeds: false,
+ autoMergeStrategy: undefined,
isMakingRequest: false,
isMergingImmediately: false,
commitMessage: this.mr.commitMessage,
@@ -40,7 +42,7 @@ export default {
};
},
computed: {
- shouldShowMergeWhenPipelineSucceedsText() {
+ shouldShowAutoMergeText() {
return this.mr.isPipelineActive;
},
status() {
@@ -85,7 +87,7 @@ export default {
mergeButtonText() {
if (this.isMergingImmediately) {
return __('Merge in progress');
- } else if (this.shouldShowMergeWhenPipelineSucceedsText) {
+ } else if (this.shouldShowAutoMergeText) {
return __('Merge when pipeline succeeds');
}
@@ -94,15 +96,6 @@ export default {
shouldShowMergeOptionsDropdown() {
return this.mr.isPipelineActive && !this.mr.onlyAllowMergeIfPipelineSucceeds;
},
- isMergeButtonDisabled() {
- const { commitMessage } = this;
- return Boolean(
- !commitMessage.length ||
- !this.shouldShowMergeControls ||
- this.isMakingRequest ||
- this.mr.preventMerge,
- );
- },
isRemoveSourceBranchButtonDisabled() {
return this.isMergeButtonDisabled;
},
@@ -111,7 +104,13 @@ export default {
return enableSquashBeforeMerge && commitsCount > 1;
},
shouldShowMergeControls() {
- return this.mr.isMergeAllowed || this.shouldShowMergeWhenPipelineSucceedsText;
+ return this.mr.isMergeAllowed || this.shouldShowAutoMergeText;
+ },
+ shouldShowSquashEdit() {
+ return this.squashBeforeMerge && this.shouldShowSquashBeforeMerge;
+ },
+ shouldShowMergeEdit() {
+ return !this.mr.ffOnlyEnabled;
},
},
methods: {
@@ -127,12 +126,12 @@ export default {
this.isMergingImmediately = true;
}
- this.setToMergeWhenPipelineSucceeds = mergeWhenBuildSucceeds === true;
+ this.autoMergeStrategy = mergeWhenBuildSucceeds ? 'merge_when_pipeline_succeeds' : undefined;
const options = {
sha: this.mr.sha,
commit_message: this.commitMessage,
- merge_when_pipeline_succeeds: this.setToMergeWhenPipelineSucceeds,
+ auto_merge_strategy: this.autoMergeStrategy,
should_remove_source_branch: this.removeSourceBranch === true,
squash: this.squashBeforeMerge,
squash_commit_message: this.squashCommitMessage,
@@ -159,9 +158,12 @@ export default {
});
},
initiateMergePolling() {
- simplePoll((continuePolling, stopPolling) => {
- this.handleMergePolling(continuePolling, stopPolling);
- });
+ simplePoll(
+ (continuePolling, stopPolling) => {
+ this.handleMergePolling(continuePolling, stopPolling);
+ },
+ { timeout: 0 },
+ );
},
handleMergePolling(continuePolling, stopPolling) {
this.service
@@ -192,6 +194,7 @@ export default {
})
.catch(() => {
new Flash(__('Something went wrong while merging this merge request. Please try again.')); // eslint-disable-line
+ stopPolling();
});
},
initiateRemoveSourceBranchPolling() {
@@ -321,43 +324,45 @@ export default {
<div v-if="mr.ffOnlyEnabled" class="mr-fast-forward-message">
{{ __('Fast-forward merge without a merge commit') }}
</div>
- <template v-else>
- <commits-header
- :is-squash-enabled="squashBeforeMerge"
- :commits-count="mr.commitsCount"
- :target-branch="mr.targetBranch"
- >
- <ul class="border-top content-list commits-list flex-list">
- <commit-edit
- v-if="squashBeforeMerge"
+ <commits-header
+ v-if="shouldShowSquashEdit || shouldShowMergeEdit"
+ :is-squash-enabled="squashBeforeMerge"
+ :commits-count="mr.commitsCount"
+ :target-branch="mr.targetBranch"
+ :is-fast-forward-enabled="mr.ffOnlyEnabled"
+ :class="{ 'border-bottom': mr.mergeError }"
+ >
+ <ul class="border-top content-list commits-list flex-list">
+ <commit-edit
+ v-if="shouldShowSquashEdit"
+ v-model="squashCommitMessage"
+ :label="__('Squash commit message')"
+ input-id="squash-message-edit"
+ squash
+ >
+ <commit-message-dropdown
+ slot="header"
v-model="squashCommitMessage"
- :label="__('Squash commit message')"
- input-id="squash-message-edit"
- squash
- >
- <commit-message-dropdown
- slot="header"
- v-model="squashCommitMessage"
- :commits="mr.commits"
+ :commits="mr.commits"
+ />
+ </commit-edit>
+ <commit-edit
+ v-if="shouldShowMergeEdit"
+ v-model="commitMessage"
+ :label="__('Merge commit message')"
+ input-id="merge-message-edit"
+ >
+ <label slot="checkbox">
+ <input
+ id="include-description"
+ type="checkbox"
+ @change="updateMergeCommitMessage($event.target.checked)"
/>
- </commit-edit>
- <commit-edit
- v-model="commitMessage"
- :label="__('Merge commit message')"
- input-id="merge-message-edit"
- >
- <label slot="checkbox">
- <input
- id="include-description"
- type="checkbox"
- @change="updateMergeCommitMessage($event.target.checked)"
- />
- {{ __('Include merge request description') }}
- </label>
- </commit-edit>
- </ul>
- </commits-header>
- </template>
+ {{ __('Include merge request description') }}
+ </label>
+ </commit-edit>
+ </ul>
+ </commits-header>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
index b1f5655a15a..accb9d9fef1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
@@ -29,8 +29,8 @@ export default {
</script>
<template>
- <div class="accept-control inline">
- <label class="merge-param-checkbox">
+ <div class="inline">
+ <label>
<input
:checked="value"
:disabled="isDisabled"
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
new file mode 100644
index 00000000000..0a29d55fbd6
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -0,0 +1,5 @@
+export const WARNING = 'warning';
+export const DANGER = 'danger';
+
+export const WARNING_MESSAGE_CLASS = 'warning_message';
+export const DANGER_MESSAGE_CLASS = 'danger_message';
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js
new file mode 100644
index 00000000000..96e8bb45e34
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js
@@ -0,0 +1,15 @@
+export default {
+ computed: {
+ triggered() {
+ return [];
+ },
+ triggeredBy() {
+ return [];
+ },
+ },
+ methods: {
+ hasDownstream() {
+ return false;
+ },
+ },
+};
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
new file mode 100644
index 00000000000..b2e64506472
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
@@ -0,0 +1,13 @@
+export default {
+ computed: {
+ isMergeButtonDisabled() {
+ const { commitMessage } = this;
+ return Boolean(
+ !commitMessage.length ||
+ !this.shouldShowMergeControls ||
+ this.isMakingRequest ||
+ this.mr.preventMerge,
+ );
+ },
+ },
+};
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 57c4dfbe3b7..d02bb2f341d 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,6 +1,6 @@
<script>
import _ from 'underscore';
-import { __ } from '~/locale';
+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';
@@ -12,6 +12,7 @@ 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 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';
import ClosedState from './components/states/mr_widget_closed.vue';
import MergingState from './components/states/mr_widget_merging.vue';
@@ -46,6 +47,7 @@ export default {
MrWidgetPipelineContainer,
Deployment,
'mr-widget-related-links': WidgetRelatedLinks,
+ MrWidgetAlertMessage,
'mr-widget-merged': MergedState,
'mr-widget-closed': ClosedState,
'mr-widget-merging': MergingState,
@@ -95,7 +97,7 @@ export default {
return this.mr.hasCI;
},
shouldRenderRelatedLinks() {
- return !!this.mr.relatedLinks && !this.mr.isNothingToMergeState;
+ return Boolean(this.mr.relatedLinks) && !this.mr.isNothingToMergeState;
},
shouldRenderSourceBranchRemovalStatus() {
return (
@@ -110,6 +112,24 @@ export default {
shouldRenderMergedPipeline() {
return this.mr.state === 'merged' && !_.isEmpty(this.mr.mergePipeline);
},
+ showMergePipelineForkWarning() {
+ return Boolean(
+ this.mr.mergePipelinesEnabled && this.mr.sourceProjectId !== this.mr.targetProjectId,
+ );
+ },
+ showTargetBranchAdvancedError() {
+ return Boolean(
+ this.mr.isOpen &&
+ this.mr.pipeline &&
+ this.mr.pipeline.target_sha &&
+ this.mr.pipeline.target_sha !== this.mr.targetBranchSha,
+ );
+ },
+ mergeError() {
+ return sprintf(s__('mrWidget|Merge failed: %{mergeError}. Please try again.'), {
+ mergeError: this.mr.mergeError,
+ });
+ },
},
watch: {
state(newVal, oldVal) {
@@ -318,17 +338,49 @@ export default {
<div class="mr-widget-section">
<component :is="componentName" :mr="mr" :service="service" />
- <section v-if="shouldRenderCollaborationStatus" class="mr-info-list mr-links">
- {{ s__('mrWidget|Allows commits from members who can merge to the target branch') }}
- </section>
+ <div class="mr-widget-info">
+ <section v-if="shouldRenderCollaborationStatus" class="mr-info-list mr-links">
+ <p>
+ {{ s__('mrWidget|Allows commits from members who can merge to the target branch') }}
+ </p>
+ </section>
+
+ <mr-widget-related-links
+ v-if="shouldRenderRelatedLinks"
+ :state="mr.state"
+ :related-links="mr.relatedLinks"
+ />
+
+ <mr-widget-alert-message
+ v-if="showMergePipelineForkWarning"
+ type="warning"
+ :help-path="mr.mergeRequestPipelinesHelpPath"
+ >
+ {{
+ s__(
+ 'mrWidget|Fork merge requests do not create merge request pipelines which validate a post merge result',
+ )
+ }}
+ </mr-widget-alert-message>
+
+ <mr-widget-alert-message
+ v-if="showTargetBranchAdvancedError"
+ type="danger"
+ :help-path="mr.mergeRequestPipelinesHelpPath"
+ >
+ {{
+ s__(
+ 'mrWidget|The target branch has advanced, which invalidates the merge request pipeline. Please update the source branch and retry merging',
+ )
+ }}
+ </mr-widget-alert-message>
- <mr-widget-related-links
- v-if="shouldRenderRelatedLinks"
- :state="mr.state"
- :related-links="mr.relatedLinks"
- />
+ <mr-widget-alert-message v-if="mr.mergeError" type="danger">
+ {{ mergeError }}
+ </mr-widget-alert-message>
- <source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" />
+ <source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" />
+ </div>
</div>
<div v-if="shouldRenderMergeHelp" class="mr-widget-footer"><mr-widget-merge-help /></div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
index 0cc4fd59f5e..3ab229567f6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
@@ -23,8 +23,8 @@ export default function deviseState(data) {
return stateKey.pipelineBlocked;
} else if (this.isSHAMismatch) {
return stateKey.shaMismatch;
- } else if (this.mergeWhenPipelineSucceeds) {
- return this.mergeError ? stateKey.autoMergeFailed : stateKey.mergeWhenPipelineSucceeds;
+ } else if (this.autoMergeEnabled) {
+ return this.mergeError ? stateKey.autoMergeFailed : stateKey.autoMergeEnabled;
} else if (!this.canMerge) {
return stateKey.notAllowedToMerge;
} else if (this.canBeMerged) {
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 58363f632a9..32badb0fb08 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
@@ -28,9 +28,11 @@ export default class MergeRequestStore {
this.iid = data.iid;
this.title = data.title;
this.targetBranch = data.target_branch;
+ this.targetBranchSha = data.target_branch_sha;
this.sourceBranch = data.source_branch;
this.sourceBranchProtected = data.source_branch_protected;
this.conflictsDocsPath = data.conflicts_docs_path;
+ this.mergeRequestPipelinesHelpPath = data.merge_request_pipelines_docs_path;
this.mergeStatus = data.merge_status;
this.commitMessage = data.default_merge_commit_message;
this.shortMergeCommitSha = data.short_merge_commit_sha;
@@ -59,7 +61,7 @@ export default class MergeRequestStore {
this.updatedAt = data.updated_at;
this.metrics = MergeRequestStore.buildMetrics(data.metrics);
- this.setToMWPSBy = MergeRequestStore.formatUserObject(data.merge_user || {});
+ this.setToAutoMergeBy = MergeRequestStore.formatUserObject(data.merge_user || {});
this.mergeUserId = data.merge_user_id;
this.currentUserId = gon.current_user_id;
this.sourceBranchPath = data.source_branch_path;
@@ -68,15 +70,16 @@ export default class MergeRequestStore {
this.targetBranchPath = data.target_branch_commits_path;
this.targetBranchTreePath = data.target_branch_tree_path;
this.conflictResolutionPath = data.conflict_resolution_path;
- this.cancelAutoMergePath = data.cancel_merge_when_pipeline_succeeds_path;
+ this.cancelAutoMergePath = data.cancel_auto_merge_path;
this.removeWIPPath = data.remove_wip_path;
this.sourceBranchRemoved = !data.source_branch_exists;
this.shouldRemoveSourceBranch = data.remove_source_branch || false;
this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false;
- this.mergeWhenPipelineSucceeds = data.merge_when_pipeline_succeeds || false;
+ this.autoMergeEnabled = Boolean(data.auto_merge_enabled);
+ this.autoMergeStrategy = data.auto_merge_strategy;
this.mergePath = data.merge_path;
this.ffOnlyEnabled = data.ff_only_enabled;
- this.shouldBeRebased = !!data.should_be_rebased;
+ this.shouldBeRebased = Boolean(data.should_be_rebased);
this.statusPath = data.status_path;
this.emailPatchesPath = data.email_patches_path;
this.plainDiffPath = data.plain_diff_path;
@@ -89,9 +92,9 @@ export default class MergeRequestStore {
this.isOpen = data.state === 'opened';
this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false;
this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false;
- this.canMerge = !!data.merge_path;
+ this.canMerge = Boolean(data.merge_path);
this.canCreateIssue = currentUser.can_create_issue || false;
- this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path;
+ this.canCancelAutomaticMerge = Boolean(data.cancel_auto_merge_path);
this.isSHAMismatch = this.sha !== data.diff_head_sha;
this.canBeMerged = data.can_be_merged || false;
this.isMergeAllowed = data.mergeable || false;
@@ -99,6 +102,9 @@ export default class MergeRequestStore {
this.allowCollaboration = data.allow_collaboration;
this.targetProjectFullPath = data.target_project_full_path;
this.sourceProjectFullPath = data.source_project_full_path;
+ this.sourceProjectId = data.source_project_id;
+ this.targetProjectId = data.target_project_id;
+ this.mergePipelinesEnabled = data.merge_pipelines_enabled;
// Cherry-pick and Revert actions related
this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false;
@@ -112,7 +118,7 @@ export default class MergeRequestStore {
this.ciStatus = data.ci_status;
this.isPipelineFailed = this.ciStatus === 'failed' || this.ciStatus === 'canceled';
this.isPipelinePassing =
- this.ciStatus === 'success' || this.ciStatus === 'success_with_warnings';
+ this.ciStatus === 'success' || this.ciStatus === 'success-with-warnings';
this.isPipelineSkipped = this.ciStatus === 'skipped';
this.pipelineDetailedStatus = pipelineStatus;
this.isPipelineActive = data.pipeline ? data.pipeline.active : false;
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
index e080ce5c229..48bc6a867f4 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
@@ -13,7 +13,7 @@ const stateToComponentMap = {
unresolvedDiscussions: 'mr-widget-unresolved-discussions',
pipelineBlocked: 'mr-widget-pipeline-blocked',
pipelineFailed: 'mr-widget-pipeline-failed',
- mergeWhenPipelineSucceeds: 'mr-widget-merge-when-pipeline-succeeds',
+ autoMergeEnabled: 'mr-widget-merge-when-pipeline-succeeds',
failedToMerge: 'mr-widget-failed-to-merge',
autoMergeFailed: 'mr-widget-auto-merge-failed',
shaMismatch: 'sha-mismatch',
@@ -45,7 +45,7 @@ export const stateKey = {
pipelineBlocked: 'pipelineBlocked',
shaMismatch: 'shaMismatch',
autoMergeFailed: 'autoMergeFailed',
- mergeWhenPipelineSucceeds: 'mergeWhenPipelineSucceeds',
+ autoMergeEnabled: 'autoMergeEnabled',
notAllowedToMerge: 'notAllowedToMerge',
readyToMerge: 'readyToMerge',
rebase: 'rebase',
diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue
index 2f498c4fa2a..25f80219993 100644
--- a/app/assets/javascripts/vue_shared/components/ci_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -21,6 +21,8 @@ import Icon from '../../vue_shared/components/icon.vue';
* - Jobs table
* - Jobs show view header
* - Jobs show view sidebar
+ * - Linked pipelines
+ * - Extended MR Popover
*/
const validSizes = [8, 12, 16, 18, 24, 32, 48, 72];
diff --git a/app/assets/javascripts/vue_shared/components/ci_pipeline_link.vue b/app/assets/javascripts/vue_shared/components/ci_pipeline_link.vue
new file mode 100644
index 00000000000..eae4c06467c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/ci_pipeline_link.vue
@@ -0,0 +1,32 @@
+<script>
+import { GlLink, GlTooltipDirective } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ href: {
+ type: String,
+ required: true,
+ },
+ pipelineId: {
+ type: Number,
+ required: true,
+ },
+ pipelineIid: {
+ type: Number,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <gl-link v-gl-tooltip :href="href" :title="__('Pipeline ID (IID)')">
+ <span class="pipeline-id">#{{ pipelineId }}</span>
+ <span class="pipeline-iid">(#{{ pipelineIid }})</span>
+ </gl-link>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
index 671b4909839..a620f560b52 100644
--- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue
+++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
@@ -7,7 +7,7 @@
*
* @example
* <clipboard-button
- * title="Copy to clipbard"
+ * title="Copy to clipboard"
* text="Content to be copied"
* css-class="btn-transparent"
* />
diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue
index ee685a4b8cd..3ba946e6447 100644
--- a/app/assets/javascripts/vue_shared/components/commit.vue
+++ b/app/assets/javascripts/vue_shared/components/commit.vue
@@ -1,5 +1,6 @@
<script>
-import { GlTooltipDirective } from '@gitlab/ui';
+import _ from 'underscore';
+import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import UserAvatarLink from './user_avatar/user_avatar_link.vue';
import Icon from '../../vue_shared/components/icon.vue';
@@ -10,6 +11,7 @@ export default {
components: {
UserAvatarLink,
Icon,
+ GlLink,
},
props: {
/**
@@ -33,6 +35,27 @@ export default {
required: false,
default: () => ({}),
},
+
+ /**
+ * If provided, is used the render the MR IID and link
+ * in place of the branch name. Must contains the
+ * following properties:
+ * - iid (number)
+ * - path (non-empty string)
+ *
+ * May optionally contain the following properties:
+ * - title (string): used in a tooltip if provided
+ *
+ * Any additional properties are ignored.
+ */
+ mergeRequestRef: {
+ type: Object,
+ required: false,
+ default: undefined,
+ validator: ref =>
+ _.isUndefined(ref) || (_.isFinite(ref.iid) && _.isString(ref.path) && !_.isEmpty(ref.path)),
+ },
+
/**
* Used to link to the commit sha.
*/
@@ -70,7 +93,11 @@ export default {
required: false,
default: () => ({}),
},
- showBranch: {
+
+ /**
+ * Indicates whether or not to show the branch/MR ref info
+ */
+ showRefInfo: {
type: Boolean,
required: false,
default: true,
@@ -78,14 +105,12 @@ export default {
},
computed: {
/**
- * Used to verify if all the properties needed to render the commit
- * ref section were provided.
- *
- * @returns {Boolean}
+ * Determines if we shoud render the ref info section based
*/
- hasCommitRef() {
- return this.commitRef && this.commitRef.name && this.commitRef.ref_url;
+ shouldShowRefInfo() {
+ return this.showRefInfo && (this.commitRef || this.mergeRequestRef);
},
+
/**
* Used to verify if all the properties needed to render the commit
* author section were provided.
@@ -108,19 +133,36 @@ export default {
};
</script>
<template>
- <div class="branch-commit">
- <template v-if="hasCommitRef && showBranch">
+ <div class="branch-commit cgray">
+ <template v-if="shouldShowRefInfo">
<div class="icon-container">
- <i v-if="tag" class="fa fa-tag" aria-hidden="true"> </i> <icon v-if="!tag" name="fork" />
+ <icon v-if="tag" name="tag" />
+ <icon v-else-if="mergeRequestRef" name="git-merge" />
+ <icon v-else name="branch" />
</div>
- <a v-gl-tooltip :href="commitRef.ref_url" :title="commitRef.name" class="ref-name">
+ <gl-link
+ v-if="mergeRequestRef"
+ v-gl-tooltip
+ :href="mergeRequestRef.path"
+ :title="mergeRequestRef.title"
+ class="ref-name"
+ >
+ {{ mergeRequestRef.iid }}
+ </gl-link>
+ <gl-link
+ v-else
+ v-gl-tooltip
+ :href="commitRef.ref_url"
+ :title="commitRef.name"
+ class="ref-name"
+ >
{{ commitRef.name }}
- </a>
+ </gl-link>
</template>
<icon name="commit" class="commit-icon js-commit-icon" />
- <a :href="commitUrl" class="commit-sha"> {{ shortSha }} </a>
+ <gl-link :href="commitUrl" class="commit-sha mr-0"> {{ shortSha }} </gl-link>
<div class="commit-title flex-truncate-parent">
<span v-if="title" class="flex-truncate-child">
@@ -132,7 +174,7 @@ export default {
:tooltip-text="author.username"
class="avatar-image-container"
/>
- <a :href="commitUrl" class="commit-row-message"> {{ title }} </a>
+ <gl-link :href="commitUrl" class="commit-row-message cgray"> {{ title }} </gl-link>
</span>
<span v-else> Can't find HEAD commit for this branch </span>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
index 4155e1bab9c..1e6f4c376c1 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
@@ -1,5 +1,4 @@
<script>
-import { viewerInformationForPath } from './lib/viewer_utils';
import MarkdownViewer from './viewers/markdown_viewer.vue';
import ImageViewer from './viewers/image_viewer.vue';
import DownloadViewer from './viewers/download_viewer.vue';
@@ -24,15 +23,18 @@ export default {
required: false,
default: '',
},
+ type: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
viewer() {
if (!this.path) return null;
+ if (!this.type) return DownloadViewer;
- const previewInfo = viewerInformationForPath(this.path);
- if (!previewInfo) return DownloadViewer;
-
- switch (previewInfo.id) {
+ switch (this.type) {
case 'markdown':
return MarkdownViewer;
case 'image':
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js
index f01a51da0b3..ba63683f5c0 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js
@@ -1,10 +1,12 @@
+import { __ } from '~/locale';
+
const viewers = {
image: {
id: 'image',
},
markdown: {
id: 'markdown',
- previewTitle: 'Preview Markdown',
+ previewTitle: __('Preview Markdown'),
},
};
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 c9915f7d685..5fdc915fffb 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
@@ -78,8 +78,8 @@ export default {
</script>
<template>
- <div ref="markdown-preview" class="md md-previewer">
+ <div ref="markdown-preview" class="md-previewer">
<gl-skeleton-loading v-if="isLoading" />
- <div v-else v-html="previewContent"></div>
+ <div v-else class="md" v-html="previewContent"></div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue
index f085ef35ccc..2b5b2269ec8 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue
@@ -40,12 +40,15 @@ export default {
},
beforeDestroy() {
document.body.removeEventListener('mouseup', this.stopDrag);
- this.$refs.dragger.removeEventListener('mousedown', this.startDrag);
+ document.body.removeEventListener('touchend', this.stopDrag);
+ document.body.removeEventListener('mousemove', this.dragMove);
+ document.body.removeEventListener('touchmove', this.dragMove);
},
methods: {
dragMove(e) {
if (!this.dragging) return;
- const left = e.pageX - this.$refs.dragTrack.getBoundingClientRect().left;
+ const moveX = e.pageX || e.touches[0].pageX;
+ const left = moveX - this.$refs.dragTrack.getBoundingClientRect().left;
const dragTrackWidth =
this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100;
@@ -60,11 +63,13 @@ export default {
this.dragging = true;
document.body.style.userSelect = 'none';
document.body.addEventListener('mousemove', this.dragMove);
+ document.body.addEventListener('touchmove', this.dragMove);
},
stopDrag() {
this.dragging = false;
document.body.style.userSelect = '';
document.body.removeEventListener('mousemove', this.dragMove);
+ document.body.removeEventListener('touchmove', this.dragMove);
},
prepareOnionSkin() {
if (this.onionOldImgInfo && this.onionNewImgInfo) {
@@ -82,6 +87,7 @@ export default {
this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100;
document.body.addEventListener('mouseup', this.stopDrag);
+ document.body.addEventListener('touchend', this.stopDrag);
}
},
onionNewImgLoaded(imgInfo) {
@@ -102,7 +108,7 @@ export default {
:style="{
width: onionMaxPixelWidth,
height: onionMaxPixelHeight,
- 'user-select': dragging === true ? 'none' : '',
+ 'user-select': dragging ? 'none' : null,
}"
class="onion-skin-frame"
>
@@ -140,7 +146,14 @@ export default {
</div>
<div class="controls">
<div class="transparent"></div>
- <div ref="dragTrack" class="drag-track" @mousedown="startDrag" @mouseup="stopDrag">
+ <div
+ ref="dragTrack"
+ class="drag-track"
+ @mousedown="startDrag"
+ @mouseup="stopDrag"
+ @touchstart="startDrag"
+ @touchend="stopDrag"
+ >
<div ref="dragger" :style="{ left: onionDraggerPixelPos }" class="dragger"></div>
</div>
<div class="opaque"></div>
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue
index 1c970b72a66..8d77b156aa4 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue
@@ -46,6 +46,8 @@ export default {
window.removeEventListener('resize', this.resizeThrottled, false);
document.body.removeEventListener('mouseup', this.stopDrag);
document.body.removeEventListener('mousemove', this.dragMove);
+ document.body.removeEventListener('touchend', this.stopDrag);
+ document.body.removeEventListener('touchmove', this.dragMove);
},
mounted() {
window.addEventListener('resize', this.resize, false);
@@ -54,13 +56,13 @@ export default {
dragMove(e) {
if (!this.dragging) return;
- let leftValue = e.pageX - this.$refs.swipeFrame.getBoundingClientRect().left;
- const spaceLeft = 20;
+ const moveX = e.pageX || e.touches[0].pageX;
+ let leftValue = moveX - this.$refs.swipeFrame.getBoundingClientRect().left;
const { clientWidth } = this.$refs.swipeFrame;
if (leftValue <= 0) {
leftValue = 0;
- } else if (leftValue > clientWidth - spaceLeft) {
- leftValue = clientWidth - spaceLeft;
+ } else if (leftValue > clientWidth) {
+ leftValue = clientWidth;
}
this.swipeWrapWidth = (leftValue / clientWidth) * 100;
@@ -68,16 +70,16 @@ export default {
},
startDrag() {
this.dragging = true;
- document.body.style.userSelect = 'none';
document.body.addEventListener('mousemove', this.dragMove);
+ document.body.addEventListener('touchmove', this.dragMove);
},
stopDrag() {
this.dragging = false;
- document.body.style.userSelect = '';
document.body.removeEventListener('mousemove', this.dragMove);
+ document.body.removeEventListener('touchmove', this.dragMove);
},
prepareSwipe() {
- if (this.swipeOldImgInfo && this.swipeNewImgInfo) {
+ if (this.swipeOldImgInfo && this.swipeNewImgInfo && this.swipeOldImgInfo.renderedWidth > 0) {
// Add 2 for border width
this.swipeMaxWidth =
Math.max(this.swipeOldImgInfo.renderedWidth, this.swipeNewImgInfo.renderedWidth) + 2;
@@ -85,6 +87,7 @@ export default {
Math.max(this.swipeOldImgInfo.renderedHeight, this.swipeNewImgInfo.renderedHeight) + 2;
document.body.addEventListener('mouseup', this.stopDrag);
+ document.body.addEventListener('touchend', this.stopDrag);
}
},
swipeNewImgLoaded(imgInfo) {
@@ -97,6 +100,8 @@ export default {
},
resize: _.throttle(function throttledResize() {
this.swipeBarPos = 0;
+ this.swipeWrapWidth = 0;
+ this.prepareSwipe();
}, 400),
},
};
@@ -104,7 +109,15 @@ export default {
<template>
<div class="swipe view">
- <div ref="swipeFrame" class="swipe-frame">
+ <div
+ ref="swipeFrame"
+ :style="{
+ width: swipeMaxPixelWidth,
+ height: swipeMaxPixelHeight,
+ 'user-select': dragging ? 'none' : null,
+ }"
+ class="swipe-frame"
+ >
<image-viewer
key="swipeOldImg"
ref="swipeOldImg"
@@ -139,6 +152,8 @@ export default {
class="swipe-bar"
@mousedown="startDrag"
@mouseup="stopDrag"
+ @touchstart="startDrag"
+ @touchend="stopDrag"
>
<span class="top-handle"></span> <span class="bottom-handle"></span>
</span>
diff --git a/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue b/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue
new file mode 100644
index 00000000000..7d49c87271d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue
@@ -0,0 +1,89 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import Icon from './icon.vue';
+
+export default {
+ components: {
+ Icon,
+ GlButton,
+ },
+ props: {
+ size: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ primaryButtonClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ dropdownClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ actions: {
+ type: Array,
+ required: true,
+ },
+ defaultAction: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ selectedAction: this.defaultAction,
+ };
+ },
+ computed: {
+ selectedActionTitle() {
+ return this.actions[this.selectedAction].title;
+ },
+ buttonSizeClass() {
+ return `btn-${this.size}`;
+ },
+ },
+ methods: {
+ handlePrimaryActionClick() {
+ this.$emit('onActionClick', this.actions[this.selectedAction]);
+ },
+ handleActionClick(selectedAction) {
+ this.selectedAction = selectedAction;
+ this.$emit('onActionSelect', selectedAction);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="btn-group droplab-dropdown comment-type-dropdown">
+ <gl-button :class="primaryButtonClass" :size="size" @click.prevent="handlePrimaryActionClick">
+ {{ selectedActionTitle }}
+ </gl-button>
+ <button
+ :class="buttonSizeClass"
+ type="button"
+ class="btn dropdown-toggle pl-2 pr-2"
+ data-display="static"
+ data-toggle="dropdown"
+ >
+ <icon name="arrow-down" aria-label="toggle dropdown" />
+ </button>
+ <ul :class="dropdownClass" class="dropdown-menu dropdown-open-top">
+ <template v-for="(action, index) in actions">
+ <li :key="index" :class="{ 'droplab-item-selected': selectedAction === index }">
+ <gl-button class="btn-transparent" @click.prevent="handleActionClick(index)">
+ <i aria-hidden="true" class="fa fa-check icon"> </i>
+ <div class="description">
+ <strong>{{ action.title }}</strong>
+ <p>{{ action.description }}</p>
+ </div>
+ </gl-button>
+ </li>
+ <li v-if="index === 0" :key="`${index}-separator`" class="divider droplab-item-ignore"></li>
+ </template>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/empty_component.js b/app/assets/javascripts/vue_shared/components/empty_component.js
new file mode 100644
index 00000000000..e4402020096
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/empty_component.js
@@ -0,0 +1,3 @@
+export default {
+ render: () => null,
+};
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index 0cbcdbf2eb4..1bfa91500cb 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -39,7 +39,7 @@ export default {
},
data() {
return {
- mouseOver: false,
+ dropdownOpen: false,
};
},
computed: {
@@ -123,8 +123,8 @@ export default {
return this.$router.currentRoute.path === `/project${this.file.url}`;
},
- toggleHover(over) {
- this.mouseOver = over;
+ toggleDropdown(val) {
+ this.dropdownOpen = val;
},
},
};
@@ -140,8 +140,7 @@ export default {
class="file-row"
role="button"
@click="clickFile"
- @mouseover="toggleHover(true)"
- @mouseout="toggleHover(false)"
+ @mouseleave="toggleDropdown(false)"
>
<div class="file-row-name-container">
<span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated">
@@ -160,7 +159,8 @@ export default {
:is="extraComponent"
v-if="extraComponent && !(hideExtraOnTree && file.type === 'tree')"
:file="file"
- :mouse-over="mouseOver"
+ :dropdown-open="dropdownOpen"
+ @toggle="toggleDropdown($event)"
/>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index 3f45dc7853b..0bac63b1062 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -37,6 +37,16 @@ export default {
type: Number,
required: true,
},
+ itemIid: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ itemIdTooltip: {
+ type: String,
+ required: false,
+ default: '',
+ },
time: {
type: String,
required: true,
@@ -85,7 +95,12 @@ export default {
<section class="header-main-content">
<ci-icon-badge :status="status" />
- <strong> {{ itemName }} #{{ itemId }} </strong>
+ <strong v-gl-tooltip :title="itemIdTooltip">
+ {{ itemName }} #{{ itemId }}
+ <template v-if="itemIid"
+ >(#{{ itemIid }})</template
+ >
+ </strong>
<template v-if="shouldRenderTriggeredLabel">
triggered
@@ -96,9 +111,8 @@ export default {
<timeago-tooltip :time="time" />
- by
-
<template v-if="user">
+ by
<gl-link
v-gl-tooltip
:href="user.path"
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
index 7e79e63aa1e..715cf97f0ac 100644
--- a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
@@ -62,6 +62,15 @@ export default {
assigneeName: assignee.name,
});
},
+ // This method is for backward compat
+ // since Graph query would return camelCase
+ // props while Rails would return snake_case
+ webUrl(assignee) {
+ return assignee.web_url || assignee.webUrl;
+ },
+ avatarUrl(assignee) {
+ return assignee.avatar_url || assignee.avatarUrl;
+ },
},
};
</script>
@@ -70,9 +79,9 @@ export default {
<user-avatar-link
v-for="assignee in assigneesToShow"
:key="assignee.id"
- :link-href="assignee.web_url"
+ :link-href="webUrl(assignee)"
:img-alt="avatarUrlTitle(assignee)"
- :img-src="assignee.avatar_url"
+ :img-src="avatarUrl(assignee)"
:img-size="24"
class="js-no-trigger"
tooltip-placement="bottom"
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 d5d967e25bf..9b2ee5062b1 100644
--- a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue
@@ -17,15 +17,17 @@ export default {
required: true,
},
},
- data() {
- return {
- milestoneDue: this.milestone.due_date ? parsePikadayDate(this.milestone.due_date) : null,
- milestoneStart: this.milestone.start_date
- ? parsePikadayDate(this.milestone.start_date)
- : null,
- };
- },
computed: {
+ milestoneDue() {
+ const dueDate = this.milestone.due_date || this.milestone.dueDate;
+
+ return dueDate ? parsePikadayDate(dueDate) : null;
+ },
+ milestoneStart() {
+ const startDate = this.milestone.start_date || this.milestone.startDate;
+
+ return startDate ? parsePikadayDate(startDate) : null;
+ },
isMilestoneStarted() {
if (!this.milestoneStart) {
return false;
@@ -72,7 +74,7 @@ export default {
<template>
<div ref="milestoneDetails" class="issue-milestone-details">
<icon :size="16" class="inline icon" name="clock" />
- <span class="milestone-title">{{ milestone.title }}</span>
+ <span class="milestone-title d-inline-block">{{ milestone.title }}</span>
<gl-tooltip :target="() => $refs.milestoneDetails" placement="bottom" class="js-item-milestone">
<span class="bold">{{ __('Milestone') }}</span> <br />
<span>{{ milestone.title }}</span> <br />
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue
index e92babc499b..e438ff16a41 100644
--- a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue
@@ -1,9 +1,17 @@
<script>
+import { GlLink } from '@gitlab/ui';
+import _ from 'underscore';
+import { sprintf } from '~/locale';
import icon from '../../../vue_shared/components/icon.vue';
+function buildDocsLinkStart(path) {
+ return `<a href="${_.escape(path)}" target="_blank" rel="noopener noreferrer">`;
+}
+
export default {
components: {
icon,
+ GlLink,
},
props: {
isLocked: {
@@ -16,6 +24,16 @@ export default {
default: false,
required: false,
},
+ lockedIssueDocsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ confidentialIssueDocsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
warningIcon() {
@@ -27,6 +45,17 @@ export default {
isLockedAndConfidential() {
return this.isConfidential && this.isLocked;
},
+ confidentialAndLockedDiscussionText() {
+ return sprintf(
+ 'This issue is %{confidentialLinkStart}confidential%{linkEnd} and %{lockedLinkStart}locked%{linkEnd}.',
+ {
+ confidentialLinkStart: buildDocsLinkStart(this.confidentialIssueDocsPath),
+ lockedLinkStart: buildDocsLinkStart(this.lockedIssueDocsPath),
+ linkEnd: '</a>',
+ },
+ false,
+ );
+ },
},
};
</script>
@@ -35,20 +64,26 @@ export default {
<icon v-if="!isLockedAndConfidential" :name="warningIcon" :size="16" class="icon inline" />
<span v-if="isLockedAndConfidential">
- {{ __('This issue is confidential and locked.') }}
+ <span v-html="confidentialAndLockedDiscussionText"></span>
{{
- __(`People without permission will never
-get a notification and won't be able to comment.`)
+ __(`People without permission will never get a notification and won't be able to comment.`)
}}
</span>
<span v-else-if="isConfidential">
{{ __('This is a confidential issue.') }}
- {{ __('Your comment will not be visible to the public.') }}
+ {{ __('People without permission will never get a notification.') }}
+ <gl-link :href="confidentialIssueDocsPath" target="_blank">
+ {{ __('Learn more') }}
+ </gl-link>
</span>
<span v-else-if="isLocked">
- {{ __('This issue is locked.') }} {{ __('Only project members can comment.') }}
+ {{ __('This issue is locked.') }}
+ {{ __('Only project members can comment.') }}
+ <gl-link :href="lockedIssueDocsPath" target="_blank">
+ {{ __('Learn more') }}
+ </gl-link>
</span>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
new file mode 100644
index 00000000000..05ad7710a62
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
@@ -0,0 +1,141 @@
+<script>
+import '~/commons/bootstrap';
+import { GlTooltipDirective } from '@gitlab/ui';
+import { sprintf } from '~/locale';
+import IssueMilestone from '../../components/issue/issue_milestone.vue';
+import IssueAssignees from '../../components/issue/issue_assignees.vue';
+import relatedIssuableMixin from '../../mixins/related_issuable_mixin';
+import CiIcon from '../ci_icon.vue';
+
+export default {
+ name: 'IssueItem',
+ components: {
+ IssueMilestone,
+ IssueAssignees,
+ CiIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [relatedIssuableMixin],
+ props: {
+ canReorder: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ greyLinkWhenMerged: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ stateTitle() {
+ return sprintf(
+ '<span class="bold">%{state}</span> %{timeInWords}<br/><span class="text-tertiary">%{timestamp}</span>',
+ {
+ state: this.stateText,
+ timeInWords: this.stateTimeInWords,
+ timestamp: this.stateTimestamp,
+ },
+ );
+ },
+ issueableLinkClass() {
+ return this.greyLinkWhenMerged
+ ? `sortable-link ${this.state === 'merged' ? ' text-secondary' : ''}`
+ : 'sortable-link';
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ :class="{
+ 'issuable-info-container': !canReorder,
+ 'card-body': canReorder,
+ }"
+ class="item-body d-flex align-items-center p-2 p-lg-3 p-xl-2 pl-xl-3"
+ >
+ <div class="item-contents d-flex align-items-center flex-wrap flex-grow-1 flex-xl-nowrap">
+ <div class="item-title d-flex align-items-center mb-1 mb-xl-0">
+ <icon
+ v-if="hasState"
+ v-tooltip
+ :css-classes="iconClass"
+ :name="iconName"
+ :size="16"
+ :title="stateTitle"
+ :aria-label="state"
+ data-html="true"
+ />
+ <icon
+ v-if="confidential"
+ v-gl-tooltip
+ name="eye-slash"
+ :size="16"
+ :title="__('Confidential')"
+ class="confidential-icon append-right-4 align-self-baseline align-self-md-auto mt-xl-0"
+ :aria-label="__('Confidential')"
+ />
+ <a :href="computedPath" :class="issueableLinkClass">{{ title }}</a>
+ </div>
+ <div class="item-meta d-flex flex-wrap mt-xl-0 justify-content-xl-end flex-xl-nowrap">
+ <div
+ class="d-flex align-items-center item-path-id order-md-0 mt-md-0 mt-1 ml-xl-2 mr-xl-auto"
+ >
+ <icon
+ v-if="hasState"
+ v-tooltip
+ :css-classes="iconClass"
+ :name="iconName"
+ :size="16"
+ :title="stateTitle"
+ :aria-label="state"
+ data-html="true"
+ class="d-xl-none"
+ />
+ <span v-tooltip :title="itemPath" class="path-id-text d-inline-block">{{
+ itemPath
+ }}</span>
+ {{ pathIdSeparator }}{{ itemId }}
+ </div>
+ <div
+ class="item-meta-child d-flex align-items-center order-0 flex-wrap mr-md-1 ml-md-auto ml-xl-2 flex-xl-nowrap"
+ >
+ <span v-if="hasPipeline" class="mr-ci-status pr-2">
+ <a :href="pipelineStatus.details_path">
+ <ci-icon v-gl-tooltip :status="pipelineStatus" :title="pipelineStatusTooltip" />
+ </a>
+ </span>
+ <issue-milestone
+ v-if="hasMilestone"
+ :milestone="milestone"
+ class="d-flex align-items-center item-milestone"
+ />
+ <slot name="dueDate"></slot>
+ <slot name="weight"></slot>
+ </div>
+ <issue-assignees
+ v-if="assignees.length"
+ :assignees="assignees"
+ class="item-assignees d-inline-flex align-items-center align-self-end ml-auto ml-md-0 mb-md-0 order-2 flex-xl-grow-0 mt-xl-0 mr-xl-1"
+ />
+ </div>
+ </div>
+ <button
+ v-if="canRemove"
+ ref="removeButton"
+ v-tooltip
+ :disabled="removeDisabled"
+ type="button"
+ class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button qa-remove-issue-button mr-xl-0 align-self-xl-center"
+ title="Remove"
+ aria-label="Remove"
+ @click="onRemoveRequest"
+ >
+ <icon :size="16" class="btn-item-remove-icon" name="close" />
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js b/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js
new file mode 100644
index 00000000000..d1aba99ac22
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js
@@ -0,0 +1,20 @@
+/* eslint-disable import/prefer-default-export */
+
+function trimFirstCharOfLineContent(text) {
+ if (!text) {
+ return text;
+ }
+
+ return text.replace(/^( |\+|-)/, '');
+}
+
+function cleanSuggestionLine(line = {}) {
+ return {
+ ...line,
+ text: trimFirstCharOfLineContent(line.text),
+ };
+}
+
+export function selectDiffLines(lines) {
+ return lines.filter(line => line.type !== 'match').map(line => cleanSuggestionLine(line));
+}
diff --git a/app/assets/javascripts/vue_shared/components/loading_button.vue b/app/assets/javascripts/vue_shared/components/loading_button.vue
index 7a53d053eec..216f6c62e69 100644
--- a/app/assets/javascripts/vue_shared/components/loading_button.vue
+++ b/app/assets/javascripts/vue_shared/components/loading_button.vue
@@ -53,7 +53,7 @@ export default {
<template>
<button :class="containerClass" :disabled="loading || disabled" type="button" @click="onClick">
- <transition name="fade">
+ <transition name="fade-in">
<gl-loading-icon
v-if="loading"
:inline="true"
@@ -63,7 +63,7 @@ export default {
class="js-loading-button-icon"
/>
</transition>
- <transition name="fade">
+ <transition name="fade-in">
<slot>
<span v-if="label" class="js-loading-button-label"> {{ label }} </span>
</slot>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 3f607aa2a0a..0f3b3568414 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -76,6 +76,7 @@ export default {
hasSuggestion: false,
markdownPreviewLoading: false,
previewMarkdown: false,
+ suggestions: this.note.suggestions || [],
};
},
computed: {
@@ -109,9 +110,6 @@ export default {
}
return lineNumber;
},
- suggestions() {
- return this.note.suggestions || [];
- },
lineType() {
return this.line ? this.line.type : '';
},
@@ -175,6 +173,7 @@ export default {
this.referencedCommands = data.references.commands;
this.referencedUsers = data.references.users;
this.hasSuggestion = data.references.suggestions && data.references.suggestions.length;
+ this.suggestions = data.references.suggestions;
}
this.$nextTick()
@@ -189,7 +188,7 @@ export default {
<div
ref="gl-form"
:class="{ 'prepend-top-default append-bottom-default': addSpacingClasses }"
- class="md-area js-vue-markdown-field"
+ class="js-vue-markdown-field md-area position-relative"
>
<markdown-header
:preview-markdown="previewMarkdown"
@@ -215,7 +214,7 @@ export default {
<div
v-show="previewMarkdown"
ref="markdown-preview"
- class="md-preview js-vue-md-preview md md-preview-holder"
+ class="js-vue-md-preview md-preview-holder"
>
<suggestions
v-if="hasSuggestion"
@@ -233,7 +232,7 @@ export default {
<div
v-show="previewMarkdown"
ref="markdown-preview"
- class="md-preview js-vue-md-preview md md-preview-holder"
+ class="js-vue-md-preview md md-preview-holder"
v-html="markdownPreview"
></div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index dbfa32cd0ce..a5a5b2ef415 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -38,7 +38,7 @@ export default {
].join('\n');
},
mdSuggestion() {
- return ['```suggestion', `{text}`, '```'].join('\n');
+ return ['```suggestion:-0+0', `{text}`, '```'].join('\n');
},
},
mounted() {
@@ -79,7 +79,7 @@ export default {
<ul class="nav-links clearfix">
<li :class="{ active: !previewMarkdown }" class="md-header-tab">
<button class="js-write-link" tabindex="-1" type="button" @click="writeMarkdownTab($event)">
- Write
+ {{ __('Write') }}
</button>
</li>
<li :class="{ active: previewMarkdown }" class="md-header-tab">
@@ -89,36 +89,41 @@ export default {
type="button"
@click="previewMarkdownTab($event)"
>
- Preview
+ {{ __('Preview') }}
</button>
</li>
<li :class="{ active: !previewMarkdown }" class="md-header-toolbar">
- <toolbar-button tag="**" button-title="Add bold text" icon="bold" />
- <toolbar-button tag="*" button-title="Add italic text" icon="italic" />
- <toolbar-button :prepend="true" tag="> " button-title="Insert a quote" icon="quote" />
- <toolbar-button tag="`" tag-block="```" button-title="Insert code" icon="code" />
+ <toolbar-button tag="**" :button-title="__('Add bold text')" icon="bold" />
+ <toolbar-button tag="*" :button-title="__('Add italic text')" icon="italic" />
+ <toolbar-button
+ :prepend="true"
+ tag="> "
+ :button-title="__('Insert a quote')"
+ icon="quote"
+ />
+ <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" />
<toolbar-button
tag="[{text}](url)"
tag-select="url"
- button-title="Add a link"
+ :button-title="__('Add a link')"
icon="link"
/>
<toolbar-button
:prepend="true"
tag="* "
- button-title="Add a bullet list"
+ :button-title="__('Add a bullet list')"
icon="list-bulleted"
/>
<toolbar-button
:prepend="true"
tag="1. "
- button-title="Add a numbered list"
+ :button-title="__('Add a numbered list')"
icon="list-numbered"
/>
<toolbar-button
:prepend="true"
tag="* [ ] "
- button-title="Add a task list"
+ :button-title="__('Add a task list')"
icon="task-done"
/>
<toolbar-button
@@ -139,11 +144,11 @@ export default {
/>
<button
v-gl-tooltip
- aria-label="Go full screen"
+ :aria-label="__('Go full screen')"
class="toolbar-btn toolbar-fullscreen-btn js-zen-enter"
data-container="body"
tabindex="-1"
- title="Go full screen"
+ :title="__('Go full screen')"
type="button"
>
<icon name="screen-full" />
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
index a351ca62c94..2eb4ec12a4a 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
@@ -1,24 +1,14 @@
<script>
import SuggestionDiffHeader from './suggestion_diff_header.vue';
+import SuggestionDiffRow from './suggestion_diff_row.vue';
+import { selectDiffLines } from '../lib/utils/diff_utils';
export default {
components: {
SuggestionDiffHeader,
+ SuggestionDiffRow,
},
props: {
- newLines: {
- type: Array,
- required: true,
- },
- fromContent: {
- type: String,
- required: false,
- default: '',
- },
- fromLine: {
- type: Number,
- required: true,
- },
suggestion: {
type: Object,
required: true,
@@ -33,6 +23,11 @@ export default {
required: true,
},
},
+ computed: {
+ lines() {
+ return selectDiffLines(this.suggestion.diff_lines);
+ },
+ },
methods: {
applySuggestion(callback) {
this.$emit('apply', { suggestionId: this.suggestion.id, callback });
@@ -52,22 +47,11 @@ export default {
/>
<table class="mb-3 md-suggestion-diff js-syntax-highlight code">
<tbody>
- <!-- Old Line -->
- <tr class="line_holder old">
- <td class="diff-line-num old_line qa-old-diff-line-number old">{{ fromLine }}</td>
- <td class="diff-line-num new_line old"></td>
- <td class="line_content old">
- <span>{{ fromContent }}</span>
- </td>
- </tr>
- <!-- New Line(s) -->
- <tr v-for="(line, key) of newLines" :key="key" class="line_holder new">
- <td class="diff-line-num old_line new"></td>
- <td class="diff-line-num new_line qa-new-diff-line-number new">{{ line.lineNumber }}</td>
- <td class="line_content new">
- <span>{{ line.content }}</span>
- </td>
- </tr>
+ <suggestion-diff-row
+ v-for="(line, index) of lines"
+ :key="`${index}-${line.text}`"
+ :line="line"
+ />
</tbody>
</table>
</div>
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 c5a2aa1f2af..32783b85df4 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,8 +1,10 @@
<script>
import Icon from '~/vue_shared/components/icon.vue';
+import { GlButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
- components: { Icon },
+ components: { Icon, GlButton, GlLoadingIcon },
+ directives: { 'gl-tooltip': GlTooltipDirective },
props: {
canApply: {
type: Boolean,
@@ -21,7 +23,6 @@ export default {
},
data() {
return {
- isAppliedSuccessfully: false,
isApplying: false,
};
},
@@ -47,14 +48,19 @@ export default {
</a>
</div>
<span v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</span>
- <button
- v-if="canApply"
- type="button"
- class="btn qa-apply-btn"
+ <div v-if="isApplying" class="d-flex align-items-center text-secondary">
+ <gl-loading-icon class="d-flex-center mr-2" />
+ <span>{{ __('Applying suggestion') }}</span>
+ </div>
+ <gl-button
+ v-else-if="canApply"
+ v-gl-tooltip.viewport="__('This also resolves the discussion')"
+ class="btn-inverted qa-apply-btn"
:disabled="isApplying"
+ variant="success"
@click="applySuggestion"
>
{{ __('Apply suggestion') }}
- </button>
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue
new file mode 100644
index 00000000000..c09bdfec250
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue
@@ -0,0 +1,32 @@
+<script>
+export default {
+ name: 'SuggestionDiffRow',
+ props: {
+ line: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ lineType() {
+ return this.line.type;
+ },
+ },
+};
+</script>
+
+<template>
+ <tr class="line_holder" :class="lineType">
+ <td class="diff-line-num old_line border-top-0 border-bottom-0" :class="lineType">
+ {{ line.old_line }}
+ </td>
+ <td class="diff-line-num new_line border-top-0 border-bottom-0" :class="lineType">
+ {{ line.new_line }}
+ </td>
+ <td class="line_content" :class="lineType">
+ <span v-if="line.text">{{ line.text }}</span>
+ <!-- TODO: replace this hack with zero-width whitespace when we have rich_text from BE -->
+ <span v-else>&#8203;</span>
+ </td>
+ </tr>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index dcda701f049..8d3705e1e4a 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -6,16 +6,6 @@ import Flash from '~/flash';
export default {
components: { SuggestionDiff },
props: {
- fromLine: {
- type: Number,
- required: false,
- default: 0,
- },
- fromContent: {
- type: String,
- required: false,
- default: '',
- },
lineType: {
type: String,
required: false,
@@ -71,41 +61,19 @@ export default {
suggestionElements.forEach((suggestionEl, i) => {
const suggestionParentEl = suggestionEl.parentElement;
- const newLines = this.extractNewLines(suggestionParentEl);
- const diffComponent = this.generateDiff(newLines, i);
+ const diffComponent = this.generateDiff(i);
diffComponent.$mount(suggestionParentEl);
});
this.isRendered = true;
},
- extractNewLines(suggestionEl) {
- // extracts the suggested lines from the markdown
- // calculates a line number for each line
-
- const newLines = suggestionEl.querySelectorAll('.line');
- const fromLine = this.suggestions.length ? this.suggestions[0].from_line : this.fromLine;
- const lines = [];
-
- newLines.forEach((line, i) => {
- const content = `${line.innerText}\n`;
- const lineNumber = fromLine + i;
- lines.push({ content, lineNumber });
- });
-
- return lines;
- },
- generateDiff(newLines, suggestionIndex) {
- // generates the diff <suggestion-diff /> component
- // all `suggestion` markdown will be swapped out by this component
-
+ generateDiff(suggestionIndex) {
const { suggestions, disabled, helpPagePath } = this;
const suggestion =
suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {};
- const fromContent = suggestion.from_content || this.fromContent;
- const fromLine = suggestion.from_line || this.fromLine;
const SuggestionDiffComponent = Vue.extend(SuggestionDiff);
const suggestionDiff = new SuggestionDiffComponent({
- propsData: { newLines, fromLine, fromContent, disabled, suggestion, helpPagePath },
+ propsData: { disabled, suggestion, helpPagePath },
});
suggestionDiff.$on('apply', ({ suggestionId, callback }) => {
@@ -130,6 +98,6 @@ export default {
<template>
<div>
<div class="flash-container js-suggestions-flash"></div>
- <div v-show="isRendered" ref="container" v-html="noteHtml"></div>
+ <div v-show="isRendered" ref="container" class="md" v-html="noteHtml"></div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 3b57b5e8da4..d6c398c8946 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -33,37 +33,36 @@ export default {
<div class="comment-toolbar clearfix">
<div class="toolbar-text">
<template v-if="!hasQuickActionsDocsPath && markdownDocsPath">
- <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">
- Markdown is supported
- </gl-link>
+ <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1"
+ >Markdown is supported</gl-link
+ >
</template>
<template v-if="hasQuickActionsDocsPath && markdownDocsPath">
- <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1"> Markdown </gl-link>
- and
- <gl-link :href="quickActionsDocsPath" target="_blank" tabindex="-1">
- quick actions
- </gl-link>
+ <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">Markdown</gl-link> and
+ <gl-link :href="quickActionsDocsPath" target="_blank" tabindex="-1">quick actions</gl-link>
are supported
</template>
</div>
<span v-if="canAttachFile" class="uploading-container">
<span class="uploading-progress-container hide">
- <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"> </i>
- <span class="attaching-file-message"></span> <span class="uploading-progress">0%</span>
+ <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i>
+ <span class="attaching-file-message"></span>
+ <span class="uploading-progress">0%</span>
<span class="uploading-spinner">
- <i class="fa fa-spinner fa-spin toolbar-button-icon" aria-hidden="true"> </i>
+ <i class="fa fa-spinner fa-spin toolbar-button-icon" aria-hidden="true"></i>
</span>
</span>
<span class="uploading-error-container hide">
<span class="uploading-error-icon">
- <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"> </i>
+ <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i>
</span>
<span class="uploading-error-message"></span>
<button class="retry-uploading-link" type="button">Try again</button> or
<button class="attach-new-file markdown-selector" type="button">attach a new file</button>
</span>
- <button class="markdown-selector button-attach-file" tabindex="-1" type="button">
- <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"> </i> Attach a file
+ <button class="markdown-selector button-attach-file btn-link" tabindex="-1" type="button">
+ <i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i
+ ><span class="text-attach-file">Attach a file</span>
</button>
<button class="btn btn-default btn-sm hide button-cancel-uploading-files" type="button">
Cancel
diff --git a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
new file mode 100644
index 00000000000..bf59a6abf3f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
@@ -0,0 +1,121 @@
+<script>
+import $ from 'jquery';
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+import Clipboard from 'clipboard';
+
+export default {
+ components: {
+ GlButton,
+ Icon,
+ },
+
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+
+ props: {
+ text: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ container: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ modalId: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ target: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ tooltipPlacement: {
+ type: String,
+ required: false,
+ default: 'top',
+ },
+ tooltipContainer: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+
+ copySuccessText: __('Copied'),
+
+ computed: {
+ modalDomId() {
+ return this.modalId ? `#${this.modalId}` : '';
+ },
+ },
+
+ mounted() {
+ this.$nextTick(() => {
+ this.clipboard = new Clipboard(this.$el, {
+ container:
+ document.querySelector(`${this.modalDomId} div.modal-content`) ||
+ document.getElementById(this.container) ||
+ document.body,
+ });
+ this.clipboard
+ .on('success', e => {
+ this.updateTooltip(e.trigger);
+ this.$emit('success', e);
+ // Clear the selection and blur the trigger so it loses its border
+ e.clearSelection();
+ $(e.trigger).blur();
+ })
+ .on('error', e => this.$emit('error', e));
+ });
+ },
+
+ destroyed() {
+ if (this.clipboard) {
+ this.clipboard.destroy();
+ }
+ },
+
+ methods: {
+ updateTooltip(target) {
+ const $target = $(target);
+ const originalTitle = $target.data('originalTitle');
+
+ if ($target.tooltip) {
+ /**
+ * The original tooltip will continue staying there unless we remove it by hand.
+ * $target.tooltip('hide') isn't working.
+ */
+ $('.tooltip').remove();
+ $target.attr('title', this.$options.copySuccessText);
+ $target.tooltip('_fixTitle');
+ $target.tooltip('show');
+ $target.attr('title', originalTitle);
+ $target.tooltip('_fixTitle');
+ }
+ },
+ },
+};
+</script>
+<template>
+ <gl-button
+ v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }"
+ :data-clipboard-target="target"
+ :data-clipboard-text="text"
+ :title="title"
+ >
+ <slot>
+ <icon name="duplicate" />
+ </slot>
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
index 8d3a3009c55..baed26a157c 100644
--- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
@@ -51,13 +51,13 @@ export default {
<div class="note-header">
<div class="note-header-info">
<a :href="getUserData.path">
- <span class="d-none d-sm-inline-block">{{ getUserData.name }}</span>
+ <span class="d-none d-sm-inline-block bold">{{ getUserData.name }}</span>
<span class="note-headline-light">@{{ getUserData.username }}</span>
</a>
</div>
</div>
<div class="note-body">
- <div class="note-text">
+ <div class="note-text md">
<p>{{ note.body }}</p>
</div>
</div>
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 b0af8399955..3c86b7e4c61 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -22,6 +22,7 @@ import noteHeader from '~/notes/components/note_header.vue';
import Icon from '~/vue_shared/components/icon.vue';
import TimelineEntryItem from './timeline_entry_item.vue';
import { spriteIcon } from '../../../lib/utils/common_utils';
+import initMRPopovers from '~/mr_popover/';
const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
@@ -71,6 +72,9 @@ export default {
);
},
},
+ mounted() {
+ initMRPopovers(this.$el.querySelectorAll('.gfm-merge_request'));
+ },
};
</script>
@@ -93,7 +97,7 @@ export default {
'system-note-commit-list': hasMoreCommits,
'hide-shade': expanded,
}"
- class="note-text"
+ class="note-text md"
v-html="note.note_html"
></div>
<div v-if="hasMoreCommits" class="flex-list">
diff --git a/app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue b/app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue
index 06974a12aed..f316c4fe112 100644
--- a/app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue
@@ -1,9 +1,3 @@
-<script>
-export default {
- name: 'TimelineEntryItem',
-};
-</script>
-
<template>
<li class="timeline-entry">
<div class="timeline-entry-inner"><slot></slot></div>
diff --git a/app/assets/javascripts/vue_shared/components/pikaday.vue b/app/assets/javascripts/vue_shared/components/pikaday.vue
index fa502b9beb9..8104d919bf6 100644
--- a/app/assets/javascripts/vue_shared/components/pikaday.vue
+++ b/app/assets/javascripts/vue_shared/components/pikaday.vue
@@ -34,7 +34,7 @@ export default {
format: 'yyyy-mm-dd',
container: this.$el,
defaultDate: this.selectedDate,
- setDefaultDate: !!this.selectedDate,
+ setDefaultDate: Boolean(this.selectedDate),
minDate: this.minDate,
maxDate: this.maxDate,
parse: dateString => parsePikadayDate(dateString),
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
new file mode 100644
index 00000000000..071bae7f665
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
@@ -0,0 +1,74 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+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',
+ components: {
+ Icon,
+ ProjectAvatar,
+ GlButton,
+ },
+ props: {
+ project: {
+ type: Object,
+ required: true,
+ validator: p => _.isFinite(p.id) && _.isString(p.name) && _.isString(p.name_with_namespace),
+ },
+ selected: {
+ type: Boolean,
+ required: true,
+ },
+ matcher: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ truncatedNamespace() {
+ return truncateNamespace(this.project.name_with_namespace);
+ },
+ highlightedProjectName() {
+ return highlight(this.project.name, this.matcher);
+ },
+ },
+ methods: {
+ onClick() {
+ this.$emit('click');
+ },
+ },
+};
+</script>
+<template>
+ <gl-button
+ class="d-flex align-items-center btn pt-1 pb-1 border-0 project-list-item"
+ @click="onClick"
+ >
+ <icon
+ class="prepend-left-10 append-right-10 flex-shrink-0 position-top-0 js-selected-icon"
+ :class="{ 'js-selected visible': selected, 'js-unselected invisible': !selected }"
+ name="mobile-issue-close"
+ />
+ <project-avatar class="flex-shrink-0 js-project-avatar" :project="project" :size="32" />
+ <div class="d-flex flex-wrap project-namespace-name-container">
+ <div
+ v-if="truncatedNamespace"
+ :title="project.name_with_namespace"
+ class="text-secondary text-truncate js-project-namespace"
+ >
+ {{ truncatedNamespace }}
+ <span v-if="truncatedNamespace" class="text-secondary">/&nbsp;</span>
+ </div>
+ <div
+ :title="project.name"
+ class="js-project-name text-truncate"
+ v-html="highlightedProjectName"
+ ></div>
+ </div>
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
new file mode 100644
index 00000000000..596fd48f96a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
@@ -0,0 +1,103 @@
+<script>
+import _ from 'underscore';
+import { GlLoadingIcon } from '@gitlab/ui';
+import ProjectListItem from './project_list_item.vue';
+
+const SEARCH_INPUT_TIMEOUT_MS = 500;
+
+export default {
+ name: 'ProjectSelector',
+ components: {
+ GlLoadingIcon,
+ ProjectListItem,
+ },
+ props: {
+ projectSearchResults: {
+ type: Array,
+ required: true,
+ },
+ selectedProjects: {
+ type: Array,
+ required: true,
+ },
+ showNoResultsMessage: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showMinimumSearchQueryMessage: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showLoadingIndicator: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showSearchErrorMessage: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ searchQuery: '',
+ };
+ },
+ methods: {
+ projectClicked(project) {
+ this.$emit('projectClicked', project);
+ },
+ isSelected(project) {
+ return Boolean(_.findWhere(this.selectedProjects, { id: project.id }));
+ },
+ focusSearchInput() {
+ this.$refs.searchInput.focus();
+ },
+ onInput: _.debounce(function debouncedOnInput() {
+ this.$emit('searched', this.searchQuery);
+ }, SEARCH_INPUT_TIMEOUT_MS),
+ },
+};
+</script>
+<template>
+ <div>
+ <input
+ ref="searchInput"
+ v-model="searchQuery"
+ :placeholder="__('Search your projects')"
+ type="search"
+ class="form-control mb-3 js-project-selector-input"
+ autofocus
+ @input="onInput"
+ />
+ <div class="d-flex flex-column">
+ <gl-loading-icon v-if="showLoadingIndicator" :size="2" class="py-2 px-4" />
+ <div v-if="!showLoadingIndicator" class="d-flex flex-column">
+ <project-list-item
+ v-for="project in projectSearchResults"
+ :key="project.id"
+ :selected="isSelected(project)"
+ :project="project"
+ :matcher="searchQuery"
+ class="js-project-list-item"
+ @click="projectClicked(project)"
+ />
+ </div>
+ <div v-if="showNoResultsMessage" class="text-muted ml-2 js-no-results-message">
+ {{ __('Sorry, no projects matched your search') }}
+ </div>
+ <div
+ v-if="showMinimumSearchQueryMessage"
+ class="text-muted ml-2 js-minimum-search-query-message"
+ >
+ {{ __('Enter at least three characters to search') }}
+ </div>
+ <div v-if="showSearchErrorMessage" class="text-danger ml-2 js-search-error-message">
+ {{ __('Something went wrong, unable to search projects') }}
+ </div>
+ </div>
+ </div>
+</template>
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
new file mode 100644
index 00000000000..1f3d248e991
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue
@@ -0,0 +1,40 @@
+<script>
+import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
+import $ from 'jquery';
+
+export default {
+ data() {
+ return {
+ width: 0,
+ height: 0,
+ };
+ },
+ beforeDestroy() {
+ this.contentResizeHandler.off('content.resize', this.debouncedResize);
+ window.removeEventListener('resize', this.debouncedResize);
+ },
+ created() {
+ this.debouncedResize = debounceByAnimationFrame(this.onResize);
+
+ // Handle when we explicictly trigger a custom resize event
+ this.contentResizeHandler = $(document).on('content.resize', this.debouncedResize);
+
+ // Handle window resize
+ window.addEventListener('resize', this.debouncedResize);
+ },
+ methods: {
+ onResize() {
+ // Slot dimensions
+ const { clientWidth, clientHeight } = this.$refs.chartWrapper;
+ this.width = clientWidth;
+ this.height = clientHeight;
+ },
+ },
+};
+</script>
+
+<template>
+ <div ref="chartWrapper">
+ <slot :width="width" :height="height"> </slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/select2_select.vue b/app/assets/javascripts/vue_shared/components/select2_select.vue
index 3074ea859cc..6d2612556ff 100644
--- a/app/assets/javascripts/vue_shared/components/select2_select.vue
+++ b/app/assets/javascripts/vue_shared/components/select2_select.vue
@@ -1,6 +1,6 @@
<script>
import $ from 'jquery';
-import 'select2/select2';
+import 'select2';
export default {
name: 'Select2Select',
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 f66e81b1e08..9c258c4651f 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
@@ -75,6 +75,16 @@ export default {
required: false,
default: false,
},
+ enableScopedLabels: {
+ type: Boolean,
+ require: false,
+ default: false,
+ },
+ scopedLabelsDocumentationLink: {
+ type: String,
+ require: false,
+ default: '#',
+ },
},
computed: {
hiddenInputName() {
@@ -123,7 +133,12 @@ export default {
@onValueClick="handleCollapsedValueClick"
/>
<dropdown-title :can-edit="canEdit" />
- <dropdown-value :labels="context.labels" :label-filter-base-path="labelFilterBasePath">
+ <dropdown-value
+ :labels="context.labels"
+ :label-filter-base-path="labelFilterBasePath"
+ :scoped-labels-documentation-link="scopedLabelsDocumentationLink"
+ :enable-scoped-labels="enableScopedLabels"
+ >
<slot></slot>
</dropdown-value>
<div v-if="canEdit" class="selectbox js-selectbox" style="display: none;">
@@ -142,6 +157,8 @@ export default {
:namespace="namespace"
:labels="context.labels"
:show-extra-options="!showCreate"
+ :scoped-labels-documentation-link="scopedLabelsDocumentationLink"
+ :enable-scoped-labels="enableScopedLabels"
/>
<div
class="dropdown-menu dropdown-select dropdown-menu-paging
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
index 498b507d11d..1eed8907bb7 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
@@ -31,6 +31,16 @@ export default {
type: Boolean,
required: true,
},
+ enableScopedLabels: {
+ type: Boolean,
+ require: false,
+ default: false,
+ },
+ scopedLabelsDocumentationLink: {
+ type: String,
+ require: false,
+ default: '#',
+ },
},
computed: {
dropdownToggleText() {
@@ -61,6 +71,8 @@ export default {
:data-labels="labelsPath"
:data-namespace-path="namespace"
:data-show-any="showExtraOptions"
+ :data-scoped-labels="enableScopedLabels"
+ :data-scoped-labels-documentation-link="scopedLabelsDocumentationLink"
type="button"
class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal"
data-toggle="dropdown"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
index 6faf3fafad1..4abf7c478ee 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
@@ -1,9 +1,12 @@
<script>
-import tooltip from '~/vue_shared/directives/tooltip';
+import DropdownValueScopedLabel from './dropdown_value_scoped_label.vue';
+import DropdownValueRegularLabel from './dropdown_value_regular_label.vue';
+import { isScopedLabel } from '~/lib/utils/common_utils';
export default {
- directives: {
- tooltip,
+ components: {
+ DropdownValueScopedLabel,
+ DropdownValueRegularLabel,
},
props: {
labels: {
@@ -14,6 +17,16 @@ export default {
type: String,
required: true,
},
+ enableScopedLabels: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ scopedLabelsDocumentationLink: {
+ type: String,
+ required: false,
+ default: '#',
+ },
},
computed: {
isEmpty() {
@@ -30,6 +43,12 @@ export default {
backgroundColor: label.color,
};
},
+ scopedLabelsDescription({ description = '' }) {
+ return `<span class="font-weight-bold scoped-label-tooltip-title">Scoped label</span><br />${description}`;
+ },
+ showScopedLabels(label) {
+ return this.enableScopedLabels && isScopedLabel(label);
+ },
},
};
</script>
@@ -44,17 +63,24 @@ export default {
<span v-if="isEmpty" class="text-secondary">
<slot>{{ __('None') }}</slot>
</span>
- <a v-for="label in labels" v-else :key="label.id" :href="labelFilterUrl(label)">
- <span
- v-tooltip
- :style="labelStyle(label)"
- :title="label.description"
- class="badge color-label"
- data-placement="bottom"
- data-container="body"
- >
- {{ label.title }}
- </span>
- </a>
+
+ <template v-for="label in labels" v-else>
+ <dropdown-value-scoped-label
+ v-if="showScopedLabels(label)"
+ :key="label.id"
+ :label="label"
+ :label-filter-url="labelFilterUrl(label)"
+ :label-style="labelStyle(label)"
+ :scoped-labels-documentation-link="scopedLabelsDocumentationLink"
+ />
+
+ <dropdown-value-regular-label
+ v-else
+ :key="label.id"
+ :label="label"
+ :label-filter-url="labelFilterUrl(label)"
+ :label-style="labelStyle(label)"
+ />
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue
index 373794fb1f2..05446903286 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue
@@ -14,10 +14,12 @@ export default {
},
computed: {
labelsList() {
- const labelsString = this.labels
- .slice(0, 5)
- .map(label => label.title)
- .join(', ');
+ const labelsString = this.labels.length
+ ? this.labels
+ .slice(0, 5)
+ .map(label => label.title)
+ .join(', ')
+ : s__('LabelSelect|Labels');
if (this.labels.length > 5) {
return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue
new file mode 100644
index 00000000000..282b181f11e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue
@@ -0,0 +1,35 @@
+<script>
+import { GlLink, GlTooltip } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlTooltip,
+ GlLink,
+ },
+ props: {
+ label: {
+ type: Object,
+ required: true,
+ },
+ labelStyle: {
+ type: Object,
+ required: true,
+ },
+ labelFilterUrl: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <a ref="regularLabelRef" :href="labelFilterUrl">
+ <span :style="labelStyle" class="badge color-label">
+ {{ label.title }}
+ </span>
+ <gl-tooltip :target="() => $refs.regularLabelRef" placement="top" boundary="viewport">
+ {{ label.description }}
+ </gl-tooltip>
+ </a>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue
new file mode 100644
index 00000000000..ad5a86de166
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue
@@ -0,0 +1,47 @@
+<script>
+import { GlLink, GlTooltip } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlTooltip,
+ GlLink,
+ },
+ props: {
+ label: {
+ type: Object,
+ required: true,
+ },
+ labelStyle: {
+ type: Object,
+ required: true,
+ },
+ scopedLabelsDocumentationLink: {
+ type: String,
+ required: true,
+ },
+ labelFilterUrl: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <span class="d-inline-block position-relative scoped-label-wrapper">
+ <a :href="labelFilterUrl">
+ <span :ref="`labelTitleRef`" :style="labelStyle" class="badge color-label label">
+ {{ label.title }}
+ </span>
+ <gl-tooltip :target="() => $refs.labelTitleRef" placement="top" boundary="viewport">
+ <span class="font-weight-bold scoped-label-tooltip-title">{{ __('Scoped label') }}</span
+ ><br />
+ {{ label.description }}
+ </gl-tooltip>
+ </a>
+
+ <gl-link :href="scopedLabelsDocumentationLink" target="_blank" class="label scoped-label"
+ ><i class="fa fa-question-circle" :style="labelStyle"></i
+ ></gl-link>
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/svg_gradient.vue b/app/assets/javascripts/vue_shared/components/svg_gradient.vue
index cca90af275e..5ce45d492f9 100644
--- a/app/assets/javascripts/vue_shared/components/svg_gradient.vue
+++ b/app/assets/javascripts/vue_shared/components/svg_gradient.vue
@@ -4,10 +4,16 @@ export default {
colors: {
type: Array,
required: true,
+ validator(value) {
+ return value.length === 2;
+ },
},
opacity: {
type: Array,
required: true,
+ validator(value) {
+ return value.length === 2;
+ },
},
identifierName: {
type: String,
diff --git a/app/assets/javascripts/vue_shared/components/table_pagination.vue b/app/assets/javascripts/vue_shared/components/table_pagination.vue
index 2a34b4630f2..9cce9a4e542 100644
--- a/app/assets/javascripts/vue_shared/components/table_pagination.vue
+++ b/app/assets/javascripts/vue_shared/components/table_pagination.vue
@@ -54,15 +54,14 @@ export default {
return this.pageInfo.nextPage;
},
getItems() {
- const total = this.pageInfo.totalPages;
- const { page } = this.pageInfo;
+ const { totalPages, nextPage, previousPage, page } = this.pageInfo;
const items = [];
if (page > 1) {
items.push({ title: FIRST, first: true });
}
- if (page > 1) {
+ if (previousPage) {
items.push({ title: PREV, prev: true });
} else {
items.push({ title: PREV, disabled: true, prev: true });
@@ -70,32 +69,34 @@ export default {
if (page > UI_LIMIT) items.push({ title: SPREAD, separator: true });
- const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1);
- const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, total);
+ 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 });
- }
+ for (let i = start; i <= end; i += 1) {
+ const isActive = i === page;
+ items.push({ title: i, active: isActive, page: true });
+ }
- if (total - page > PAGINATION_UI_BUTTON_LIMIT) {
- items.push({ title: SPREAD, separator: true, page: true });
+ if (totalPages - page > PAGINATION_UI_BUTTON_LIMIT) {
+ items.push({ title: SPREAD, separator: true, page: true });
+ }
}
- if (page === total) {
- items.push({ title: NEXT, disabled: true, next: true });
- } else if (total - page >= 1) {
+ if (nextPage) {
items.push({ title: NEXT, next: true });
+ } else {
+ items.push({ title: NEXT, disabled: true, next: true });
}
- if (total - page >= 1) {
+ if (totalPages && totalPages - page >= 1) {
items.push({ title: LAST, last: true });
}
return items;
},
showPagination() {
- return this.pageInfo.totalPages > 1;
+ return this.pageInfo.nextPage || this.pageInfo.previousPage;
},
},
methods: {
@@ -120,7 +121,7 @@ export default {
this.change(1);
break;
default:
- this.change(+text);
+ this.change(Number(text));
break;
}
},
@@ -149,9 +150,9 @@ export default {
}"
class="page-item"
>
- <a class="page-link" @click.prevent="changePage(item.title, item.disabled)">
+ <button type="button" class="page-link" @click="changePage(item.title, item.disabled)">
{{ item.title }}
- </a>
+ </button>
</li>
</ul>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index f9773622001..a60d5eb491e 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -1,11 +1,13 @@
<script>
import { GlPopover, GlSkeletonLoading } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
import { glEmojiTag } from '../../../emoji';
export default {
name: 'UserPopover',
components: {
+ Icon,
GlPopover,
GlSkeletonLoading,
UserAvatarImage,
@@ -68,16 +70,31 @@ export default {
<gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" />
</div>
<div class="text-secondary">
- <div v-if="user.bio" class="js-bio">{{ user.bio }}</div>
- <div v-if="user.organization" class="js-organization">{{ user.organization }}</div>
+ <div v-if="user.bio" class="js-bio d-flex mb-1">
+ <icon name="profile" css-classes="category-icon flex-shrink-0" />
+ <span class="ml-1">{{ user.bio }}</span>
+ </div>
+ <div v-if="user.organization" class="js-organization d-flex mb-1">
+ <icon
+ v-show="!jobInfoIsLoading"
+ name="work"
+ css-classes="category-icon flex-shrink-0"
+ />
+ <span class="ml-1">{{ user.organization }}</span>
+ </div>
<gl-skeleton-loading
v-if="jobInfoIsLoading"
:lines="1"
class="animation-container-small mb-1"
/>
</div>
- <div class="text-secondary">
- {{ user.location }}
+ <div class="js-location text-secondary d-flex">
+ <icon
+ v-show="!locationIsLoading && user.location"
+ name="location"
+ css-classes="category-icon flex-shrink-0"
+ />
+ <span class="ml-1">{{ user.location }}</span>
<gl-skeleton-loading
v-if="locationIsLoading"
:lines="1"
diff --git a/app/assets/javascripts/vue_shared/directives/tooltip.js b/app/assets/javascripts/vue_shared/directives/tooltip.js
index 549d27e96d9..2d1f7a1cfd0 100644
--- a/app/assets/javascripts/vue_shared/directives/tooltip.js
+++ b/app/assets/javascripts/vue_shared/directives/tooltip.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import '~/commons/bootstrap';
export default {
bind(el) {
diff --git a/app/assets/javascripts/vue_shared/mixins/is_ee.js b/app/assets/javascripts/vue_shared/mixins/is_ee.js
new file mode 100644
index 00000000000..8e00d93ef18
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/mixins/is_ee.js
@@ -0,0 +1,10 @@
+import Vue from 'vue';
+import { isEE } from '~/lib/utils/common_utils';
+
+Vue.mixin({
+ computed: {
+ isEE() {
+ return isEE();
+ },
+ },
+});
diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
new file mode 100644
index 00000000000..8e0e4baa75a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
@@ -0,0 +1,217 @@
+import _ from 'underscore';
+import { sprintf, __ } from '~/locale';
+import { formatDate } from '~/lib/utils/datetime_utility';
+import tooltip from '~/vue_shared/directives/tooltip';
+import icon from '~/vue_shared/components/icon.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+
+const mixins = {
+ data() {
+ return {
+ removeDisabled: false,
+ };
+ },
+ props: {
+ idKey: {
+ type: Number,
+ required: true,
+ },
+ displayReference: {
+ type: String,
+ required: true,
+ },
+ pathIdSeparator: {
+ type: String,
+ required: true,
+ },
+ eventNamespace: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ confidential: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ title: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ path: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ state: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ createdAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ closedAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ mergedAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ milestone: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ dueDate: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ assignees: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ weight: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ canRemove: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isMergeRequest: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ pipelineStatus: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ components: {
+ icon,
+ },
+ directives: {
+ tooltip,
+ },
+ mixins: [timeagoMixin],
+ computed: {
+ hasState() {
+ return this.state && this.state.length > 0;
+ },
+ hasPipeline() {
+ return this.isMergeRequest && this.pipelineStatus && Object.keys(this.pipelineStatus).length;
+ },
+ isOpen() {
+ return this.state === 'opened';
+ },
+ isClosed() {
+ return this.state === 'closed';
+ },
+ isMerged() {
+ return this.state === 'merged';
+ },
+ hasTitle() {
+ return this.title.length > 0;
+ },
+ hasMilestone() {
+ return !_.isEmpty(this.milestone);
+ },
+ iconName() {
+ if (this.isMergeRequest && this.isMerged) {
+ return 'merge';
+ }
+
+ return this.isOpen ? 'issue-open-m' : 'issue-close';
+ },
+ iconClass() {
+ if (this.isMergeRequest && this.isClosed) {
+ return 'merge-request-status closed issue-token-state-icon-closed';
+ }
+
+ return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed';
+ },
+ computedLinkElementType() {
+ return this.path.length > 0 ? 'a' : 'span';
+ },
+ computedPath() {
+ return this.path.length ? this.path : null;
+ },
+ itemPath() {
+ return this.displayReference.split(this.pathIdSeparator)[0];
+ },
+ itemId() {
+ return this.displayReference.split(this.pathIdSeparator).pop();
+ },
+ createdAtInWords() {
+ return this.createdAt ? this.timeFormated(this.createdAt) : '';
+ },
+ createdAtTimestamp() {
+ return this.createdAt ? formatDate(new Date(this.createdAt)) : '';
+ },
+ mergedAtTimestamp() {
+ return this.mergedAt ? formatDate(new Date(this.mergedAt)) : '';
+ },
+ mergedAtInWords() {
+ return this.mergedAt ? this.timeFormated(this.mergedAt) : '';
+ },
+ closedAtInWords() {
+ return this.closedAt ? this.timeFormated(this.closedAt) : '';
+ },
+ closedAtTimestamp() {
+ return this.closedAt ? formatDate(new Date(this.closedAt)) : '';
+ },
+ stateText() {
+ if (this.isMerged) {
+ return __('Merged');
+ }
+
+ return this.isOpen ? __('Opened') : __('Closed');
+ },
+ stateTimeInWords() {
+ if (this.isMerged) {
+ return this.mergedAtInWords;
+ }
+
+ return this.isOpen ? this.createdAtInWords : this.closedAtInWords;
+ },
+ stateTimestamp() {
+ if (this.isMerged) {
+ return this.mergedAtTimestamp;
+ }
+
+ return this.isOpen ? this.createdAtTimestamp : this.closedAtTimestamp;
+ },
+ pipelineStatusTooltip() {
+ return this.hasPipeline
+ ? sprintf(__('Pipeline: %{status}'), { status: this.pipelineStatus.label })
+ : '';
+ },
+ },
+ methods: {
+ onRemoveRequest() {
+ let namespacePrefix = '';
+ if (this.eventNamespace && this.eventNamespace.length > 0) {
+ namespacePrefix = `${this.eventNamespace}`;
+ }
+
+ this.$emit(`${namespacePrefix}RemoveRequest`, this.idKey);
+
+ this.removeDisabled = true;
+ },
+ },
+};
+
+export default mixins;
diff --git a/app/assets/javascripts/vue_shared/models/label.js b/app/assets/javascripts/vue_shared/models/label.js
deleted file mode 100644
index 2d2732d0661..00000000000
--- a/app/assets/javascripts/vue_shared/models/label.js
+++ /dev/null
@@ -1,13 +0,0 @@
-export default class ListLabel {
- constructor(obj) {
- this.id = obj.id;
- this.title = obj.title;
- this.type = obj.type;
- this.color = obj.color;
- this.textColor = obj.text_color;
- this.description = obj.description;
- this.priority = obj.priority !== null ? obj.priority : Infinity;
- }
-}
-
-window.ListLabel = ListLabel;
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 83ad8766cb5..a2f518cd24e 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -2,44 +2,36 @@
* This is a manifest file that'll automatically include all the stylesheets available in this directory
* and any sub-directories. You're free to add application-wide styles to this file and they'll appear at
* the top of the compiled file, but it's generally better to create a new file per style scope.
- *= require jquery.atwho
- *= require select2
*= require_self
*= require cropper.css
*/
-/*
- * Welcome to GitLab css!
- * If you need to add or modify UI component that is common for many pages
- * like a table or typography then make changes in the framework/ directory.
- * If you need to add unique style that should affect only one page - use pages/
- * directory.
- */
-
+// Welcome to GitLab css!
+// If you need to add or modify UI component that is common for many pages
+// like a table or typography then make changes in the framework/ directory.
+// If you need to add unique style that should affect only one page - use pages/
+// directory.
+@import "../../../node_modules/at.js/dist/css/jquery.atwho";
@import "../../../node_modules/pikaday/scss/pikaday";
@import "../../../node_modules/dropzone/dist/basic";
+@import "../../../node_modules/select2/select2";
-/*
- * GitLab UI framework
- */
+// GitLab UI framework
@import "framework";
-/*
- * Font icons
- */
+// Font icons
@import "font-awesome";
-/*
- * Page specific styles (issues, projects etc):
- */
+// Page specific styles (issues, projects etc):
@import "pages/**/*";
-/*
- * Component specific styles, will be moved to gitlab-ui
- */
+// Component specific styles, will be moved to gitlab-ui
@import "components/**/*";
-/*
- * Styles for JS behaviors.
- */
+// Vendors specific styles
+@import "vendors/**/*";
+
+// Styles for JS behaviors.
@import "behaviors";
+
+@import "utilities";
diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss
index c8357f7751c..7f6384f4eea 100644
--- a/app/assets/stylesheets/bootstrap_migration.scss
+++ b/app/assets/stylesheets/bootstrap_migration.scss
@@ -22,7 +22,9 @@ body,
.form-control,
.search form {
// Override default font size used in non-csslab UI
- font-size: 14px;
+ // Use rem to keep default font-size at 14px on body so 1rem still
+ // fits 8px grid, but also allow users to change browser font size
+ font-size: .875rem;
}
legend {
@@ -343,16 +345,6 @@ input[type=color].form-control {
}
}
-// Bootstrap 3 compatibility because bootstrap_form Gem is not updated yet
-.input-group-btn:first-child {
- @extend .input-group-prepend;
-}
-
-// Bootstrap 3 compatibility because bootstrap_form Gem is not updated yet
-.input-group-btn:last-child {
- @extend .input-group-append;
-}
-
/*
Bootstrap 4.1.2 introduced a new default vertical alignment which breaks our icons,
so we need to reset the vertical alignment to the default value. See:
diff --git a/app/assets/stylesheets/components/avatar.scss b/app/assets/stylesheets/components/avatar.scss
new file mode 100644
index 00000000000..1afa5ed90f4
--- /dev/null
+++ b/app/assets/stylesheets/components/avatar.scss
@@ -0,0 +1,202 @@
+$avatar-sizes: (
+ 16: (
+ font-size: 10px,
+ line-height: 16px,
+ border-radius: $border-radius-small
+ ),
+ 18: (
+ border-radius: $border-radius-small
+ ),
+ 20: (
+ border-radius: $border-radius-small
+ ),
+ 24: (
+ font-size: 12px,
+ line-height: 24px,
+ border-radius: $border-radius-default
+ ),
+ 26: (
+ font-size: 20px,
+ line-height: 1.33,
+ border-radius: $border-radius-default
+ ),
+ 32: (
+ font-size: 14px,
+ line-height: 32px,
+ border-radius: $border-radius-default
+ ),
+ 40: (
+ font-size: 16px,
+ line-height: 38px,
+ border-radius: $border-radius-default
+ ),
+ 48: (
+ font-size: 20px,
+ line-height: 48px,
+ border-radius: $border-radius-large
+ ),
+ 60: (
+ font-size: 32px,
+ line-height: 58px,
+ border-radius: $border-radius-large
+ ),
+ 64: (
+ font-size: 28px,
+ line-height: 64px,
+ border-radius: $border-radius-large
+ ),
+ 90: (
+ font-size: 36px,
+ line-height: 88px,
+ border-radius: $border-radius-large
+ ),
+ 100: (
+ font-size: 36px,
+ line-height: 98px,
+ border-radius: $border-radius-large
+ ),
+ 160: (
+ font-size: 96px,
+ line-height: 158px,
+ border-radius: $border-radius-large
+ )
+);
+
+$identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $identicon-blue, $identicon-teal,
+ $identicon-orange, $gray-darker;
+
+.avatar-circle {
+ float: left;
+ margin-right: $gl-padding;
+ border-radius: $avatar-radius;
+ border: 1px solid $gray-normal;
+
+ @each $size, $size-config in $avatar-sizes {
+ &.s#{$size} {
+ @include avatar-size(#{$size}px, if($size < 48, 8px, 16px));
+ }
+ }
+}
+
+.avatar {
+ @extend .avatar-circle;
+ transition-property: none;
+
+ width: 40px;
+ height: 40px;
+ padding: 0;
+ background: $gray-lightest;
+ overflow: hidden;
+ border-color: rgba($black, $gl-avatar-border-opacity);
+
+ &.avatar-inline {
+ float: none;
+ display: inline-block;
+ margin-left: 2px;
+ flex-shrink: 0;
+
+ &.s16 {
+ margin-right: 4px;
+ }
+
+ &.s24 {
+ margin-right: 4px;
+ }
+ }
+
+ &.center {
+ font-size: 14px;
+ line-height: 1.8em;
+ text-align: center;
+ }
+
+ &.avatar-tile {
+ border-radius: 0;
+ border: 0;
+ }
+
+ &.avatar-placeholder {
+ border: 0;
+ }
+}
+
+.identicon {
+ text-align: center;
+ vertical-align: top;
+ color: $gray-800;
+ background-color: $gray-darker;
+
+ // Sizes
+ @each $size, $size-config in $avatar-sizes {
+ $keys: map-keys($size-config);
+
+ &.s#{$size} {
+ @each $key in $keys {
+ // We don't want `border-radius` to be included here.
+ @if ($key != 'border-radius') {
+ #{$key}: map-get($size-config, #{$key});
+ }
+ }
+ }
+ }
+
+ // Background colors
+ @for $i from 1 through length($identicon-backgrounds) {
+ &.bg#{$i} {
+ background-color: nth($identicon-backgrounds, $i);
+ }
+ }
+}
+
+.avatar-container {
+ @extend .avatar-circle;
+ overflow: hidden;
+ display: flex;
+
+ a {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ text-decoration: none;
+ }
+
+ .avatar {
+ border-radius: 0;
+ border: 0;
+ height: auto;
+ width: 100%;
+ margin: 0;
+ align-self: center;
+ }
+
+ &.s40 {
+ min-width: 40px;
+ min-height: 40px;
+ }
+
+ &.s64 {
+ min-width: 64px;
+ min-height: 64px;
+ }
+}
+
+.rect-avatar {
+ border-radius: $border-radius-small;
+
+ @each $size, $size-config in $avatar-sizes {
+ &.s#{$size} {
+ border-radius: map-get($size-config, 'border-radius');
+ }
+ }
+}
+
+.avatar-counter {
+ background-color: $gray-darkest;
+ color: $white-light;
+ border: 1px solid $gray-normal;
+ border-radius: 1em;
+ font-family: $regular-font;
+ font-size: 9px;
+ line-height: 16px;
+ text-align: center;
+}
diff --git a/app/assets/stylesheets/components/dashboard_skeleton.scss b/app/assets/stylesheets/components/dashboard_skeleton.scss
new file mode 100644
index 00000000000..a104d035a9a
--- /dev/null
+++ b/app/assets/stylesheets/components/dashboard_skeleton.scss
@@ -0,0 +1,77 @@
+.dashboard-cards {
+ margin-right: -$gl-padding-8;
+ margin-left: -$gl-padding-8;
+}
+
+.dashboard-card {
+ &-header {
+ &-warning {
+ background-color: $orange-100;
+ }
+ }
+
+ &-body {
+ min-height: 120px;
+
+ &-warning {
+ background-color: $orange-50;
+ }
+
+ &-failed {
+ background-color: $red-50;
+ }
+ }
+
+ &-icon {
+ color: $gray-500;
+ }
+
+ &-footer {
+ border-radius: $gl-padding;
+ height: $gl-padding-32;
+
+ &-arrow {
+ color: $gray-300;
+ }
+
+ &-downstream {
+ margin-right: -$gl-padding-8;
+ }
+
+ &-extra {
+ background-color: $gray-400;
+ font-size: 10px;
+ line-height: $gl-line-height;
+ width: $gl-padding;
+ }
+ }
+
+ &-header,
+ &-footer {
+ &-failed {
+ background-color: $red-100;
+ }
+ }
+
+ &-skeleton-info {
+ border-radius: $gl-padding;
+ height: $gl-padding;
+ overflow: hidden;
+
+ &::after {
+ content: ' ';
+ display: block;
+ animation: blockTextShine 1s linear infinite forwards;
+ background-repeat: no-repeat;
+ background-size: cover;
+ background-image: linear-gradient(to right,
+ $gray-100 0%,
+ $gray-50 20%,
+ $gray-100 40%,
+ $gray-100 100%);
+ border-radius: $gl-padding;
+ height: $gl-padding;
+ margin-top: -$gl-padding-8;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/components/popover.scss b/app/assets/stylesheets/components/popover.scss
index 2f4d30fe923..774be9ef588 100644
--- a/app/assets/stylesheets/components/popover.scss
+++ b/app/assets/stylesheets/components/popover.scss
@@ -5,5 +5,68 @@
padding: $gl-padding-8;
font-size: $gl-font-size-small;
line-height: $gl-line-height;
+
+ .category-icon {
+ color: $gray-600;
+ }
+ }
+
+ &.blue {
+ background-color: $blue-600;
+
+ .popover-body {
+ color: $white-light;
+ }
+
+ &.bs-popover-bottom {
+ .arrow::after {
+ border-bottom-color: $blue-600;
+ }
+ }
+
+ &.bs-popover-top {
+ .arrow::after {
+ border-top-color: $blue-600;
+ }
+ }
+ }
+}
+
+.mr-popover {
+ .text-secondary {
+ font-size: 12px;
+ line-height: 1.33;
+ }
+}
+
+.onboarding-popover {
+ box-shadow: 0 2px 4px $dropdown-shadow-color;
+
+ .popover-body {
+ font-size: $gl-font-size;
+ line-height: $gl-line-height;
+ padding: $gl-padding;
+ }
+
+ .popover-header {
+ display: none;
+ }
+
+ .accept-mr-label {
+ background-color: $accepting-mr-label-color;
+ color: $white-light;
+ }
+}
+
+.onboarding-welcome-page {
+ .popover {
+ min-width: auto;
+ max-width: 40%;
+
+ .popover-body {
+ padding-top: $gl-padding;
+ padding-bottom: $gl-padding;
+ font-size: $gl-font-size-small;
+ }
}
}
diff --git a/app/assets/stylesheets/components/project_list_item.scss b/app/assets/stylesheets/components/project_list_item.scss
new file mode 100644
index 00000000000..8e7c2c4398c
--- /dev/null
+++ b/app/assets/stylesheets/components/project_list_item.scss
@@ -0,0 +1,24 @@
+.project-list-item {
+ &:not(:disabled):not(.disabled) {
+ &:focus,
+ &:active,
+ &:focus:active {
+ outline: none;
+ box-shadow: none;
+ }
+ }
+}
+
+// When housed inside a modal, the edge of each item
+// should extend to the edge of the modal.
+.modal-body {
+ .project-list-item {
+ border-radius: 0;
+ margin-left: -$gl-padding;
+ margin-right: -$gl-padding;
+
+ .project-namespace-name-container {
+ overflow: hidden;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss
index 048a5c0300c..7f9cf1266b1 100644
--- a/app/assets/stylesheets/components/related_items_list.scss
+++ b/app/assets/stylesheets/components/related_items_list.scss
@@ -11,18 +11,24 @@ $item-weight-max-width: 48px;
}
}
+.sortable-link {
+ max-width: 85%;
+}
+
.item-body {
- display: flex;
position: relative;
- align-items: center;
- padding: $gl-padding-8;
line-height: $gl-line-height;
- .item-contents {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- flex-grow: 1;
+ .issue-token-state-icon-open {
+ color: $green-500;
+ }
+
+ .issue-token-state-icon-closed {
+ color: $blue-500;
+ }
+
+ .merge-request-status.closed {
+ color: $red-500;
}
.issue-token-state-icon-open,
@@ -40,235 +46,185 @@ $item-weight-max-width: 48px;
}
.confidential-icon {
- align-self: baseline;
color: $orange-600;
- margin-right: $gl-padding-4;
}
.item-title {
flex-basis: 100%;
- margin-bottom: $gl-padding-8;
font-size: $gl-font-size-small;
&.mr-title {
font-weight: $gl-font-weight-bold;
}
- .sortable-link {
- max-width: 85%;
- }
-
.issue-token-state-icon-open,
.issue-token-state-icon-closed {
display: none;
}
}
- .item-meta {
- display: flex;
- flex-wrap: wrap;
- flex-basis: 100%;
- font-size: $gl-font-size-small;
+ .item-path-id .path-id-text,
+ .item-milestone .milestone-title,
+ .item-due-date,
+ .item-weight .board-card-info-text {
color: $gl-text-color-secondary;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ }
+}
- .item-meta-child {
- order: 0;
- display: flex;
- flex-wrap: wrap;
- flex-basis: 100%;
-
- .item-due-date,
- .item-weight {
- margin-left: $gl-padding-8;
- }
+.item-meta {
+ flex-basis: 100%;
+ font-size: $gl-font-size-small;
+ color: $gl-text-color-secondary;
- .item-milestone,
- .item-weight {
- cursor: help;
- }
+ .item-meta-child {
+ flex-basis: 100%;
+ }
- .item-milestone {
- text-decoration: none;
- max-width: $item-milestone-max-width;
- }
+ .item-milestone,
+ .item-weight {
+ cursor: help;
+ }
- .item-due-date {
- margin-right: 0;
- }
+ .item-milestone {
+ text-decoration: none;
+ max-width: $item-milestone-max-width;
- .item-weight {
- margin-right: 0;
- max-width: $item-weight-max-width;
- }
+ .ic-clock {
+ color: $gl-text-color-tertiary;
+ margin-right: $gl-padding-4;
}
+ }
- .item-path-id .path-id-text,
- .item-milestone .milestone-title,
- .item-due-date,
- .item-weight .board-card-info-text {
- color: $gl-text-color-secondary;
- display: inline-block;
- text-overflow: ellipsis;
- overflow: hidden;
- white-space: nowrap;
- }
+ .item-weight {
+ max-width: $item-weight-max-width;
+ }
- .item-path-id {
- margin-top: $gl-padding-4;
- font-size: $gl-font-size-xs;
- white-space: nowrap;
+ .item-assignees {
+ .user-avatar-link {
+ margin-right: -$gl-padding-4;
- .path-id-text {
- font-weight: $gl-font-weight-bold;
- max-width: $item-path-max-width;
+ &:nth-of-type(1) {
+ z-index: 2;
}
- .issue-token-state-icon-open,
- .issue-token-state-icon-closed {
- display: block;
+ &:nth-of-type(2) {
+ z-index: 1;
}
- &:not(.mr-item-path) {
- order: 1;
+ &:last-child {
+ margin-right: 0;
}
}
- .item-milestone .ic-clock {
- color: $gl-text-color-tertiary;
- margin-right: $gl-padding-4;
+ .avatar {
+ height: $gl-padding;
+ width: $gl-padding;
+ margin-right: 0;
+ vertical-align: bottom;
}
- .item-assignees {
- order: 2;
- align-self: flex-end;
- align-items: center;
- margin-left: auto;
-
- .user-avatar-link {
- margin-right: -$gl-padding-4;
-
- &:nth-of-type(1) {
- z-index: 2;
- }
+ .avatar-counter {
+ height: $gl-padding;
+ border: 1px solid transparent;
+ background-color: $gl-text-color-tertiary;
+ font-weight: $gl-font-weight-bold;
+ padding: 0 $gl-padding-4;
+ line-height: $gl-padding;
+ }
+ }
+}
- &:nth-of-type(2) {
- z-index: 1;
- }
+.item-path-id {
+ font-size: $gl-font-size-xs;
+ white-space: nowrap;
- &:last-child {
- margin-right: 0;
- }
- }
+ .path-id-text {
+ font-weight: $gl-font-weight-bold;
+ max-width: $item-path-max-width;
+ }
- .avatar {
- height: $gl-padding;
- width: $gl-padding;
- margin-right: 0;
- vertical-align: bottom;
- }
+ .issue-token-state-icon-open,
+ .issue-token-state-icon-closed {
+ display: block;
+ }
- .avatar-counter {
- height: $gl-padding;
- border: 1px solid transparent;
- background-color: $gl-text-color-tertiary;
- font-weight: $gl-font-weight-bold;
- padding: 0 $gl-padding-4;
- line-height: $gl-padding;
- }
+ @include media-breakpoint-down(sm) {
+ &:not(.mr-item-path) {
+ order: 1;
}
}
+}
- .btn-item-remove {
- position: absolute;
- right: 0;
- top: $gl-padding-4 / 2;
- padding: $gl-padding-4;
- margin-right: $gl-padding-4 / 2;
- line-height: 0;
- border-color: transparent;
- color: $gl-text-color-secondary;
+.btn-item-remove {
+ position: absolute;
+ right: 0;
+ top: $gl-padding-4 / 2;
+ padding: $gl-padding-4;
+ margin-right: $gl-padding-4 / 2;
+ line-height: 0;
+ border-color: transparent;
+ color: $gl-text-color-secondary;
- &:hover {
- color: $gl-text-color;
- }
+ &:hover {
+ color: $gl-text-color;
}
}
.mr-status-wrapper,
-.mr-ci-status
- {
+.mr-ci-status {
line-height: 0;
}
@include media-breakpoint-up(sm) {
- .item-body {
- .item-contents .item-title {
- .mr-title-link,
- .sortable-link {
- max-width: 90%;
- }
- }
+ .sortable-link {
+ max-width: 90%;
}
}
/* Small devices (landscape phones, 768px and up) */
@include media-breakpoint-up(md) {
+ .sortable-link {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ max-width: 100%;
+ }
+
.item-body {
.item-contents {
min-width: 0;
+ }
- .item-title {
- flex-basis: unset;
- // 95% because we compensate
- // for remove button which is
- // positioned absolutely
- width: 95%;
- margin-bottom: $gl-padding-4;
-
- .mr-title-link,
- .sortable-link {
- text-overflow: ellipsis;
- overflow: hidden;
- white-space: nowrap;
- max-width: 100%;
- }
- }
-
- .item-meta {
- .item-path-id {
- order: 0;
- margin-top: 0;
- }
-
- .item-meta-child {
- flex-basis: unset;
- margin-left: auto;
- margin-right: $gl-padding-4;
-
- ~ .item-assignees {
- margin-left: $gl-padding-4;
- }
- }
-
- .item-assignees {
- margin-bottom: 0;
- margin-left: 0;
- order: 2;
- }
- }
+ .item-title {
+ flex-basis: unset;
+ // 95% because we compensate
+ // for remove button which is
+ // positioned absolutely
+ width: 95%;
}
.btn-item-remove {
order: 1;
}
}
+
+ .item-meta {
+ .item-meta-child {
+ flex-basis: unset;
+
+ ~ .item-assignees {
+ margin-left: $gl-padding-4;
+ }
+ }
+ }
}
/* Medium devices (desktops, 992px and up) */
@include media-breakpoint-up(lg) {
.item-body {
- padding: $gl-padding;
-
.item-title {
font-size: $gl-font-size;
}
@@ -276,106 +232,60 @@ $item-weight-max-width: 48px;
.item-meta .item-path-id {
font-size: inherit; // Base size given to `item-meta` is `$gl-font-size-small`
}
-
- .issue-token-state-icon-open,
- .issue-token-state-icon-closed {
- margin-right: $gl-padding-4;
- }
}
}
/* Large devices (large desktops, 1200px and up) */
@include media-breakpoint-up(xl) {
.item-body {
- padding: $gl-padding-8;
- padding-left: $gl-padding;
+ .item-title {
+ min-width: 0;
+ width: auto;
+ flex-basis: unset;
+ font-weight: $gl-font-weight-normal;
- .item-contents {
- flex-wrap: nowrap;
- overflow: hidden;
-
- .item-title {
- display: flex;
- margin-bottom: 0;
- min-width: 0;
- width: auto;
- flex-basis: unset;
- font-weight: $gl-font-weight-normal;
-
- .mr-title-link,
- .sortable-link {
- display: block;
- text-overflow: ellipsis;
- white-space: nowrap;
- overflow: hidden;
- }
-
- .issue-token-state-icon-open,
- .issue-token-state-icon-closed {
- display: block;
- margin-right: $gl-padding-8;
- }
-
- .confidential-icon {
- align-self: auto;
- margin-top: 0;
- }
+ .issue-token-state-icon-open,
+ .issue-token-state-icon-closed {
+ display: block;
+ margin-right: $gl-padding-8;
}
+ }
+ }
- .item-meta {
- margin-top: 0;
- justify-content: flex-end;
- flex: 1;
- flex-wrap: nowrap;
-
- .item-path-id {
- order: 0;
- margin-top: 0;
- margin-left: $gl-padding-8;
- margin-right: auto;
-
- .issue-token-state-icon-open,
- .issue-token-state-icon-closed {
- display: none;
- }
- }
-
- .item-meta-child {
- margin-left: $gl-padding-8;
- flex-wrap: nowrap;
- }
-
- .item-assignees {
- flex-grow: 0;
- margin-top: 0;
- margin-right: $gl-padding-4;
-
- .avatar {
- height: $gl-padding-24;
- width: $gl-padding-24;
- }
-
- .avatar-counter {
- height: $gl-padding-24;
- min-width: $gl-padding-24;
- line-height: $gl-padding-24;
- border-radius: $gl-padding-24;
- }
- }
- }
+ .item-contents {
+ overflow: hidden;
+ }
+
+ .item-meta {
+ flex: 1;
+ }
+
+ .item-assignees {
+ .avatar {
+ height: $gl-padding-24;
+ width: $gl-padding-24;
}
- .btn-item-remove {
- position: relative;
- align-self: center;
- top: initial;
- right: 0;
- margin-right: 0;
- padding: $btn-sm-side-margin;
+ .avatar-counter {
+ height: $gl-padding-24;
+ min-width: $gl-padding-24;
+ line-height: $gl-padding-24;
+ border-radius: $gl-padding-24;
+ }
+ }
- &:hover {
- border-color: $border-color;
- }
+ .btn-item-remove {
+ position: relative;
+ top: initial;
+ right: 0;
+ padding: $btn-sm-side-margin;
+
+ &:hover {
+ border-color: $border-color;
}
}
+
+ .sortable-link {
+ line-height: 1.3;
+ }
}
diff --git a/app/assets/stylesheets/components/toast.scss b/app/assets/stylesheets/components/toast.scss
new file mode 100644
index 00000000000..33e1c4e5349
--- /dev/null
+++ b/app/assets/stylesheets/components/toast.scss
@@ -0,0 +1,53 @@
+/*
+* 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-24;
+ margin-top: $toast-default-margin;
+ line-height: $gl-line-height;
+ background-color: rgba($gray-900, $toast-background-opacity);
+
+ @include media-breakpoint-down(xs) {
+ .action:first-child {
+ // Ensures actions buttons are right aligned on mobile
+ margin-left: auto;
+ }
+ }
+
+ .action {
+ color: $blue-300;
+ margin: 0 0 0 $toast-action-margin-left;
+ text-transform: none;
+ font-size: $gl-font-size;
+
+ &:first-child {
+ padding-right: 0;
+ }
+ }
+
+ .toast-close {
+ font-size: $default-icon-size;
+ margin-left: $toast-default-margin;
+ padding-left: $gl-padding;
+ }
+ }
+}
+
+// Overrides the default positioning of toasts
+body .toasted-container.bottom-left {
+ bottom: $toast-offset;
+ left: $toast-offset;
+}
diff --git a/app/assets/stylesheets/errors.scss b/app/assets/stylesheets/errors.scss
index 658e0ff638e..8c32b6c8985 100644
--- a/app/assets/stylesheets/errors.scss
+++ b/app/assets/stylesheets/errors.scss
@@ -17,7 +17,7 @@ body {
text-align: center;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
margin: auto;
- font-size: 14px;
+ font-size: .875rem;
}
h1 {
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 555ea276c6c..9b0d19b0ef0 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -8,7 +8,6 @@
@import 'framework/animations';
@import 'framework/vue_transitions';
-@import 'framework/avatar';
@import 'framework/asciidoctor';
@import 'framework/banner';
@import 'framework/blocks';
@@ -60,9 +59,11 @@
@import 'framework/memory_graph';
@import 'framework/responsive_tables';
@import 'framework/stacked_progress_bar';
+@import 'framework/sortable';
@import 'framework/ci_variable_list';
@import 'framework/feature_highlight';
@import 'framework/terms';
@import 'framework/read_more';
@import 'framework/flex_grid';
@import 'framework/system_messages';
+@import "framework/spinner";
diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss
index 70d50c74ca9..6f5a2e561af 100644
--- a/app/assets/stylesheets/framework/animations.scss
+++ b/app/assets/stylesheets/framework/animations.scss
@@ -27,7 +27,7 @@
&.flipOutY,
&.bounceIn,
&.bounceOut {
- @include webkit-prefix(animation-duration, .75s);
+ @include webkit-prefix(animation-duration, 0.75s);
}
&.short {
@@ -73,22 +73,10 @@
@mixin disable-all-animation {
/*CSS transitions*/
- -o-transition-property: none !important;
- -moz-transition-property: none !important;
- -ms-transition-property: none !important;
- -webkit-transition-property: none !important;
transition-property: none !important;
/*CSS transforms*/
- -o-transform: none !important;
- -moz-transform: none !important;
- -ms-transform: none !important;
- -webkit-transform: none !important;
transform: none !important;
/*CSS animations*/
- -webkit-animation: none !important;
- -moz-animation: none !important;
- -o-animation: none !important;
- -ms-animation: none !important;
animation: none !important;
}
@@ -202,7 +190,7 @@ a {
}
}
- [class^="skeleton-line-"] {
+ [class^='skeleton-line-'] {
position: relative;
background-color: $gray-100;
height: 10px;
@@ -218,13 +206,11 @@ a {
animation: blockTextShine 1s linear infinite forwards;
background-repeat: no-repeat;
background-size: cover;
- background-image: linear-gradient(
- to right,
- $gray-100 0%,
- $gray-50 20%,
- $gray-100 40%,
- $gray-100 100%
- );
+ background-image: linear-gradient(to right,
+ $gray-100 0%,
+ $gray-50 20%,
+ $gray-100 40%,
+ $gray-100 100%);
height: 10px;
}
}
@@ -282,3 +268,27 @@ $skeleton-line-widths: (
@include webkit-prefix(animation-duration, 1s);
transform-origin: 50% 50%;
}
+
+/* ----------------------------------------------
+ * Generated by Animista on 2019-4-26 17:40:41
+ * w: http://animista.net, t: @cssanimista
+ * ---------------------------------------------- */
+@keyframes slide-in-fwd-bottom {
+ 0% {
+ transform: translateZ(-1400px) translateY(800px);
+ opacity: 0;
+ }
+
+ 100% {
+ transform: translateZ(0) translateY(0);
+ opacity: 1;
+ }
+}
+
+.slide-in-fwd-bottom-enter-active {
+ animation: slide-in-fwd-bottom 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
+}
+
+.slide-in-fwd-bottom-leave-active {
+ animation: slide-in-fwd-bottom 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) both reverse;
+}
diff --git a/app/assets/stylesheets/framework/asciidoctor.scss b/app/assets/stylesheets/framework/asciidoctor.scss
index 62493c32833..1586265d40e 100644
--- a/app/assets/stylesheets/framework/asciidoctor.scss
+++ b/app/assets/stylesheets/framework/asciidoctor.scss
@@ -1,7 +1,7 @@
.admonitionblock td.icon {
width: 1%;
- [class^="fa icon-"] {
+ [class^='fa icon-'] {
@extend .fa-2x;
}
diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss
deleted file mode 100644
index bfd3d776bd4..00000000000
--- a/app/assets/stylesheets/framework/avatar.scss
+++ /dev/null
@@ -1,160 +0,0 @@
-@mixin avatar-size($size, $margin-right) {
- width: $size;
- height: $size;
- margin-right: $margin-right;
-}
-
-.avatar-circle {
- float: left;
- margin-right: 15px;
- border-radius: $avatar-radius;
- border: 1px solid $gray-normal;
- &.s16 { @include avatar-size(16px, 6px); }
- &.s18 { @include avatar-size(18px, 6px); }
- &.s19 { @include avatar-size(19px, 6px); }
- &.s20 { @include avatar-size(20px, 7px); }
- &.s24 { @include avatar-size(24px, 8px); }
- &.s26 { @include avatar-size(26px, 8px); }
- &.s32 { @include avatar-size(32px, 10px); }
- &.s36 { @include avatar-size(36px, 10px); }
- &.s40 { @include avatar-size(40px, 10px); }
- &.s46 { @include avatar-size(46px, 15px); }
- &.s48 { @include avatar-size(48px, 10px); }
- &.s60 { @include avatar-size(60px, 12px); }
- &.s64 { @include avatar-size(64px, 14px); }
- &.s70 { @include avatar-size(70px, 14px); }
- &.s90 { @include avatar-size(90px, 15px); }
- &.s100 { @include avatar-size(100px, 15px); }
- &.s110 { @include avatar-size(110px, 15px); }
- &.s140 { @include avatar-size(140px, 15px); }
- &.s160 { @include avatar-size(160px, 20px); }
-}
-
-.avatar {
- @extend .avatar-circle;
- transition-property: none;
-
- width: 40px;
- height: 40px;
- padding: 0;
- background: $gray-lightest;
- overflow: hidden;
-
- &.avatar-inline {
- float: none;
- display: inline-block;
- margin-left: 2px;
- flex-shrink: 0;
- -webkit-flex-shrink: 0;
-
- &.s16 { margin-right: 4px; }
- &.s24 { margin-right: 4px; }
- }
-
- &.center {
- font-size: 14px;
- line-height: 1.8em;
- text-align: center;
- }
-
- &.avatar-tile {
- border-radius: 0;
- border: 0;
- }
-
- &:not([href]):hover {
- border-color: darken($gray-normal, 10%);
- }
-}
-
-.identicon {
- text-align: center;
- vertical-align: top;
- color: $gl-gray-700;
- background-color: $gray-darker;
-
- // Sizes
- &.s16 { font-size: 12px; line-height: 1.33; }
- &.s24 { font-size: 13px; line-height: 1.8; }
- &.s26 { font-size: 20px; line-height: 1.33; }
- &.s32 { font-size: 20px; line-height: 30px; }
- &.s40 { font-size: 16px; line-height: 38px; }
- &.s48 { font-size: 20px; line-height: 46px; }
- &.s60 { font-size: 32px; line-height: 58px; }
- &.s64 { font-size: 32px; line-height: 64px; }
- &.s70 { font-size: 34px; line-height: 70px; }
- &.s90 { font-size: 36px; line-height: 88px; }
- &.s100 { font-size: 36px; line-height: 98px; }
- &.s110 { font-size: 40px; line-height: 108px; font-weight: $gl-font-weight-normal; }
- &.s140 { font-size: 72px; line-height: 138px; }
- &.s160 { font-size: 96px; line-height: 158px; }
-
- // Background colors
- &.bg1 { background-color: $identicon-red; }
- &.bg2 { background-color: $identicon-purple; }
- &.bg3 { background-color: $identicon-indigo; }
- &.bg4 { background-color: $identicon-blue; }
- &.bg5 { background-color: $identicon-teal; }
- &.bg6 { background-color: $identicon-orange; }
- &.bg7 { background-color: $gray-darker; }
-}
-
-.avatar-container {
- @extend .avatar-circle;
- overflow: hidden;
- display: flex;
-
- a {
- width: 100%;
- height: 100%;
- display: flex;
- text-decoration: none;
- }
-
- .avatar {
- border-radius: 0;
- border: 0;
- height: auto;
- width: 100%;
- margin: 0;
- align-self: center;
- }
-
- &.s40 { min-width: 40px; min-height: 40px; }
- &.s64 { min-width: 64px; min-height: 64px; }
-}
-
-.rect-avatar {
- border-radius: $border-radius-small;
- &.s16 { border-radius: $border-radius-small; }
- &.s18 { border-radius: $border-radius-small; }
- &.s19 { border-radius: $border-radius-small; }
- &.s20 { border-radius: $border-radius-small; }
- &.s24 { border-radius: $border-radius-default; }
- &.s26 { border-radius: $border-radius-default; }
- &.s32 { border-radius: $border-radius-default; }
- &.s36 { border-radius: $border-radius-default; }
- &.s40 { border-radius: $border-radius-default; }
- &.s46 { border-radius: $border-radius-default; }
- &.s48 { border-radius: $border-radius-large; }
- &.s60 { border-radius: $border-radius-large; }
- &.s64 { border-radius: $border-radius-large; }
- &.s70 { border-radius: $border-radius-large; }
- &.s90 { border-radius: $border-radius-large; }
- &.s96 { border-radius: $border-radius-large; }
- &.s100 { border-radius: $border-radius-large; }
- &.s110 { border-radius: $border-radius-large; }
- &.s140 { border-radius: $border-radius-large; }
- &.s160 { border-radius: $border-radius-large; }
-}
-
-.avatar-counter {
- background-color: $gray-darkest;
- color: $white-light;
- border: 1px solid $gray-normal;
- border-radius: 1em;
- font-family: $regular-font;
- font-size: 9px;
- line-height: 16px;
- text-align: center;
-}
diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss
index 5cfd5bbd4f5..7760c48cb92 100644
--- a/app/assets/stylesheets/framework/awards.scss
+++ b/app/assets/stylesheets/framework/awards.scss
@@ -23,9 +23,9 @@
box-shadow: 0 6px 12px $award-emoji-menu-shadow;
pointer-events: none;
opacity: 0;
- transform: scale(.2);
+ transform: scale(0.2);
transform-origin: 0 -45px;
- transition: .3s cubic-bezier(.67, .06, .19, 1.44);
+ transition: 0.3s cubic-bezier(0.67, 0.06, 0.19, 1.44);
transition-property: transform, opacity;
&.is-rendered {
@@ -62,7 +62,7 @@
}
.emoji-search {
- background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAFu0lEQVRIia1WTahkVxH+quqce7vf6zdvJpHoIlkYJ2SiJiIokmQjgoGgIAaEIYuYXWICgojiwkmC4taFwhjcyIDusogEIwwiSSCKPwsdwzAg0SjJ9Izzk5n3+nXfe8+pqizOvd395scfsJqi6dPnnDr11Vc/NJ1OwUTosqJLCmYCHCAC2mSHs+ojZv6AO46Y+20AhIneJsafhPhXVZSXDk7qi+aOLhtQNuBmQtcarAKjTXpn2+l3u2yPunvZSABRucjcAV/eMZuM48/Go/g1d19kc4wq+e8MZjWkbI/P5t2P3RFFbv7SQdyBlBUx8N8OTuqjMcof+N94yMPrY2DMm/ytnb32J0QrY+6AqsHM4Q64O9SKDmerKDD3Oy/tNL9vk342CC8RuU6n0ymCMHb22scu7zQngtASOjUHE1BX4UUAv4b7Ow6qiXCXuz/UdvogAAweDY943/b4cAz0ZlYHXeMsnT07RVb7wMUr8ykI4H5HVkMd5Rcb4/jNURVOL5qErAaAUUdCCIJ5kx5q2nw8m39ImEAAsjpE6PStB0YfMcd1wqqG3Xn7A3PfZyyKnNjaqD4fmE/fCNKshirIyY1xvI+Av6g5QIAIIWX7cJPssboSiBBEeKmsZne0Sb8kzAUWNYyq8NvbDo0fZ6beqxuLmqOOMr/lwOh+YXpXtbjERGja9JyZ9+HxpXKb9Gj5oywRESbj+Cj1ENG1QViTGBl1FbC1We1tbVRfHWIoQkhqH9xbpE92XUbb6VJZ1R4crjRz1JWcDMJvLdoMcyAEhjuwHo8Bfndg3mbszhOY+adVlMtD3po51OwzIQiEaams7oeJhxRw1FFOVpFRRUYIhMBAFRnjOsC8IFHHUA4TQQhgAqpAiIFfGbxkIqj54ayGbL7UoOqHCniAEKHLNr26l+D9wQJzeUwMAnfHvEnLECzZRwRV++d60ptjW9VLZeolEJG6GwCCE0CFVNB+Ay0NEqoQYG4YYFu7B8IEVRt3uRzy/osIoLV9QZimWXGHUMFdmI6M64DUF2Je88R9VZqCSP+QlcF5k+4tCzSsXaqjINuK6UyE0+s/mk6/qFq8oAIL9pqMLhkGsNrOyoOIlszust3aJv0U9+kFdwjTGwWl1YdF+KWlQSZ0Se/psj8yGVdg5tJyfH96EBWmLtoEMwMzMFt031NzGWLLzKhC+KV7H5ZeeaMOPxemma2x68puc0LN3+/u6LJiePS6MKHvn4wu6cPzJj0hsioeMfDrEvjv5r6W9gBvjKJujuKzQ0URIZj75NylvT+mbHfXQa4rwAMaVRTMm/SFyzvNy0yF6+4AM+1ubcSnqkAIUjQKl1RKSbE5jt+vovx1MBqF0WW7/d1Z80ab9BtmuJ3Xk5cJKds9TZt/uLPXvtiTrQ+dIwqfAejUvM1os6FNikXKUHfQ+ekUsXT5u85enJ0CaBSkkGEo1syUQ+DfMdE/4GA1uzupf9zdbzhOmLsF4efHVXjaHHAzmDtGdQRd/Nc5wAEJjNki3XfhyvwVNz80xANrht3LsENY9cBBdN1L9GUyyvFRFZ42t75sBvCQRykbRlU4tT2pPxoCvzx09d4GmPs200M6wKdWSDGK8mppYSWdhAlt0qeaLv+IadXU9/Evq4FAZ8ej+LmtcTxaRX4NWI0Uag5Vg1p5MYg8BnlhXIdPHDow+vTWZvVMVttXDLqkTzZdPj6Qii6cP1cSvIdl3iQkNYyi9HH0I22y+93tY3DcQkTZgQtM+POoCr8x97eylkmtrgKuztrvXJ21x/aNKuqIkZ/fntRfCdcTfhUTAIhRzoDojJD0aSNLLwMzmpT7+JaLtyf1MwDo6qz9djFaUq3t9MlFmy/c1OCSceY9fMsVaL9mvH9ocXdkdWxv1scAePG0THAhMOaLdOw/Gvxfxb1w4eCapyIENUcV5M3/u8FitAxZ25P6GAHT3UX39Srw+QOb1ZffA98Dl2Wy1BYkAAAAAElFTkSuQmCC");
+ background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAFu0lEQVRIia1WTahkVxH+quqce7vf6zdvJpHoIlkYJ2SiJiIokmQjgoGgIAaEIYuYXWICgojiwkmC4taFwhjcyIDusogEIwwiSSCKPwsdwzAg0SjJ9Izzk5n3+nXfe8+pqizOvd395scfsJqi6dPnnDr11Vc/NJ1OwUTosqJLCmYCHCAC2mSHs+ojZv6AO46Y+20AhIneJsafhPhXVZSXDk7qi+aOLhtQNuBmQtcarAKjTXpn2+l3u2yPunvZSABRucjcAV/eMZuM48/Go/g1d19kc4wq+e8MZjWkbI/P5t2P3RFFbv7SQdyBlBUx8N8OTuqjMcof+N94yMPrY2DMm/ytnb32J0QrY+6AqsHM4Q64O9SKDmerKDD3Oy/tNL9vk342CC8RuU6n0ymCMHb22scu7zQngtASOjUHE1BX4UUAv4b7Ow6qiXCXuz/UdvogAAweDY943/b4cAz0ZlYHXeMsnT07RVb7wMUr8ykI4H5HVkMd5Rcb4/jNURVOL5qErAaAUUdCCIJ5kx5q2nw8m39ImEAAsjpE6PStB0YfMcd1wqqG3Xn7A3PfZyyKnNjaqD4fmE/fCNKshirIyY1xvI+Av6g5QIAIIWX7cJPssboSiBBEeKmsZne0Sb8kzAUWNYyq8NvbDo0fZ6beqxuLmqOOMr/lwOh+YXpXtbjERGja9JyZ9+HxpXKb9Gj5oywRESbj+Cj1ENG1QViTGBl1FbC1We1tbVRfHWIoQkhqH9xbpE92XUbb6VJZ1R4crjRz1JWcDMJvLdoMcyAEhjuwHo8Bfndg3mbszhOY+adVlMtD3po51OwzIQiEaams7oeJhxRw1FFOVpFRRUYIhMBAFRnjOsC8IFHHUA4TQQhgAqpAiIFfGbxkIqj54ayGbL7UoOqHCniAEKHLNr26l+D9wQJzeUwMAnfHvEnLECzZRwRV++d60ptjW9VLZeolEJG6GwCCE0CFVNB+Ay0NEqoQYG4YYFu7B8IEVRt3uRzy/osIoLV9QZimWXGHUMFdmI6M64DUF2Je88R9VZqCSP+QlcF5k+4tCzSsXaqjINuK6UyE0+s/mk6/qFq8oAIL9pqMLhkGsNrOyoOIlszust3aJv0U9+kFdwjTGwWl1YdF+KWlQSZ0Se/psj8yGVdg5tJyfH96EBWmLtoEMwMzMFt031NzGWLLzKhC+KV7H5ZeeaMOPxemma2x68puc0LN3+/u6LJiePS6MKHvn4wu6cPzJj0hsioeMfDrEvjv5r6W9gBvjKJujuKzQ0URIZj75NylvT+mbHfXQa4rwAMaVRTMm/SFyzvNy0yF6+4AM+1ubcSnqkAIUjQKl1RKSbE5jt+vovx1MBqF0WW7/d1Z80ab9BtmuJ3Xk5cJKds9TZt/uLPXvtiTrQ+dIwqfAejUvM1os6FNikXKUHfQ+ekUsXT5u85enJ0CaBSkkGEo1syUQ+DfMdE/4GA1uzupf9zdbzhOmLsF4efHVXjaHHAzmDtGdQRd/Nc5wAEJjNki3XfhyvwVNz80xANrht3LsENY9cBBdN1L9GUyyvFRFZ42t75sBvCQRykbRlU4tT2pPxoCvzx09d4GmPs200M6wKdWSDGK8mppYSWdhAlt0qeaLv+IadXU9/Evq4FAZ8ej+LmtcTxaRX4NWI0Uag5Vg1p5MYg8BnlhXIdPHDow+vTWZvVMVttXDLqkTzZdPj6Qii6cP1cSvIdl3iQkNYyi9HH0I22y+93tY3DcQkTZgQtM+POoCr8x97eylkmtrgKuztrvXJ21x/aNKuqIkZ/fntRfCdcTfhUTAIhRzoDojJD0aSNLLwMzmpT7+JaLtyf1MwDo6qz9djFaUq3t9MlFmy/c1OCSceY9fMsVaL9mvH9ocXdkdWxv1scAePG0THAhMOaLdOw/Gvxfxb1w4eCapyIENUcV5M3/u8FitAxZ25P6GAHT3UX39Srw+QOb1ZffA98Dl2Wy1BYkAAAAAElFTkSuQmCC');
background-repeat: no-repeat;
background-position: right 5px center;
background-size: 16px;
@@ -90,7 +90,7 @@
background: none;
border: 0;
border-radius: $border-radius-base;
- transition: transform .15s cubic-bezier(.3, 0, .2, 2);
+ transition: transform 0.15s cubic-bezier(0.3, 0, 0.2, 2);
&:hover {
background-color: transparent;
@@ -151,8 +151,7 @@
outline: 0;
.award-control-icon svg {
- background: $award-emoji-positive-add-bg;
- fill: $award-emoji-positive-add-lines;
+ fill: $blue-500;
}
.award-control-icon-neutral {
@@ -233,10 +232,7 @@
height: $default-icon-size;
width: $default-icon-size;
border-radius: 50%;
- }
-
- path {
- fill: $border-gray-normal;
+ fill: $gray-700;
}
}
diff --git a/app/assets/stylesheets/framework/blank.scss b/app/assets/stylesheets/framework/blank.scss
index 91dbb2a6365..cbd390e7145 100644
--- a/app/assets/stylesheets/framework/blank.scss
+++ b/app/assets/stylesheets/framework/blank.scss
@@ -69,6 +69,7 @@
@include media-breakpoint-up(sm) {
display: flex;
+ height: 100%;
align-items: center;
padding: 50px 30px;
}
@@ -99,3 +100,30 @@
}
}
}
+
+@include media-breakpoint-up(lg) {
+ .column-large {
+ flex: 2;
+ }
+
+ .column-small {
+ flex: 1;
+ margin-bottom: 15px;
+
+ .blank-state {
+ max-width: 400px;
+ flex-wrap: wrap;
+ margin-left: 15px;
+ }
+
+ .blank-state-icon {
+ margin-bottom: 30px;
+ }
+ }
+}
+
+@include media-breakpoint-down(xs) {
+ .blank-state-icon svg {
+ width: 315px;
+ }
+}
diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss
index 43b7c26b272..65c0ee74c60 100644
--- a/app/assets/stylesheets/framework/blocks.scss
+++ b/app/assets/stylesheets/framework/blocks.scss
@@ -22,6 +22,10 @@
}
}
+.oneline {
+ line-height: 35px;
+}
+
.row-content-block {
margin-top: 0;
background-color: $gray-light;
@@ -77,20 +81,13 @@
color: $gl-text-color;
}
- .oneline {
- line-height: 35px;
- }
-
> p:last-child {
margin-bottom: 0;
}
.block-controls {
- display: -webkit-flex;
display: flex;
- -webkit-justify-content: flex-end;
justify-content: flex-end;
- -webkit-flex: 1;
flex: 1;
.control {
@@ -111,10 +108,6 @@
padding: 11px 0;
margin-bottom: 11px;
- .oneline {
- line-height: 35px;
- }
-
&.no-bottom-space {
border-bottom: 0;
margin-bottom: 0;
@@ -153,7 +146,7 @@
display: inline-block;
margin-left: 5px;
font-size: 18px;
- color: color("gray");
+ color: color('gray');
}
p {
@@ -163,8 +156,6 @@
}
.cover-desc {
- color: $gl-text-color;
-
&.username:last-child {
padding-bottom: $gl-padding;
}
@@ -208,6 +199,7 @@
&.user-cover-block {
padding: 24px 0 0;
+ border-bottom: 1px solid $border-color;
.nav-links {
width: 100%;
@@ -228,7 +220,6 @@
}
.group-info {
-
h1 {
display: inline;
font-weight: $gl-font-weight-normal;
@@ -242,14 +233,6 @@
margin-top: -1px;
}
-.nav-block {
- .controls {
- float: right;
- margin-top: 8px;
- padding-bottom: 8px;
- }
-}
-
.content-block {
padding: $gl-padding 0;
border-bottom: 1px solid $white-dark;
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index cb2c8879c5f..767832e242c 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -1,12 +1,12 @@
@mixin btn-comment-icon {
border-radius: 50%;
background: $white-light;
- padding: 1px 5px;
+ padding: 1px;
font-size: 12px;
color: $blue-500;
+ border: 1px solid $blue-500;
width: 24px;
height: 24px;
- border: 1px solid $blue-500;
&:hover,
&.inverted {
@@ -21,7 +21,7 @@
}
@mixin btn-default {
- border-radius: 3px;
+ border-radius: $border-radius-default;
font-size: $gl-font-size;
font-weight: $gl-font-weight-normal;
padding: $gl-vert-padding $gl-btn-padding;
@@ -37,7 +37,7 @@
@include btn-default;
}
-@mixin btn-outline($background, $text, $border, $hover-background, $hover-text, $hover-border, $active-background, $active-border) {
+@mixin btn-outline($background, $text, $border, $hover-background, $hover-text, $hover-border, $active-background, $active-border, $active-text) {
background-color: $background;
color: $text;
border-color: $border;
@@ -61,13 +61,22 @@
}
}
+ &:focus {
+ box-shadow: 0 0 4px 1px $blue-300;
+ }
+
&:active {
background-color: $active-background;
border-color: $active-border;
- color: $hover-text;
+ box-shadow: inset 0 2px 4px 0 rgba($black, 0.2);
+ color: $active-text;
> .icon {
- color: $hover-text;
+ color: $active-text;
+ }
+
+ &:focus {
+ box-shadow: inset 0 2px 4px 0 rgba($black, 0.2);
}
}
}
@@ -139,6 +148,7 @@
@include btn-white;
color: $gl-text-color;
+ white-space: nowrap;
&:focus:active {
outline: 0;
@@ -163,21 +173,21 @@
&.btn-inverted {
&.btn-success {
- @include btn-outline($white-light, $green-600, $green-500, $green-500, $white-light, $green-600, $green-600, $green-700);
+ @include btn-outline($white-light, $green-600, $green-500, $green-100, $green-700, $green-500, $green-200, $green-600, $green-800);
}
&.btn-remove,
&.btn-danger {
- @include btn-outline($white-light, $red-500, $red-500, $red-500, $white-light, $red-600, $red-600, $red-700);
+ @include btn-outline($white-light, $red-500, $red-500, $red-100, $red-700, $red-500, $red-200, $red-600, $red-800);
}
&.btn-warning {
- @include btn-outline($white-light, $orange-500, $orange-500, $orange-500, $white-light, $orange-600, $orange-600, $orange-700);
+ @include btn-outline($white-light, $orange-500, $orange-500, $orange-100, $orange-700, $orange-500, $orange-200, $orange-600, $orange-800);
}
&.btn-primary,
&.btn-info {
- @include btn-outline($white-light, $blue-500, $blue-500, $blue-500, $white-light, $blue-600, $blue-600, $blue-700);
+ @include btn-outline($white-light, $blue-500, $blue-500, $blue-100, $blue-700, $blue-500, $blue-200, $blue-600, $blue-800);
}
}
@@ -192,11 +202,11 @@
&.btn-close,
&.btn-close-color {
- @include btn-outline($white-light, $orange-600, $orange-500, $orange-500, $white-light, $orange-600, $orange-600, $orange-700);
+ @include btn-outline($white-light, $orange-600, $orange-500, $orange-100, $orange-700, $orange-500, $orange-200, $orange-600, $orange-800);
}
&.btn-spam {
- @include btn-outline($white-light, $red-500, $red-500, $red-500, $white-light, $red-600, $red-600, $red-700);
+ @include btn-outline($white-light, $red-500, $red-500, $red-100, $red-700, $red-500, $red-200, $red-600, $red-800);
}
&.btn-danger,
@@ -239,7 +249,7 @@
padding: 6px 16px;
border-color: $border-color;
color: $gray-darkest;
- background-color: $gray-light;
+ background-color: $white-light;
&:hover,
&:active,
@@ -248,7 +258,6 @@
box-shadow: none;
border-color: lighten($blue-300, 20%);
color: $gray-darkest;
- background-color: $gray-light;
}
}
@@ -329,6 +338,8 @@
svg {
top: auto;
+ width: 16px;
+ height: 16px;
}
}
@@ -395,15 +406,13 @@
cursor: default;
&:active {
- -moz-box-shadow: inset 0 0 0 $white-light;
- -webkit-box-shadow: inset 0 0 0 $white-light;
box-shadow: inset 0 0 0 $white-light;
}
}
.btn-inverted {
&-secondary {
- @include btn-outline($white-light, $blue-500, $blue-500, $blue-500, $white-light, $blue-600, $blue-600, $blue-700);
+ @include btn-outline($white-light, $blue-500, $blue-500, $blue-100, $blue-700, $blue-500, $blue-200, $blue-600, $blue-800);
}
}
@@ -445,7 +454,8 @@
border-color: transparent;
}
- &.btn-secondary-hover-link {
+ &.btn-secondary-hover-link,
+ &.btn-default-hover-link {
color: $gl-text-color-secondary;
&:hover,
diff --git a/app/assets/stylesheets/framework/callout.scss b/app/assets/stylesheets/framework/callout.scss
index 0d8e4afa76f..643b20c56bc 100644
--- a/app/assets/stylesheets/framework/callout.scss
+++ b/app/assets/stylesheets/framework/callout.scss
@@ -28,6 +28,10 @@
background-color: $red-100;
border-color: $red-200;
color: $red-700;
+
+ a {
+ color: $red-700;
+ }
}
.bs-callout-warning {
diff --git a/app/assets/stylesheets/framework/ci_variable_list.scss b/app/assets/stylesheets/framework/ci_variable_list.scss
index 7207e5119ce..28d7492b99c 100644
--- a/app/assets/stylesheets/framework/ci_variable_list.scss
+++ b/app/assets/stylesheets/framework/ci_variable_list.scss
@@ -47,6 +47,7 @@
display: flex;
align-items: flex-start;
width: 100%;
+ padding-bottom: $gl-padding;
@include media-breakpoint-down(xs) {
display: block;
@@ -66,6 +67,7 @@
}
}
+.ci-variable-masked-item,
.ci-variable-protected-item {
flex: 0 1 auto;
display: flex;
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index fa424532879..db09118ba15 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -5,6 +5,9 @@
.cgreen { color: $green-600; }
.cdark { color: $common-gray-dark; }
+.fwhite { fill: $white-light; }
+.fgray { fill: $gray-700; }
+
.text-plain,
.text-plain:hover {
color: $gl-text-color;
@@ -48,6 +51,10 @@
color: $brand-info;
}
+.bg-gray-light {
+ background-color: $gray-light;
+}
+
.text-break-word {
word-break: break-all;
}
@@ -57,7 +64,11 @@
text-decoration: underline;
}
-.hint { font-style: italic; color: $gl-gray-400; }
+.hint {
+ font-style: italic;
+ color: $gl-gray-400;
+}
+
.light { color: $gl-text-color; }
.slead {
@@ -116,7 +127,7 @@ hr {
text-overflow: ellipsis;
white-space: nowrap;
- > div,
+ > div:not(.block),
.str-truncated {
display: inline;
}
@@ -158,13 +169,14 @@ p.time {
text-shadow: none;
}
-.thin_area {
+.thin-area {
height: 150px;
}
// Fix issue with notes & lists creating a bunch of bottom borders.
li.note {
img { max-width: 100%; }
+
.note-title {
li {
border-bottom: 0 !important;
@@ -183,11 +195,6 @@ li.note {
background-color: inherit;
}
-.show-suppressed-diff,
-.show-all-commits {
- cursor: pointer;
-}
-
.error-message {
padding: 10px;
background: $red-400;
@@ -200,12 +207,12 @@ li.note {
}
}
-.warning_message {
- border-left: 4px solid $orange-200;
- color: $orange-700;
+@mixin message($background-color, $border-color, $text-color) {
+ border-left: 4px solid $border-color;
+ color: $text-color;
padding: 10px;
margin-bottom: 10px;
- background: $orange-100;
+ background: $background-color;
padding-left: 20px;
&.centered {
@@ -213,6 +220,14 @@ li.note {
}
}
+.warning_message {
+ @include message($orange-100, $orange-200, $orange-700);
+}
+
+.danger_message {
+ @include message($red-100, $red-200, $red-900);
+}
+
.gitlab-promo {
a {
color: $gl-gray-350;
@@ -335,7 +350,7 @@ img.emoji {
.disabled-content {
pointer-events: none;
- opacity: .5;
+ opacity: 0.5;
}
.break-word {
@@ -371,18 +386,23 @@ img.emoji {
.prepend-top-default { margin-top: $gl-padding !important; }
.prepend-top-16 { margin-top: 16px; }
.prepend-top-20 { margin-top: 20px; }
+.prepend-top-32 { margin-top: 32px; }
.prepend-left-4 { margin-left: 4px; }
.prepend-left-5 { margin-left: 5px; }
.prepend-left-8 { margin-left: 8px; }
.prepend-left-10 { margin-left: 10px; }
+.prepend-left-15 { margin-left: 15px; }
.prepend-left-default { margin-left: $gl-padding; }
.prepend-left-20 { margin-left: 20px; }
+.prepend-left-32 { margin-left: 32px; }
.append-right-4 { margin-right: 4px; }
.append-right-5 { margin-right: 5px; }
.append-right-8 { margin-right: 8px; }
.append-right-10 { margin-right: 10px; }
+.append-right-15 { margin-right: 15px; }
.append-right-default { margin-right: $gl-padding; }
.append-right-20 { margin-right: 20px; }
+.prepend-right-32 { margin-right: 32px; }
.append-bottom-0 { margin-bottom: 0; }
.append-bottom-4 { margin-bottom: $gl-padding-4; }
.append-bottom-5 { margin-bottom: 5px; }
@@ -391,15 +411,20 @@ img.emoji {
.append-bottom-15 { margin-bottom: 15px; }
.append-bottom-20 { margin-bottom: 20px; }
.append-bottom-default { margin-bottom: $gl-padding; }
+.prepend-bottom-32 { margin-bottom: 32px; }
.inline { display: inline-block; }
.center { text-align: center; }
+.block { display: block; }
+.flex { display: flex; }
.vertical-align-middle { vertical-align: middle; }
.vertical-align-sub { vertical-align: sub; }
.flex-align-self-center { align-self: center; }
.flex-grow { flex-grow: 1; }
.flex-no-shrink { flex-shrink: 0; }
.ws-initial { white-space: initial; }
+.ws-normal { white-space: normal; }
.overflow-auto { overflow: auto; }
+
.d-flex-center {
display: flex;
align-items: center;
@@ -413,27 +438,24 @@ img.emoji {
.mw-460 { max-width: 460px; }
.mw-6em { max-width: 6em; }
+.mw-70p { max-width: 70%; }
.min-height-0 { min-height: 0; }
-.w-3 { width: #{3 * $grid-size}; }
-
-.h-3 { width: #{3 * $grid-size}; }
+.svg-w-100 {
+ svg {
+ width: 100%;
+ }
+}
/** COMMON SPACING CLASSES **/
-.gl-pl-0 { padding-left: 0; }
-.gl-pl-1 { padding-left: #{0.5 * $grid-size}; }
-.gl-pl-2 { padding-left: $grid-size; }
-.gl-pl-3 { padding-left: #{2 * $grid-size}; }
-.gl-pl-4 { padding-left: #{3 * $grid-size}; }
-.gl-pl-5 { padding-left: #{4 * $grid-size}; }
-
-.gl-pr-0 { padding-right: 0; }
-.gl-pr-1 { padding-right: #{0.5 * $grid-size}; }
-.gl-pr-2 { padding-right: $grid-size; }
-.gl-pr-3 { padding-right: #{2 * $grid-size}; }
-.gl-pr-4 { padding-right: #{3 * $grid-size}; }
-.gl-pr-5 { padding-right: #{4 * $grid-size}; }
+@each $index, $padding in $spacing-scale {
+ #{'.gl-p-#{$index}'} { padding: $padding; }
+ #{'.gl-pl-#{$index}'} { padding-left: $padding; }
+ #{'.gl-pr-#{$index}'} { padding-right: $padding; }
+ #{'.gl-pt-#{$index}'} { padding-top: $padding; }
+ #{'.gl-pb-#{$index}'} { padding-bottom: $padding; }
+}
/**
* Removes browser specific clear icon from input fields in
@@ -447,10 +469,10 @@ img.emoji {
}
/** COMMON POSITIONING CLASSES */
-.position-bottom-0 { bottom: 0; }
-.position-left-0 { left: 0; }
-.position-right-0 { right: 0; }
-.position-top-0 { top: 0; }
+.position-bottom-0 { bottom: 0 !important; }
+.position-left-0 { left: 0 !important; }
+.position-right-0 { right: 0 !important; }
+.position-top-0 { top: 0 !important; }
.drag-handle {
width: 4px;
@@ -463,3 +485,54 @@ img.emoji {
background-color: $gray-600;
}
}
+
+.cursor-pointer {
+ cursor: pointer;
+}
+
+// Make buttons/dropdowns full-width on mobile
+.full-width-mobile {
+ @include media-breakpoint-down(xs) {
+ width: 100%;
+
+ > .dropdown-menu,
+ > .btn {
+ width: 100%;
+ }
+ }
+}
+
+.onboarding-helper-container {
+ bottom: 40px;
+ right: 40px;
+ font-size: $gl-font-size-small;
+ background: $gray-100;
+ width: 200px;
+ border-radius: 24px;
+ box-shadow: 0 2px 4px $issue-boards-card-shadow;
+ z-index: 10000;
+
+ .collapsible {
+ max-height: 0;
+ transition: max-height 0.5s cubic-bezier(0, 1, 0, 1);
+ }
+
+ &.expanded {
+ border-bottom-right-radius: $border-radius-default;
+ border-bottom-left-radius: $border-radius-default;
+
+ .collapsible {
+ max-height: 1000px;
+ transition: max-height 1s ease-in-out;
+ }
+ }
+
+ .avatar {
+ border-color: darken($gray-normal, 10%);
+
+ img {
+ width: 32px;
+ height: 32px;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index 8b6a7017c47..3238b01c6c0 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -5,7 +5,7 @@
padding-left: $contextual-sidebar-collapsed-width;
}
- @include media-breakpoint-up(lg) {
+ @include media-breakpoint-up(xl) {
padding-left: $contextual-sidebar-width;
}
@@ -15,7 +15,7 @@
}
.page-with-icon-sidebar {
- @include media-breakpoint-up(sm) {
+ @include media-breakpoint-up(md) {
padding-left: $contextual-sidebar-collapsed-width;
}
}
@@ -71,6 +71,44 @@
}
}
+@mixin collapse-contextual-sidebar-content {
+ .context-header {
+ height: 60px;
+ width: $contextual-sidebar-collapsed-width;
+
+ a {
+ padding: 10px 4px;
+ }
+ }
+
+ .sidebar-top-level-items > li {
+ .sidebar-sub-level-items {
+ &:not(.flyout-list) {
+ display: none;
+ }
+ }
+ }
+
+ .nav-icon-container {
+ margin-right: 0;
+ }
+
+ .toggle-sidebar-button {
+ padding: 16px;
+ width: $contextual-sidebar-collapsed-width - 1px;
+
+ .collapse-text,
+ .icon-angle-double-left {
+ display: none;
+ }
+
+ .icon-angle-double-right {
+ display: block;
+ margin: 0;
+ }
+ }
+}
+
.nav-sidebar {
transition: width $sidebar-transition-duration, left $sidebar-transition-duration;
position: fixed;
@@ -89,7 +127,7 @@
}
}
- &.sidebar-collapsed-desktop {
+ @mixin collapse-contextual-sidebar {
width: $contextual-sidebar-collapsed-width;
.nav-sidebar-inner-scroll {
@@ -115,6 +153,10 @@
}
}
+ &.sidebar-collapsed-desktop {
+ @include collapse-contextual-sidebar;
+ }
+
&.sidebar-expanded-mobile {
left: 0;
}
@@ -150,7 +192,7 @@
}
}
- @include media-breakpoint-down(xs) {
+ @include media-breakpoint-down(sm) {
left: (-$contextual-sidebar-width);
}
@@ -167,16 +209,19 @@
height: 16px;
width: 16px;
}
+
+ @media (min-width: map-get($grid-breakpoints, md)) and (max-width: map-get($grid-breakpoints, xl) - 1px) {
+ &:not(.sidebar-expanded-mobile) {
+ @include collapse-contextual-sidebar;
+ @include collapse-contextual-sidebar-content;
+ }
+ }
}
.nav-sidebar-inner-scroll {
height: 100%;
width: 100%;
overflow: auto;
-
- @include media-breakpoint-up(sm) {
- overflow: hidden;
- }
}
.with-performance-bar .nav-sidebar {
@@ -346,53 +391,13 @@
}
}
-.toggle-sidebar-button {
- @include media-breakpoint-down(xs) {
- display: none;
- }
-}
-
.collapse-text {
white-space: nowrap;
overflow: hidden;
}
.sidebar-collapsed-desktop {
- .context-header {
- height: 60px;
- width: $contextual-sidebar-collapsed-width;
-
- a {
- padding: 10px 4px;
- }
- }
-
- .sidebar-top-level-items > li {
- .sidebar-sub-level-items {
- &:not(.flyout-list) {
- display: none;
- }
- }
- }
-
- .nav-icon-container {
- margin-right: 0;
- }
-
- .toggle-sidebar-button {
- padding: 16px;
- width: $contextual-sidebar-collapsed-width - 1px;
-
- .collapse-text,
- .icon-angle-double-left {
- display: none;
- }
-
- .icon-angle-double-right {
- display: block;
- margin: 0;
- }
- }
+ @include collapse-contextual-sidebar-content;
}
.fly-out-top-item {
@@ -428,16 +433,14 @@
color: $gl-text-color-secondary;
}
- @include media-breakpoint-down(xs) {
+ @include media-breakpoint-down(sm) {
display: flex;
align-items: center;
i {
font-size: 18px;
}
- }
- @include media-breakpoint-down(xs) {
+ .breadcrumbs-links {
padding-left: $gl-padding;
border-left: 1px solid $gl-text-color-quaternary;
@@ -445,21 +448,25 @@
}
}
-@include media-breakpoint-down(xs) {
+@include media-breakpoint-down(sm) {
.close-nav-button {
display: flex;
}
-}
-.mobile-overlay {
- display: none;
+ .toggle-sidebar-button {
+ display: none;
+ }
- &.mobile-nav-open {
- display: block;
- position: fixed;
- background-color: $black-transparent;
- height: 100%;
- width: 100%;
- z-index: 300;
+ .mobile-overlay {
+ display: none;
+
+ &.mobile-nav-open {
+ display: block;
+ position: fixed;
+ background-color: $black-transparent;
+ height: 100%;
+ width: 100%;
+ z-index: 300;
+ }
}
}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index b90db135b4a..cd951f67293 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -351,6 +351,10 @@
// Expects up to 3 digits on the badge
margin-right: 40px;
}
+
+ .dropdown-menu-content {
+ padding: $dropdown-item-padding-y $dropdown-item-padding-x;
+ }
}
.droplab-dropdown {
@@ -566,10 +570,10 @@
}
.dropdown-menu-close {
- right: 5px;
+ top: $gl-padding-4;
+ right: $gl-padding-8;
width: 20px;
height: 20px;
- top: -1px;
}
.dropdown-menu-close-icon {
diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss
index be85e03430e..13c5541da92 100644
--- a/app/assets/stylesheets/framework/emojis.scss
+++ b/app/assets/stylesheets/framework/emojis.scss
@@ -2,7 +2,7 @@ gl-emoji {
font-style: normal;
display: inline-flex;
vertical-align: middle;
- font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
font-size: 1.4em;
line-height: 1em;
}
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 6108eaa1ad0..536a26a6ffe 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -25,10 +25,6 @@
}
}
- table {
- @extend .table;
- }
-
.file-title {
position: relative;
background-color: $gray-light;
@@ -123,7 +119,7 @@
}
}
- &.wiki {
+ &.md {
padding: $gl-padding;
@include media-breakpoint-up(md) {
@@ -245,6 +241,7 @@
*/
&.code {
padding: 0;
+ border-radius: 0 0 $border-radius-default $border-radius-default;
}
.list-inline.previews {
@@ -332,9 +329,13 @@ span.idiff {
background-color: $gray-light;
border-bottom: 1px solid $border-color;
border-top: 1px solid $border-color;
- padding: 5px $gl-padding;
+ 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 {
@@ -365,10 +366,6 @@ span.idiff {
color: $gl-text-color;
}
- small {
- margin: 0 10px 0 0;
- }
-
.file-actions .btn {
padding: 0 10px;
font-size: 13px;
@@ -456,6 +453,28 @@ span.idiff {
}
}
+ .note-container {
+ .user-avatar-link.new-comment {
+ position: absolute;
+ margin: 40px $gl-padding 0 116px;
+
+ ~ .note-edit-form form.edit-note {
+ @include media-breakpoint-up(sm) {
+ margin-left: $note-icon-gutter-width;
+ }
+ }
+ }
+ }
+
+ .diff-discussions:not(:last-child) .discussion .discussion-body {
+ padding-bottom: $gl-padding;
+
+ .discussion-reply-holder {
+ border-bottom: 1px solid $gray-100;
+ border-radius: 0;
+ }
+ }
+
.md-previewer {
padding: $gl-padding;
}
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index f48b3ddc912..26cbb7f5c13 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -50,19 +50,15 @@
}
.filtered-search-wrapper {
- display: -webkit-flex;
display: flex;
@include media-breakpoint-down(xs) {
- -webkit-flex-direction: column;
flex-direction: column;
}
.tokens-container {
- display: -webkit-flex;
display: flex;
flex: 1;
- -webkit-flex: 1;
padding-left: 12px;
position: relative;
margin-bottom: 0;
@@ -82,21 +78,18 @@
.input-token:only-child,
.input-token:last-child {
flex: 1;
- -webkit-flex: 1;
max-width: inherit;
}
}
.filtered-search-token,
.filtered-search-term {
- display: -webkit-flex;
display: flex;
flex-shrink: 0;
margin-top: 4px;
margin-bottom: 4px;
.selectable {
- display: -webkit-flex;
display: flex;
}
@@ -115,6 +108,8 @@
}
.value-container {
+ display: flex;
+ align-items: center;
background-color: $white-normal;
color: $filter-value-text-color;
border-radius: 0 2px 2px 0;
@@ -128,7 +123,7 @@
.remove-token {
display: inline-block;
- padding-left: 4px;
+ padding-left: 8px;
padding-right: 0;
.fa-close {
@@ -176,7 +171,6 @@
}
.scroll-container {
- display: -webkit-flex;
display: flex;
overflow-x: auto;
white-space: nowrap;
@@ -186,7 +180,6 @@
.filtered-search-box {
position: relative;
flex: 1;
- display: -webkit-flex;
display: flex;
width: 100%;
min-width: 0;
@@ -194,7 +187,6 @@
background-color: $white-light;
@include media-breakpoint-down(xs) {
- -webkit-flex: 1 1 auto;
flex: 1 1 auto;
margin-bottom: 10px;
}
@@ -226,7 +218,7 @@
min-width: 200px;
padding-right: 25px;
padding-left: 0;
- height: $input-height;
+ height: $input-height - 2;
line-height: inherit;
border-color: transparent;
@@ -349,7 +341,6 @@
}
.filter-dropdown-container {
- display: -webkit-flex;
display: flex;
.dropdown-toggle {
@@ -423,3 +414,10 @@
padding: 8px 16px;
text-align: center;
}
+
+.search-token-target-branch {
+ .value {
+ font-family: $monospace-font;
+ font-size: 13px;
+ }
+}
diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss
index afa85f0e4ae..e3dd127366d 100644
--- a/app/assets/stylesheets/framework/flash.scss
+++ b/app/assets/stylesheets/framework/flash.scss
@@ -6,6 +6,19 @@
position: relative;
z-index: 1;
+ .flash-notice,
+ .flash-alert,
+ .flash-success,
+ .flash-warning {
+ border-radius: $border-radius-default;
+ color: $white-light;
+
+ .container-fluid,
+ .container-fluid.container-limited {
+ background: transparent;
+ }
+ }
+
.flash-notice {
@extend .alert;
background-color: $blue-500;
@@ -28,7 +41,8 @@
.flash-warning {
@extend .alert;
- background-color: $orange-500;
+ background-color: $orange-100;
+ color: $orange-900;
margin: 0;
}
@@ -60,19 +74,6 @@
margin: 0;
}
- .flash-notice,
- .flash-alert,
- .flash-success,
- .flash-warning {
- border-radius: $border-radius-default;
- color: $white-light;
-
- .container-fluid,
- .container-fluid.container-limited {
- background: transparent;
- }
- }
-
&.flash-container-page {
margin-bottom: 0;
diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss
index cbf9ee24ec5..2a601afff53 100644
--- a/app/assets/stylesheets/framework/forms.scss
+++ b/app/assets/stylesheets/framework/forms.scss
@@ -27,10 +27,16 @@ input[type='text'].danger {
}
label {
+ font-weight: $gl-font-weight-bold;
+
&.inline-label {
margin: 0;
}
+ &.form-check-label {
+ font-weight: $gl-font-weight-normal;
+ }
+
&.label-bold {
font-weight: $gl-font-weight-bold;
}
@@ -41,14 +47,6 @@ label {
margin: 0;
}
-.form-label {
- @extend label;
-}
-
-.form-control-label {
- @extend .col-md-2;
-}
-
.inline-input-group {
width: 250px;
}
@@ -81,44 +79,14 @@ label {
margin-left: 0;
margin-right: 0;
- .form-control-label {
- font-weight: $gl-font-weight-bold;
- padding-top: 4px;
- }
-
.form-control {
height: 29px;
background: $white-light;
font-family: $monospace-font;
}
- .input-group-prepend .btn,
- .input-group-append .btn {
- padding: 3px $gl-btn-padding;
- background-color: $gray-light;
- border: 1px solid $border-color;
- }
-
- .text-block {
- line-height: 0.8;
- padding-top: 9px;
-
- code {
- line-height: 1.8;
- }
-
- img {
- margin-right: $gl-padding;
- }
- }
-
@include media-breakpoint-down(xs) {
padding: 0 $gl-padding;
-
- .form-control-label,
- .text-block {
- padding-left: 0;
- }
}
}
@@ -128,7 +96,7 @@ label {
.form-control {
@include box-shadow(none);
- border-radius: 2px;
+ border-radius: $border-radius-default;
padding: $gl-vert-padding $gl-input-padding;
&.input-short {
@@ -140,25 +108,14 @@ label {
}
}
-.select-wrapper {
- position: relative;
-
- .fa-chevron-down {
- position: absolute;
- font-size: 10px;
- right: 10px;
- top: 12px;
- color: $gray-darkest;
- pointer-events: none;
- }
-}
-
.select-control {
padding-left: 10px;
padding-right: 10px;
+ appearance: none;
+ /* stylelint-disable property-no-vendor-prefix */
-webkit-appearance: none;
-moz-appearance: none;
- appearance: none;
+ /* stylelint-enable property-no-vendor-prefix */
&::-ms-expand {
display: none;
@@ -173,12 +130,7 @@ label {
margin-top: 35px;
}
-.form-group .form-control-label,
-.form-group .form-control-label-full-width {
- font-weight: $gl-font-weight-normal;
-}
-
-.form-control::-webkit-input-placeholder {
+.form-control::placeholder {
color: $gl-text-color-tertiary;
}
@@ -203,10 +155,13 @@ label {
.form-text.text-muted {
margin-bottom: 0;
margin-top: #{$grid-size / 2};
+ font-size: $gl-font-size;
}
-.gl-field-error {
+.gl-field-error,
+.invalid-feedback {
color: $red-500;
+ font-size: $gl-font-size;
}
.gl-show-field-errors {
@@ -218,7 +173,8 @@ label {
border: 1px solid $green-600;
&:focus {
- box-shadow: 0 0 0 1px $green-600 inset, 0 1px 1px $gl-field-focus-shadow inset, 0 0 4px 0 $green-600;
+ box-shadow: 0 0 0 1px $green-600 inset, 0 1px 1px $gl-field-focus-shadow inset,
+ 0 0 4px 0 $green-600;
border: 0 none;
}
}
@@ -227,7 +183,8 @@ label {
border: 1px solid $red-500;
&:focus {
- box-shadow: 0 0 0 1px $red-500 inset, 0 1px 1px $gl-field-focus-shadow inset, 0 0 4px 0 $gl-field-focus-shadow-error;
+ box-shadow: 0 0 0 1px $red-500 inset, 0 1px 1px $gl-field-focus-shadow inset,
+ 0 0 4px 0 $gl-field-focus-shadow-error;
border: 0 none;
}
}
@@ -253,16 +210,26 @@ label {
}
}
-.input-icon-wrapper {
+.input-icon-wrapper,
+.select-wrapper {
position: relative;
+}
- .input-icon-right {
- position: absolute;
- right: 0.8em;
- top: 50%;
- transform: translateY(-50%);
- color: $gray-600;
- }
+.select-wrapper > .fa-chevron-down {
+ position: absolute;
+ font-size: 10px;
+ right: 10px;
+ top: 12px;
+ color: $gray-darkest;
+ pointer-events: none;
+}
+
+.input-icon-wrapper > .input-icon-right {
+ position: absolute;
+ right: 0.8em;
+ top: 50%;
+ transform: translateY(-50%);
+ color: $gray-600;
}
.input-md {
@@ -274,3 +241,21 @@ label {
max-width: $input-lg-width;
width: 100%;
}
+
+.input-group-text {
+ max-height: $input-height;
+}
+
+.gl-form-checkbox {
+ align-items: baseline;
+
+ &.form-check-inline .form-check-input {
+ align-self: flex-start;
+ margin-right: $gl-padding-8;
+ height: 1.5 * $gl-font-size;
+ }
+
+ .help-text {
+ margin-bottom: 0;
+ }
+}
diff --git a/app/assets/stylesheets/framework/gfm.scss b/app/assets/stylesheets/framework/gfm.scss
index 50d4298d418..6943bfbc3d0 100644
--- a/app/assets/stylesheets/framework/gfm.scss
+++ b/app/assets/stylesheets/framework/gfm.scss
@@ -32,7 +32,7 @@
height: $chip-size;
background: $white-light;
background-image: linear-gradient(135deg, $gray-dark 25%, transparent 0%, transparent 75%, $gray-dark 0%),
- linear-gradient(135deg, $gray-dark 25%, transparent 0%, transparent 75%, $gray-dark 0%);
+ linear-gradient(135deg, $gray-dark 25%, transparent 0%, transparent 75%, $gray-dark 0%);
background-size: $bg-size $bg-size;
background-position: 0 0, $bg-pos $bg-pos;
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 23dcc1817b1..1bc597bd4ae 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -39,7 +39,6 @@
.header-content {
width: 100%;
- display: -webkit-flex;
display: flex;
justify-content: space-between;
position: relative;
@@ -47,11 +46,8 @@
padding-left: 0;
.title-container {
- display: -webkit-flex;
display: flex;
- -webkit-align-items: stretch;
align-items: stretch;
- -webkit-flex: 1 1 auto;
flex: 1 1 auto;
padding-top: 0;
overflow: visible;
@@ -60,7 +56,6 @@
.title {
padding-right: 0;
color: currentColor;
- display: -webkit-flex;
display: flex;
position: relative;
margin: 0;
@@ -85,7 +80,6 @@
}
a {
- display: -webkit-flex;
display: flex;
align-items: center;
padding: 2px 8px;
@@ -173,7 +167,6 @@
.navbar-nav {
@include media-breakpoint-down(xs) {
- display: -webkit-flex;
display: flex;
padding-right: 10px;
flex-direction: row;
@@ -258,7 +251,6 @@
> li {
> a,
> button {
- display: -webkit-flex;
display: flex;
align-items: center;
justify-content: center;
@@ -294,7 +286,6 @@
}
.navbar-sub-nav {
- display: -webkit-flex;
display: flex;
margin: 0 0 0 6px;
@@ -313,7 +304,9 @@
}
}
-.caret-down {
+.caret-down,
+.btn .caret-down {
+ top: 0;
height: 11px;
width: 11px;
margin-left: 4px;
@@ -326,14 +319,12 @@
}
.breadcrumbs {
- display: -webkit-flex;
display: flex;
min-height: $breadcrumb-min-height;
color: $gl-text-color;
}
.breadcrumbs-container {
- display: -webkit-flex;
display: flex;
width: 100%;
position: relative;
@@ -344,7 +335,6 @@
}
.breadcrumbs-links {
- -webkit-flex: 1;
flex: 1;
min-width: 0;
align-self: center;
@@ -379,7 +369,6 @@
}
.breadcrumbs-list {
- display: -webkit-flex;
display: flex;
margin-bottom: 0;
line-height: 16px;
@@ -430,7 +419,6 @@
}
.breadcrumbs-extra {
- display: -webkit-flex;
display: flex;
flex: 0 0 auto;
margin-left: auto;
@@ -459,29 +447,44 @@
}
}
+.title-container,
.navbar-nav {
- li {
- .badge.badge-pill {
- position: inherit;
- font-weight: $gl-font-weight-normal;
- margin-left: -6px;
- font-size: 11px;
- color: $white-light;
- padding: 0 5px;
- line-height: 12px;
- border-radius: 7px;
- box-shadow: 0 1px 0 rgba($gl-header-color, 0.2);
-
- &.issues-count {
- background-color: $green-500;
- }
+ .badge.badge-pill {
+ position: inherit;
+ font-weight: $gl-font-weight-normal;
+ margin-left: -6px;
+ font-size: 11px;
+ color: $white-light;
+ padding: 0 5px;
+ line-height: 12px;
+ border-radius: 7px;
+ box-shadow: 0 1px 0 rgba($gl-header-color, 0.2);
+
+ &.green-badge {
+ background-color: $green-500;
+ }
- &.merge-requests-count {
- background-color: $orange-600;
- }
+ &.merge-requests-count {
+ background-color: $orange-600;
+ }
+
+ &.todos-count {
+ background-color: $blue-500;
+ }
+ }
+
+ .canary-badge {
+ .badge {
+ font-size: $gl-font-size-small;
+ line-height: $gl-line-height;
+ padding: 0 $grid-size;
+ }
- &.todos-count {
- background-color: $blue-500;
+ &:hover {
+ text-decoration: none;
+
+ .badge {
+ text-decoration: none;
}
}
}
@@ -594,10 +597,15 @@
.emoji-menu-toggle-button {
@include emoji-menu-toggle-button;
+ padding: $gl-vert-padding $gl-btn-padding;
}
.input-group {
- height: 34px;
+ &,
+ .input-group-prepend,
+ .input-group-append {
+ height: $input-height;
+ }
}
}
diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss
index 946f575ac13..741f92110c3 100644
--- a/app/assets/stylesheets/framework/highlight.scss
+++ b/app/assets/stylesheets/framework/highlight.scss
@@ -8,7 +8,7 @@
pre {
padding: 10px 0;
border: 0;
- border-radius: 0;
+ border-radius: 0 0 $border-radius-default $border-radius-default;
font-family: $monospace-font;
font-size: $code-font-size;
line-height: 19px;
@@ -42,6 +42,7 @@
padding: 10px;
text-align: right;
float: left;
+ border-bottom-left-radius: $border-radius-default;
a {
font-family: $monospace-font;
diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss
index 49b9b7014ae..1be5ef276fd 100644
--- a/app/assets/stylesheets/framework/icons.scss
+++ b/app/assets/stylesheets/framework/icons.scss
@@ -20,8 +20,8 @@
}
.ci-status-icon-pending,
-.ci-status-icon-failed_with_warnings,
-.ci-status-icon-success_with_warnings {
+.ci-status-icon-failed-with-warnings,
+.ci-status-icon-success-with-warnings {
svg {
fill: $orange-500;
}
@@ -31,6 +31,7 @@
}
}
+.ci-status-icon-preparing,
.ci-status-icon-running {
svg {
fill: $blue-400;
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index d9d4a210f5f..555a3fe0dc7 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -15,7 +15,7 @@
word-wrap: break-word;
&::after {
- content: " ";
+ content: ' ';
display: table;
clear: both;
}
@@ -156,6 +156,12 @@ ul.content-list {
margin-top: 3px;
margin-bottom: 4px;
+ &.btn-ldap-override {
+ @include media-breakpoint-up(sm) {
+ margin-bottom: 0;
+ }
+ }
+
&.has-tooltip,
&:last-child {
margin-right: 0;
@@ -167,15 +173,7 @@ ul.content-list {
}
.no-comments {
- opacity: .5;
- }
- }
-
- .member-controls {
- float: none;
-
- @include media-breakpoint-up(sm) {
- float: right;
+ opacity: 0.5;
}
}
@@ -196,8 +194,6 @@ ul.content-list {
// Content list using flexbox
.flex-list {
.flex-row {
- display: -webkit-flex;
- display: -ms-flexbox;
display: flex;
align-items: center;
white-space: nowrap;
diff --git a/app/assets/stylesheets/framework/logo.scss b/app/assets/stylesheets/framework/logo.scss
index 429cfbe7235..c5feefb8c54 100644
--- a/app/assets/stylesheets/framework/logo.scss
+++ b/app/assets/stylesheets/framework/logo.scss
@@ -9,7 +9,6 @@
}
.tanuki-logo {
-
.tanuki-left-ear,
.tanuki-right-ear,
.tanuki-nose {
@@ -34,7 +33,9 @@
.tanuki-left-cheek {
@include include-keyframes(animate-tanuki-left-cheek) {
- 0%, 10%, 100% {
+ 0%,
+ 10%,
+ 100% {
fill: lighten($tanuki-yellow, 25%);
}
@@ -46,11 +47,13 @@
.tanuki-left-eye {
@include include-keyframes(animate-tanuki-left-eye) {
- 10%, 80% {
+ 10%,
+ 80% {
fill: $tanuki-orange;
}
- 20%, 90% {
+ 20%,
+ 90% {
fill: lighten($tanuki-orange, 25%);
}
}
@@ -58,11 +61,13 @@
.tanuki-left-ear {
@include include-keyframes(animate-tanuki-left-ear) {
- 10%, 80% {
+ 10%,
+ 80% {
fill: $tanuki-red;
}
- 20%, 90% {
+ 20%,
+ 90% {
fill: lighten($tanuki-red, 25%);
}
}
@@ -70,11 +75,13 @@
.tanuki-nose {
@include include-keyframes(animate-tanuki-nose) {
- 20%, 70% {
+ 20%,
+ 70% {
fill: $tanuki-red;
}
- 30%, 80% {
+ 30%,
+ 80% {
fill: lighten($tanuki-red, 25%);
}
}
@@ -82,11 +89,13 @@
.tanuki-right-eye {
@include include-keyframes(animate-tanuki-right-eye) {
- 30%, 60% {
+ 30%,
+ 60% {
fill: $tanuki-orange;
}
- 40%, 70% {
+ 40%,
+ 70% {
fill: lighten($tanuki-orange, 25%);
}
}
@@ -94,11 +103,13 @@
.tanuki-right-ear {
@include include-keyframes(animate-tanuki-right-ear) {
- 30%, 60% {
+ 30%,
+ 60% {
fill: $tanuki-red;
}
- 40%, 70% {
+ 40%,
+ 70% {
fill: lighten($tanuki-red, 25%);
}
}
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index d6c4e68f68f..bfd96a4bc05 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -50,10 +50,6 @@
transition: opacity 200ms ease-in-out;
}
-.md-area {
- position: relative;
-}
-
.md-header {
.nav-links {
a {
@@ -61,6 +57,10 @@
padding-top: 0;
line-height: 19px;
+ &.btn.btn-sm {
+ padding: 2px 5px;
+ }
+
&:focus {
margin-top: -10px;
padding-top: 10px;
@@ -131,30 +131,6 @@
width: 100%;
}
-.md:not(.use-csslab) {
- &.md-preview-holder {
- // Reset ul style types since we're nested inside a ul already
- @include bulleted-list;
- }
-
- // On diffs code should wrap nicely and not overflow
- code {
- white-space: pre-wrap;
- word-break: keep-all;
- }
-
- hr {
- // Darken 'whitesmoke' a bit to make it more visible in note bodies
- border-color: darken($gray-normal, 8%);
- margin: 10px 0;
- }
-
-
- table:not(.js-syntax-highlight) {
- @include markdown-table;
- }
-}
-
.toolbar-btn {
float: left;
padding: 0 7px;
@@ -187,88 +163,6 @@
}
}
-.atwho-view {
- overflow-y: auto;
- overflow-x: hidden;
-
- .name,
- small.aliases,
- small.params {
- float: left;
- }
-
- small.aliases,
- small.params {
- padding: 2px 5px;
- }
-
- small.description {
- float: right;
- padding: 3px 5px;
- }
-
- .avatar-inline {
- margin-bottom: 0;
- }
-
- .has-warning {
- .name,
- .description {
- color: $orange-700;
- }
- }
-
- .cur {
- .avatar {
- @include disable-all-animation;
- border: 1px solid $white-light;
- }
- }
-
- ul > li {
- @include clearfix;
- white-space: nowrap;
- }
-
- // TODO: fallback to global style
- .atwho-view-ul {
- padding: 8px 1px;
-
- li {
- padding: 8px 16px;
- border: 0;
-
- &.cur {
- background-color: $gray-darker;
- color: $gl-text-color;
-
- small {
- color: inherit;
- }
-
- &.has-warning {
- color: $orange-700;
- background-color: $orange-100;
- }
- }
-
- div.avatar {
- display: inline-flex;
- justify-content: center;
- align-items: center;
-
- .center {
- line-height: 14px;
- }
- }
-
- strong {
- color: $gl-text-color;
- }
- }
- }
-}
-
.md-suggestion-diff {
display: table !important;
border: 1px solid $border-color !important;
@@ -293,15 +187,6 @@
}
@include media-breakpoint-down(xs) {
- .atwho-view-ul {
- width: 350px;
- }
-
- .atwho-view ul li {
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
.referenced-users {
margin-right: 0;
}
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index 3b0869e31a9..e7278554e6e 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -22,32 +22,6 @@
}
/*
- * Mixin for markdown tables
- */
-@mixin markdown-table {
- width: auto;
- display: inline-block;
- overflow-x: auto;
- border: 0;
- border-color: $gl-gray-100;
-
- @supports (width: fit-content) {
- display: block;
- width: fit-content;
- }
-
- tr {
- th {
- border-bottom: solid 2px $gl-gray-100;
- }
-
- td {
- border-color: $gl-gray-100;
- }
- }
-}
-
-/*
* Base mixin for lists in GitLab
*/
@mixin basic-list {
@@ -99,20 +73,6 @@
}
}
-@mixin bulleted-list {
- > ul {
- list-style-type: disc;
-
- ul {
- list-style-type: circle;
-
- ul {
- list-style-type: square;
- }
- }
- }
-}
-
@mixin webkit-prefix($property, $value) {
#{'-webkit-' + $property}: $value;
#{$property}: $value;
@@ -120,16 +80,13 @@
/* http://phrappe.com/css/conditional-css-for-webkit-based-browsers/ */
@mixin on-webkit-only {
+ /* stylelint-disable-next-line media-feature-name-no-vendor-prefix */
@media screen and (-webkit-min-device-pixel-ratio: 0) {
@content;
}
}
@mixin keyframes($animation-name) {
- @-webkit-keyframes #{$animation-name} {
- @content;
- }
-
@keyframes #{$animation-name} {
@content;
}
@@ -169,12 +126,10 @@
width: 43px;
height: 30px;
transition-duration: 0.3s;
- -webkit-transform: translateZ(0);
- background: linear-gradient(
- to $gradient-direction,
- $gradient-color 45%,
- rgba($gradient-color, 0.4)
- );
+ transform: translateZ(0);
+ background: linear-gradient(to $gradient-direction,
+ $gradient-color 45%,
+ rgba($gradient-color, 0.4));
&.scrolling {
visibility: visible;
@@ -263,16 +218,22 @@
}
}
-@mixin build-trace-top-bar($height) {
+// Used in EE for Web Terminal
+@mixin build-trace-bar($height) {
height: $height;
min-height: $height;
background: $gray-light;
border: 1px solid $border-color;
color: $gl-text-color;
- position: sticky;
+ padding: $grid-size;
+}
+
+@mixin build-trace-top-bar($height) {
+ @include build-trace-bar($height);
+
position: -webkit-sticky;
+ position: sticky;
top: $header-height;
- padding: $grid-size;
.with-performance-bar & {
top: $header-height + $performance-bar-height;
@@ -370,8 +331,8 @@
line-height: 1;
padding: 0;
min-width: 16px;
- color: $gray-darkest;
- fill: $gray-darkest;
+ color: $gray-600;
+ fill: $gray-600;
.fa {
position: relative;
@@ -421,3 +382,17 @@
}
}
}
+
+/*
+* Mixin that handles the size and right margin of avatars.
+*/
+@mixin avatar-size($size, $margin-right) {
+ width: $size;
+ height: $size;
+ margin-right: $margin-right;
+}
+
+@mixin code-icon-size() {
+ width: $gl-font-size * $code-line-height * 0.9;
+ height: $gl-font-size * $code-line-height * 0.9;
+}
diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss
index 3703b7568c8..f75e5b55506 100644
--- a/app/assets/stylesheets/framework/modal.scss
+++ b/app/assets/stylesheets/framework/modal.scss
@@ -34,10 +34,10 @@
.modal-body {
background-color: $modal-body-bg;
line-height: $line-height-base;
- min-height: $modal-body-height;
position: relative;
padding: #{3 * $grid-size} #{2 * $grid-size};
text-align: left;
+ white-space: normal;
.form-actions {
margin: #{2 * $grid-size} #{-2 * $grid-size} #{-2 * $grid-size};
@@ -52,24 +52,22 @@
display: flex;
flex-direction: row;
- .btn + .btn {
+ .btn + .btn:not(.dropdown-toggle-split),
+ .btn + .btn-group,
+ .btn-group + .btn {
margin-left: $grid-size;
}
@include media-breakpoint-down(xs) {
flex-direction: column;
- .btn + .btn {
+ .btn + .btn:not(.dropdown-toggle-split),
+ .btn + .btn-group,
+ .btn-group + .btn {
margin-left: 0;
margin-top: $grid-size;
}
}
-
- @include media-breakpoint-up(sm) {
- .btn:nth-child(1) {
- margin-left: auto;
- }
- }
}
body.modal-open {
diff --git a/app/assets/stylesheets/framework/notes.scss b/app/assets/stylesheets/framework/notes.scss
index d349e3fad9c..85ddf11d6fe 100644
--- a/app/assets/stylesheets/framework/notes.scss
+++ b/app/assets/stylesheets/framework/notes.scss
@@ -4,7 +4,7 @@
}
// Diff is side by side
- .notes_content.parallel & {
+ .notes-content.parallel & {
// We hide at double what we normally hide at because
// there are two columns of notes
@media (#{$condition}-width: (2 * $breakpoint-width)) {
diff --git a/app/assets/stylesheets/framework/page_title.scss b/app/assets/stylesheets/framework/page_title.scss
index e8302953a63..c77e2be8e5a 100644
--- a/app/assets/stylesheets/framework/page_title.scss
+++ b/app/assets/stylesheets/framework/page_title.scss
@@ -1,8 +1,4 @@
.page-title-holder {
- @extend .d-flex;
- @extend .align-items-center;
-
- padding-top: $gl-padding-top;
border-bottom: 1px solid $border-color;
.page-title {
diff --git a/app/assets/stylesheets/framework/panels.scss b/app/assets/stylesheets/framework/panels.scss
index 3a117106cff..cd3d6f8297e 100644
--- a/app/assets/stylesheets/framework/panels.scss
+++ b/app/assets/stylesheets/framework/panels.scss
@@ -7,7 +7,6 @@
margin-bottom: $gl-vert-padding;
}
-
.card-header {
padding: $gl-vert-padding $gl-padding;
line-height: 36px;
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index 19640ab5986..ada8f2fe1a6 100644
--- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -13,8 +13,8 @@
a,
button {
- padding: $gl-btn-padding;
- padding-bottom: 11px;
+ padding: $gl-padding-8;
+ padding-bottom: $gl-padding-8 + 1;
font-size: 14px;
line-height: 28px;
color: $gl-text-color-secondary;
@@ -58,8 +58,12 @@
}
.top-area {
- @include clearfix;
border-bottom: 1px solid $border-color;
+ display: flex;
+
+ @include media-breakpoint-down(md) {
+ flex-flow: column-reverse wrap;
+ }
.nav-text {
padding-top: 16px;
@@ -75,9 +79,8 @@
}
.nav-links {
- margin-bottom: 0;
border-bottom: 0;
- float: left;
+ flex: 1;
&.wide {
width: 100%;
@@ -98,16 +101,23 @@
&.mobile-separator {
border-bottom: 1px solid $border-color;
+ margin-bottom: $gl-padding-8;
}
}
}
.nav-controls {
display: inline-block;
- float: right;
text-align: right;
- padding: $gl-padding-8 0;
- margin-bottom: 0;
+
+ @include media-breakpoint-down(sm) {
+ margin-top: $gl-padding-8;
+ }
+
+ @include media-breakpoint-up(md) {
+ display: flex;
+ align-items: center;
+ }
> .btn,
> .btn-container,
@@ -115,8 +125,6 @@
> input,
> form {
margin-right: $gl-padding-top;
- display: inline-block;
- vertical-align: top;
&:last-child {
margin-right: 0;
@@ -143,7 +151,7 @@
@include media-breakpoint-up(lg) { width: 250px; }
}
- @include media-breakpoint-down(xs) {
+ @include media-breakpoint-down(sm) {
padding-bottom: 0;
width: 100%;
@@ -153,7 +161,7 @@
.dropdown-toggle,
.dropdown-menu-toggle,
.form-control {
- margin: 0 0 10px;
+ margin: 0 0 $gl-padding-8;
display: block;
width: 100%;
}
@@ -165,7 +173,7 @@
form {
display: block;
height: auto;
- margin-bottom: 14px;
+ margin-bottom: $gl-padding-8;
input {
width: 100%;
@@ -181,6 +189,33 @@
margin: 0;
width: 100%;
}
+
+ &.inline {
+ display: flex;
+ flex-flow: row wrap;
+ justify-content: space-between;
+
+ > .btn,
+ > .btn-container,
+ > .dropdown,
+ > input,
+ > form {
+ flex: 1 1 auto;
+ margin: 0 0 10px;
+ margin-left: $gl-padding-top;
+ width: auto;
+
+ &:first-child {
+ margin-left: 0;
+ float: none;
+ }
+ }
+
+ .btn-full {
+ flex: 1 1 100%;
+ margin-left: 0;
+ }
+ }
}
}
@@ -209,20 +244,11 @@
width: 100%;
}
- @include media-breakpoint-down(xs) {
- flex-flow: row wrap;
-
+ @include media-breakpoint-down(md) {
.nav-controls {
$controls-margin: $btn-margin-5 - 2px;
flex: 0 0 100%;
-
- &.controls-flex {
- display: flex;
- flex-flow: row wrap;
- align-items: center;
- justify-content: center;
- padding: 0 0 $gl-padding-top;
- }
+ margin-top: $gl-padding-8;
.controls-item,
.controls-item-full,
@@ -299,8 +325,8 @@
.fade-right,
.fade-left {
- top: 16px;
- bottom: auto;
+ bottom: $gl-padding;
+ top: auto;
}
&.is-smaller {
@@ -340,6 +366,7 @@
display: flex;
border-bottom: 1px solid $border-color;
overflow: hidden;
+ align-items: center;
.nav-links {
border-bottom: 0;
diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss
index bcd601e198a..81ccea1e01f 100644
--- a/app/assets/stylesheets/framework/selects.scss
+++ b/app/assets/stylesheets/framework/selects.scss
@@ -32,7 +32,7 @@
}
&::after {
- content: "\f078";
+ content: '\f078';
position: absolute;
z-index: 1;
text-align: center;
@@ -264,6 +264,16 @@
}
}
+.project-result {
+ .project-name {
+ font-weight: $gl-font-weight-bold;
+ }
+
+ .project-path {
+ color: $gl-gray-400;
+ }
+}
+
.user-result {
min-height: 24px;
display: flex;
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index c4dbcf2ddc9..43d0e51e4c9 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -157,3 +157,55 @@
.sidebar-collapsed-icon .sidebar-collapsed-value {
font-size: 12px;
}
+
+.ancestor-tree {
+ .vertical-timeline {
+ position: relative;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+
+ &::before {
+ content: '';
+ border-left: 1px solid $gray-500;
+ position: absolute;
+ top: $gl-padding;
+ bottom: $gl-padding;
+ left: map-get($spacers, 2) - 1px;
+ }
+
+ &-row {
+ margin-top: map-get($spacers, 3);
+
+ &:nth-child(1) {
+ margin-top: 0;
+ }
+ }
+
+ &-icon {
+ /**
+ * 2px extra is to give a little more height than needed
+ * to hide timeline line before/after the element starts/ends
+ */
+ height: map-get($spacers, 4) + 2px;
+ z-index: 1;
+ position: relative;
+ top: -3px;
+ padding: $gl-padding-4 0;
+ background-color: $gray-light;
+
+ &.opened {
+ color: $green-500;
+ }
+
+ &.closed {
+ color: $blue-500;
+ }
+ }
+
+ &-content {
+ line-height: initial;
+ margin-left: $gl-padding-8;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/snippets.scss b/app/assets/stylesheets/framework/snippets.scss
index 36ab38f1c9d..3ab83f4c8e6 100644
--- a/app/assets/stylesheets/framework/snippets.scss
+++ b/app/assets/stylesheets/framework/snippets.scss
@@ -22,6 +22,10 @@
.snippet-file-content {
border-radius: 3px;
+
+ .file-title-flex-parent .btn-clipboard {
+ line-height: 28px;
+ }
}
.snippet-header {
diff --git a/app/assets/stylesheets/framework/sortable.scss b/app/assets/stylesheets/framework/sortable.scss
new file mode 100644
index 00000000000..8c070200135
--- /dev/null
+++ b/app/assets/stylesheets/framework/sortable.scss
@@ -0,0 +1,92 @@
+.sortable-container {
+ background-color: $gray-light;
+
+ .flex-list {
+ padding: 5px;
+ margin-bottom: 0;
+ }
+}
+
+.sortable-row {
+ .flex-row {
+ display: flex;
+
+ &.issuable-info-container {
+ padding-right: 0;
+ }
+ }
+
+ .sortable-link {
+ color: $black;
+ }
+}
+
+.gl-sortable {
+ .header {
+ user-select: none;
+
+ &:hover {
+ cursor: pointer;
+ background-color: $gray-100;
+ }
+
+ &:focus {
+ outline: 1px solid $blue-300;
+ }
+ }
+}
+
+.related-issues-list-item {
+ .card-body,
+ .issuable-info-container {
+ padding: $gl-padding-4 $gl-padding-4 $gl-padding-4 $gl-padding;
+
+ .block-truncated {
+ padding: $gl-padding-8 0;
+ line-height: $gl-btn-line-height;
+ }
+
+ @include media-breakpoint-down(md) {
+ padding-left: $gl-padding;
+
+ .block-truncated {
+ flex-direction: column-reverse;
+ padding: $gl-padding-4 0;
+
+ .text-secondary {
+ margin-top: $gl-padding-4;
+ }
+
+ .issue-token-title-text {
+ display: block;
+ }
+ }
+
+ .issue-item-remove-button {
+ align-self: baseline;
+ }
+ }
+
+ @include media-breakpoint-only(md) {
+ .block-truncated .issue-token-title-text {
+ white-space: nowrap;
+ }
+
+ .issue-item-remove-button {
+ align-self: center;
+ }
+ }
+
+ @include media-breakpoint-down(sm) {
+ padding-left: $gl-padding-8;
+
+ .block-truncated .issue-token-title-text {
+ white-space: normal;
+ }
+ }
+ }
+
+ &.is-dragging {
+ padding: 0;
+ }
+}
diff --git a/app/assets/stylesheets/framework/spinner.scss b/app/assets/stylesheets/framework/spinner.scss
new file mode 100644
index 00000000000..91fe75075dc
--- /dev/null
+++ b/app/assets/stylesheets/framework/spinner.scss
@@ -0,0 +1,51 @@
+@mixin spinner-color($color) {
+ border-color: rgba($color, 0.25);
+ border-top-color: $color;
+}
+
+@mixin spinner-size($size, $border-width) {
+ width: $size;
+ height: $size;
+ border-width: $border-width;
+ @include webkit-prefix(transform-origin, 50% 50% calc((#{$size} / 2) + #{$border-width}));
+}
+
+@keyframes spinner-rotate {
+ 0% {
+ transform: rotate(0);
+ }
+
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+.spinner {
+ border-radius: 50%;
+ position: relative;
+ margin: 0 auto;
+ animation-name: spinner-rotate;
+ animation-duration: 0.6s;
+ animation-timing-function: linear;
+ animation-iteration-count: infinite;
+ border-style: solid;
+ display: inline-flex;
+ @include spinner-size(16px, 2px);
+ @include spinner-color($orange-600);
+
+ &.spinner-md {
+ @include spinner-size(32px, 3px);
+ }
+
+ &.spinner-lg {
+ @include spinner-size(64px, 4px);
+ }
+
+ &.spinner-dark {
+ @include spinner-color($gray-700);
+ }
+
+ &.spinner-light {
+ @include spinner-color($white);
+ }
+}
diff --git a/app/assets/stylesheets/framework/system_messages.scss b/app/assets/stylesheets/framework/system_messages.scss
index 3d66136938f..6205ccaa52f 100644
--- a/app/assets/stylesheets/framework/system_messages.scss
+++ b/app/assets/stylesheets/framework/system_messages.scss
@@ -12,8 +12,9 @@
p {
@include str-truncated(100%);
- margin-top: 0;
+ margin-top: -1px;
margin-bottom: 0;
+ font-size: $gl-font-size-small;
}
}
diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss
index 295a5b5ee7a..ba406bac50b 100644
--- a/app/assets/stylesheets/framework/tables.scss
+++ b/app/assets/stylesheets/framework/tables.scss
@@ -161,4 +161,3 @@ table {
border-top: 0;
}
}
-
diff --git a/app/assets/stylesheets/framework/terms.scss b/app/assets/stylesheets/framework/terms.scss
index 3f4be8829d7..b07d6023127 100644
--- a/app/assets/stylesheets/framework/terms.scss
+++ b/app/assets/stylesheets/framework/terms.scss
@@ -13,7 +13,6 @@
.card {
.card-header {
- display: -webkit-flex;
display: flex;
align-items: center;
justify-content: space-between;
diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss
index 3d5208c3db5..42a739e88f7 100644
--- a/app/assets/stylesheets/framework/timeline.scss
+++ b/app/assets/stylesheets/framework/timeline.scss
@@ -42,8 +42,8 @@
}
}
- .avatar {
- margin-right: 15px;
+ img.avatar {
+ margin-right: $gl-padding;
}
.controls {
@@ -55,4 +55,5 @@
.discussion .timeline-entry {
margin: 0;
border-right: 0;
+ border-radius: $border-radius-default $border-radius-default 0 0;
}
diff --git a/app/assets/stylesheets/framework/toggle.scss b/app/assets/stylesheets/framework/toggle.scss
index 8258da07e4d..5f8ac3b7e37 100644
--- a/app/assets/stylesheets/framework/toggle.scss
+++ b/app/assets/stylesheets/framework/toggle.scss
@@ -34,7 +34,7 @@
background: $gl-gray-400;
border-radius: 12px;
padding: 3px;
- transition: all .4s ease;
+ transition: all 0.4s ease;
&::selection,
&::before::selection,
@@ -52,7 +52,7 @@
left: 0;
border-radius: 9px;
background: $feature-toggle-color;
- transition: all .2s ease;
+ transition: all 0.2s ease;
&,
.toggle-icon-svg {
@@ -135,12 +135,18 @@
}
@keyframes animate-enabled {
- 0%, 35% { opacity: 0; }
+ 0%,
+
+ 35% { opacity: 0; }
+
100% { opacity: 1; }
}
@keyframes animate-disabled {
- 0%, 35% { opacity: 0; }
+ 0%,
+
+ 35% { opacity: 0; }
+
100% { opacity: 1; }
}
}
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 1b36c1f4862..7c152efd9c7 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -1,13 +1,44 @@
-@mixin md-typography {
+/**
+ * Apply Markdown typography
+ *
+ */
+.md:not(.use-csslab) {
color: $gl-text-color;
word-wrap: break-word;
- [dir="auto"] {
+ [dir='auto'] {
text-align: initial;
}
+ *:first-child {
+ margin-top: 0;
+ }
+
+ > :last-child {
+ margin-bottom: 0;
+ }
+
+ p {
+ color: $gl-text-color;
+ margin: 0 0 16px;
+
+ > code {
+ font-weight: inherit;
+ }
+
+ a:not(.no-attachment-icon) img {
+ // Remove bottom padding because
+ // <p> already has $gl-padding bottom
+ margin-bottom: 0;
+ }
+ }
+
a {
color: $blue-600;
+
+ > code {
+ color: $blue-600;
+ }
}
img:not(.emoji) {
@@ -28,18 +59,12 @@
max-width: 100%;
}
- p a:not(.no-attachment-icon) img {
- // Remove bottom padding because
- // <p> already has $gl-padding bottom
- margin-bottom: 0;
- }
-
- *:first-child {
- margin-top: 0;
- }
-
- > :last-child {
- margin-bottom: 0;
+ &:not(.md-file) img:not(.emoji) {
+ border: 1px solid $white-normal;
+ padding: 5px;
+ margin: 5px 0;
+ // Ensure that image does not exceed viewport
+ max-height: calc(100vh - 100px);
}
// Single code lines should wrap
@@ -47,6 +72,7 @@
font-family: $monospace-font;
white-space: pre-wrap;
word-wrap: normal;
+ word-break: keep-all;
}
kbd {
@@ -131,20 +157,34 @@
}
}
- p {
- color: $gl-text-color;
- margin: 0 0 16px;
+ hr {
+ // Darken 'whitesmoke' a bit to make it more visible in note bodies
+ border-color: darken($gray-normal, 8%);
+ margin: 10px 0;
}
- table:not(.js-syntax-highlight) {
+ table:not(.code) {
@extend .table;
@extend .table-bordered;
margin: 16px 0;
color: $gl-text-color;
border: 0;
+ width: auto;
+ display: block;
+ overflow-x: auto;
- th {
- background: $label-gray-bg;
+ tbody {
+ background-color: $white-light;
+ }
+
+ tr {
+ th {
+ border-bottom: solid 2px $gl-gray-200;
+ }
+
+ td {
+ border-color: $gl-gray-200;
+ }
}
}
@@ -173,14 +213,6 @@
}
}
- p > code {
- font-weight: inherit;
- }
-
- a > code {
- color: $blue-600;
- }
-
dd {
margin-left: $gl-padding;
}
@@ -196,6 +228,18 @@
margin: 3px 28px 3px 0 !important;
}
+ > ul {
+ list-style-type: disc;
+
+ ul {
+ list-style-type: circle;
+
+ ul {
+ list-style-type: square;
+ }
+ }
+ }
+
li {
line-height: 1.6em;
margin-left: 25px;
@@ -224,8 +268,8 @@
}
}
- a[href*="/uploads/"],
- a[href*="storage.googleapis.com/google-code-attachments/"] {
+ a[href*='/uploads/'],
+ a[href*='storage.googleapis.com/google-code-attachments/'] {
&::before {
margin-right: 4px;
@@ -233,17 +277,17 @@
font-size: inherit;
text-rendering: auto;
-webkit-font-smoothing: antialiased;
- content: "\f0c6";
+ content: '\f0c6';
}
&:hover::before {
text-decoration: none;
}
- }
- a.no-attachment-icon {
- &::before {
- display: none;
+ &.no-attachment-icon {
+ &::before {
+ display: none;
+ }
}
}
@@ -362,28 +406,6 @@ code {
}
/**
- * Apply Markdown typography
- *
- */
-.wiki:not(.use-csslab) {
- @include md-typography;
-}
-
-.md:not(.use-csslab) {
- @include md-typography;
-
- &:not(.wiki) {
- img:not(.emoji) {
- border: 1px solid $white-normal;
- padding: 5px;
- margin: 5px 0;
- // Ensure that image does not exceed viewport
- max-height: calc(100vh - 100px);
- }
- }
-}
-
-/**
* Textareas intended for GFM
*
*/
@@ -423,6 +445,7 @@ h4 {
/**
* form text input i.e. search bar, comments, forms, etc.
*/
+/* stylelint-disable selector-no-vendor-prefix */
input,
textarea {
&::-webkit-input-placeholder {
@@ -447,5 +470,10 @@ textarea {
color: $gl-text-color-tertiary;
}
}
+/* stylelint-enable */
.lh-100 { line-height: 1; }
+
+wbr {
+ display: inline-block;
+}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 25b272ab3a9..dc451a97e17 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -11,6 +11,14 @@ $default-transition-duration: 0.15s;
$contextual-sidebar-width: 220px;
$contextual-sidebar-collapsed-width: 50px;
$toggle-sidebar-height: 48px;
+$spacing-scale: (
+ 0: 0,
+ 1: #{0.5 * $grid-size},
+ 2: $grid-size,
+ 3: #{2 * $grid-size},
+ 4: #{3 * $grid-size},
+ 5: #{4 * $grid-size}
+);
/*
* Color schema
@@ -23,6 +31,7 @@ $darken-border-dashed-factor: 25%;
$white-light: #fff;
$white-normal: #f0f0f0;
$white-dark: #eaeaea;
+$white-transparent: rgba(255, 255, 255, 0.8);
$gray-lightest: #fdfdfd;
$gray-light: #fafafa;
@@ -41,13 +50,13 @@ $t-gray-a-04: rgba($black, 0.04);
$t-gray-a-06: rgba($black, 0.06);
$t-gray-a-08: rgba($black, 0.08);
-$gl-gray-100: #dddddd;
-$gl-gray-200: #cccccc;
-$gl-gray-350: #aaaaaa;
-$gl-gray-400: #999999;
-$gl-gray-500: #777777;
-$gl-gray-600: #666666;
-$gl-gray-700: #555555;
+$gl-gray-100: #ddd;
+$gl-gray-200: #ccc;
+$gl-gray-350: #aaa;
+$gl-gray-400: #999;
+$gl-gray-500: #777;
+$gl-gray-600: #666;
+$gl-gray-700: #555;
$green-50: #f1fdf6;
$green-100: #dcf5e7;
@@ -100,7 +109,7 @@ $red-950: #4b140b;
$gray-50: #fafafa;
$gray-100: #f2f2f2;
$gray-200: #dfdfdf;
-$gray-300: #cccccc;
+$gray-300: #ccc;
$gray-400: #bababa;
$gray-500: #a7a7a7;
$gray-600: #919191;
@@ -109,6 +118,84 @@ $gray-800: #4f4f4f;
$gray-900: #2e2e2e;
$gray-950: #1f1f1f;
+$greens: (
+ '50': $green-50,
+ '100': $green-100,
+ '200': $green-200,
+ '300': $green-300,
+ '400': $green-400,
+ '500': $green-500,
+ '600': $green-600,
+ '700': $green-700,
+ '800': $green-800,
+ '900': $green-900,
+ '950': $green-950
+);
+
+$blues: (
+ '50': $blue-50,
+ '100': $blue-100,
+ '200': $blue-200,
+ '300': $blue-300,
+ '400': $blue-400,
+ '500': $blue-500,
+ '600': $blue-600,
+ '700': $blue-700,
+ '800': $blue-800,
+ '900': $blue-900,
+ '950': $blue-950
+);
+
+$oranges: (
+ '50': $orange-50,
+ '100': $orange-100,
+ '200': $orange-200,
+ '300': $orange-300,
+ '400': $orange-400,
+ '500': $orange-500,
+ '600': $orange-600,
+ '700': $orange-700,
+ '800': $orange-800,
+ '900': $orange-900,
+ '950': $orange-950
+);
+
+$reds: (
+ '50': $red-50,
+ '100': $red-100,
+ '200': $red-200,
+ '300': $red-300,
+ '400': $red-400,
+ '500': $red-500,
+ '600': $red-600,
+ '700': $red-700,
+ '800': $red-800,
+ '900': $red-900,
+ '950': $red-950
+);
+
+$grays: (
+ '50': $gray-50,
+ '100': $gray-100,
+ '200': $gray-200,
+ '300': $gray-300,
+ '400': $gray-400,
+ '500': $gray-500,
+ '600': $gray-600,
+ '700': $gray-700,
+ '800': $gray-800,
+ '900': $gray-900,
+ '950': $gray-950
+);
+
+$color-ranges: (
+ 'primary': $blues,
+ 'secondary': $grays,
+ 'success': $greens,
+ 'warning': $oranges,
+ 'danger': $reds
+);
+
// GitLab themes
$indigo-50: #f7f7ff;
@@ -218,6 +305,15 @@ $gl-gray-dark: #313236;
$gl-gray-light: #5c5c5c;
$gl-header-color: #4c4e54;
+$type-scale: (
+ 1: 12px,
+ 2: 14px,
+ 3: 16px,
+ 4: 20px,
+ 5: 28px,
+ 6: 42px
+);
+
/*
* Lists
*/
@@ -277,7 +373,7 @@ $general-hover-transition-duration: 100ms;
$general-hover-transition-curve: linear;
$highlight-changes-color: rgb(235, 255, 232);
$performance-bar-height: 35px;
-$system-header-height: 35px;
+$system-header-height: 16px;
$system-footer-height: $system-header-height;
$flash-height: 52px;
$context-header-height: 60px;
@@ -285,9 +381,13 @@ $breadcrumb-min-height: 48px;
$home-panel-title-row-height: 64px;
$home-panel-avatar-mobile-size: 24px;
$gl-line-height: 16px;
+$gl-line-height-20: 20px;
$gl-line-height-24: 24px;
$gl-line-height-14: 14px;
+$issue-box-upcoming-bg: #8f8f8f;
+$pages-group-name-color: #4c4e54;
+
/*
* Common component specific colors
*/
@@ -323,8 +423,8 @@ $line-select-yellow: #fcf8e7;
$line-select-yellow-dark: #f0e2bd;
$dark-diff-match-bg: rgba(255, 255, 255, 0.3);
$dark-diff-match-color: rgba(255, 255, 255, 0.1);
-$diff-image-info-color: gray;
-$diff-view-modes-color: gray;
+$diff-image-info-color: #808080;
+$diff-view-modes-color: #808080;
$diff-view-modes-border: #c1c1c1;
$diff-jagged-border-gradient-color: darken($white-normal, 8%);
@@ -333,7 +433,7 @@ $diff-jagged-border-gradient-color: darken($white-normal, 8%);
*/
$monospace-font: 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', 'Ubuntu Mono',
'Courier New', 'andale mono', 'lucida console', monospace;
-$regular-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell,
+$regular-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans', Ubuntu, Cantarell,
'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
@@ -343,6 +443,7 @@ $regular-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-San
$dropdown-width: 300px;
$dropdown-min-height: 40px;
$dropdown-max-height: 312px;
+$dropdown-max-height-lg: 445px;
$dropdown-vertical-offset: 4px;
$dropdown-empty-row-bg: rgba(#000, 0.04);
$dropdown-shadow-color: rgba(#000, 0.1);
@@ -398,6 +499,17 @@ $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-action-margin-left: 16px;
+$toast-background-opacity: 0.95;
+
+/*
* Status icons
*/
$status-icon-size: 22px;
@@ -409,7 +521,7 @@ $award-emoji-menu-shadow: rgba(0, 0, 0, 0.175);
$award-emoji-positive-add-bg: #fed159;
$award-emoji-positive-add-lines: #bb9c13;
$award-emoji-width: 376px;
-$award-emoji-width-xs: 300px;
+$award-emoji-width-xs: 90%;
/*
* Search Box
@@ -478,6 +590,7 @@ $issue-board-list-difference-md: $issue-board-list-difference-sm + $issue-boards
*/
$avatar-radius: 50%;
$gl-avatar-size: 40px;
+$gl-avatar-border-opacity: 0.1;
/*
* Blame
@@ -528,6 +641,7 @@ $input-lg-width: 320px;
*/
$document-index-color: #888;
$help-shortcut-header-color: #333;
+$accepting-mr-label-color: #69d100;
/*
* Issues
@@ -570,6 +684,11 @@ $feature-toggle-text-color: #fff;
$feature-toggle-color-enabled: #4a8bee;
/*
+ * Monitor Charts
+ */
+$chart-tooltip-max-width: 512px;
+
+/*
Stat Graph
*/
$stat-graph-common-bg: #f3f3f3;
@@ -625,6 +744,18 @@ Animation Functions
$dropdown-animation-timing: cubic-bezier(0.23, 1, 0.32, 1);
/*
+GitLab Plans
+*/
+$gl-gold-plan: #d4af37;
+$gl-silver-plan: #91a1ab;
+$gl-bronze-plan: #cd7f32;
+
+/*
+Cross-project Pipelines
+ */
+$linked-project-column-margin: 60px;
+
+/*
Performance Bar
*/
$perf-bar-production: #222;
@@ -648,6 +779,17 @@ $image-comment-cursor-left-offset: 12;
$image-comment-cursor-top-offset: 12;
/*
+Add GitLab Slack Application
+*/
+$add-to-slack-popup-max-width: 400px;
+$add-to-slack-gif-max-width: 850px;
+$add-to-slack-well-max-width: 750px;
+$add-to-slack-logo-size: 100px;
+$double-headed-arrow-width: 100px;
+$double-headed-arrow-height: 25px;
+$right-arrow-size: 16px;
+
+/*
Popup
*/
$popup-triangle-size: 15px;
@@ -682,3 +824,16 @@ $mr-version-controls-height: 56px;
Compare Branches
*/
$compare-branches-sticky-header-height: 68px;
+
+/**
+ Bootstrap 4.2.0 introduced new icons for validating forms.
+ Our design system does not use those, so we are disabling them for now:
+ - Docs: https://getbootstrap.com/docs/4.3/components/forms/#server-side
+ - Issue: https://gitlab.com/gitlab-org/design.gitlab.com/issues/242
+ */
+$enable-validation-icons: false;
+
+/*
+Licenses
+*/
+$license-header-cell-width: 150px;
diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss
index 1dfe2a69a2f..ea96381a098 100644
--- a/app/assets/stylesheets/framework/variables_overrides.scss
+++ b/app/assets/stylesheets/framework/variables_overrides.scss
@@ -7,9 +7,9 @@ $secondary: $gray-light;
$input-disabled-bg: $gray-light;
$input-border-color: $gray-200;
$input-color: $gl-text-color;
+$input-font-size: $gl-font-size;
$font-family-sans-serif: $regular-font;
$font-family-monospace: $monospace-font;
-$input-line-height: 20px;
$btn-line-height: 20px;
$table-accent-bg: $gray-light;
$card-border-color: $border-color;
@@ -37,9 +37,14 @@ $h6-font-size: 14px;
$spacer: $grid-size;
$spacers: (
0: 0,
- 1: ($spacer * .5),
+ 1: ($spacer * 0.5),
2: ($spacer),
3: ($spacer * 2),
4: ($spacer * 3),
- 5: ($spacer * 4)
+ 5: ($spacer * 4),
+ 6: ($spacer * 5),
+ 7: ($spacer * 6),
+ 8: ($spacer * 7),
+ 9: ($spacer * 8)
);
+$pagination-color: $gl-text-color;
diff --git a/app/assets/stylesheets/framework/vue_transitions.scss b/app/assets/stylesheets/framework/vue_transitions.scss
index e07a177e153..e3bdc0b0199 100644
--- a/app/assets/stylesheets/framework/vue_transitions.scss
+++ b/app/assets/stylesheets/framework/vue_transitions.scss
@@ -1,9 +1,13 @@
.fade-enter-active,
-.fade-leave-active {
+.fade-leave-active,
+.fade-in-enter-active,
+.fade-out-leave-active {
transition: opacity $sidebar-transition-duration $general-hover-transition-curve;
}
.fade-enter,
+.fade-in-enter,
+.fade-out-leave-to,
.fade-leave-to {
opacity: 0;
}
diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss
index 161943766d4..434cbd6d21c 100644
--- a/app/assets/stylesheets/framework/wells.scss
+++ b/app/assets/stylesheets/framework/wells.scss
@@ -12,6 +12,10 @@
border-bottom: 1px solid $well-inner-border;
}
+ &.borderless {
+ border-bottom: 0;
+ }
+
&.branch-info {
.commit-sha,
.commit-info {
diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss
index 2b0794759d5..ac3214a07d9 100644
--- a/app/assets/stylesheets/highlight/common.scss
+++ b/app/assets/stylesheets/highlight/common.scss
@@ -1,4 +1,4 @@
-@import "../framework/variables";
+@import '../framework/variables';
@mixin diff-background($background, $idiff, $border) {
background: $background;
diff --git a/app/assets/stylesheets/highlight/embedded.scss b/app/assets/stylesheets/highlight/embedded.scss
index 44c8a1d39ec..74364ee4ddb 100644
--- a/app/assets/stylesheets/highlight/embedded.scss
+++ b/app/assets/stylesheets/highlight/embedded.scss
@@ -1,3 +1,3 @@
.code {
- @import "white_base";
+ @import 'white_base';
}
diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss
index 23ec3380ce9..ee0ec94c636 100644
--- a/app/assets/stylesheets/highlight/white_base.scss
+++ b/app/assets/stylesheets/highlight/white_base.scss
@@ -1,6 +1,6 @@
/* https://github.com/aahan/pygments-github-style */
-@import "./common";
+@import './common';
/*
* White Syntax Colors
@@ -37,16 +37,16 @@ $white-kt: #458;
$white-m: #099;
$white-s: #d14;
$white-n: #333;
-$white-na: teal;
+$white-na: #008080;
$white-nb: #0086b3;
$white-nc: #458;
-$white-no: teal;
-$white-ni: purple;
+$white-no: #008080;
+$white-ni: #800080;
$white-ne: #900;
$white-nf: #900;
$white-nn: #555;
-$white-nt: navy;
-$white-nv: teal;
+$white-nt: #000080;
+$white-nv: #008080;
$white-w: #bbb;
$white-mf: #099;
$white-mh: #099;
@@ -64,9 +64,9 @@ $white-sr: #009926;
$white-s1: #d14;
$white-ss: #990073;
$white-bp: #999;
-$white-vc: teal;
-$white-vg: teal;
-$white-vi: teal;
+$white-vc: #008080;
+$white-vg: #008080;
+$white-vi: #008080;
$white-il: #099;
$white-gc-color: #999;
$white-gc-bg: #eaf2f5;
@@ -77,7 +77,7 @@ $white-gc-bg: #eaf2f5;
background-color: $gray-light;
}
- // Line numbers
+// Line numbers
.line-numbers,
.diff-line-num {
background-color: $gray-light;
@@ -103,7 +103,6 @@ pre.code,
// Diff line
.line_holder {
-
&.match .line_content,
.new-nonewline.line_content,
.old-nonewline.line_content {
@@ -201,25 +200,38 @@ pre .hll {
background-color: $white-pre-hll-bg !important;
}
- // Search result highlight
+// Search result highlight
span.highlight_word {
background-color: $white-highlight !important;
}
- // Links to URLs, emails, or dependencies
+// Links to URLs, emails, or dependencies
.line a {
color: $white-nb;
}
.hll { background-color: $white-hll-bg; }
-.c { color: $white-c; font-style: italic; }
-.err { color: $white-err; background-color: $white-err-bg; }
+
+.c { color: $white-c;
+ font-style: italic; }
+
+.err { color: $white-err;
+ background-color: $white-err-bg; }
.k { font-weight: $gl-font-weight-bold; }
.o { font-weight: $gl-font-weight-bold; }
-.cm { color: $white-cm; font-style: italic; }
-.cp { color: $white-cp; font-weight: $gl-font-weight-bold; }
-.c1 { color: $white-c1; font-style: italic; }
-.cs { color: $white-cs; font-weight: $gl-font-weight-bold; font-style: italic; }
+
+.cm { color: $white-cm;
+ font-style: italic; }
+
+.cp { color: $white-cp;
+ font-weight: $gl-font-weight-bold; }
+
+.c1 { color: $white-c1;
+ font-style: italic; }
+
+.cs { color: $white-cs;
+ font-weight: $gl-font-weight-bold;
+ font-style: italic; }
.gd {
color: $white-gd;
@@ -248,24 +260,34 @@ span.highlight_word {
.go { color: $white-go; }
.gp { color: $white-gp; }
.gs { font-weight: $gl-font-weight-bold; }
-.gu { color: $white-gu; font-weight: $gl-font-weight-bold; }
+
+.gu { color: $white-gu;
+ font-weight: $gl-font-weight-bold; }
.gt { color: $white-gt; }
.kc { font-weight: $gl-font-weight-bold; }
.kd { font-weight: $gl-font-weight-bold; }
.kn { font-weight: $gl-font-weight-bold; }
.kp { font-weight: $gl-font-weight-bold; }
.kr { font-weight: $gl-font-weight-bold; }
-.kt { color: $white-kt; font-weight: $gl-font-weight-bold; }
+
+.kt { color: $white-kt;
+ font-weight: $gl-font-weight-bold; }
.m { color: $white-m; }
.s { color: $white-s; }
.n { color: $white-n; }
.na { color: $white-na; }
.nb { color: $white-nb; }
-.nc { color: $white-nc; font-weight: $gl-font-weight-bold; }
+
+.nc { color: $white-nc;
+ font-weight: $gl-font-weight-bold; }
.no { color: $white-no; }
.ni { color: $white-ni; }
-.ne { color: $white-ne; font-weight: $gl-font-weight-bold; }
-.nf { color: $white-nf; font-weight: $gl-font-weight-bold; }
+
+.ne { color: $white-ne;
+ font-weight: $gl-font-weight-bold; }
+
+.nf { color: $white-nf;
+ font-weight: $gl-font-weight-bold; }
.nn { color: $white-nn; }
.nt { color: $white-nt; }
.nv { color: $white-nv; }
@@ -291,4 +313,6 @@ span.highlight_word {
.vg { color: $white-vg; }
.vi { color: $white-vi; }
.il { color: $white-il; }
-.gc { color: $white-gc-color; background-color: $white-gc-bg; }
+
+.gc { color: $white-gc-color;
+ background-color: $white-gc-bg; }
diff --git a/app/assets/stylesheets/mailers/highlighted_diff_email.scss b/app/assets/stylesheets/mailers/highlighted_diff_email.scss
index 8b234a5a656..33c114838c2 100644
--- a/app/assets/stylesheets/mailers/highlighted_diff_email.scss
+++ b/app/assets/stylesheets/mailers/highlighted_diff_email.scss
@@ -1,4 +1,4 @@
-@import "framework/variables";
+@import 'framework/variables';
// This file is largely copied from `highlight/white.scss`, but modified to
// avoid all descendant selectors (`table td`). This is because the CSS inlining
@@ -40,16 +40,16 @@ $highlighted-kt: #458;
$highlighted-m: #099;
$highlighted-s: #d14;
$highlighted-n: #333;
-$highlighted-na: teal;
+$highlighted-na: #008080;
$highlighted-nb: #0086b3;
$highlighted-nc: #458;
-$highlighted-no: teal;
-$highlighted-ni: purple;
+$highlighted-no: #008080;
+$highlighted-ni: #800080;
$highlighted-ne: #900;
$highlighted-nf: #900;
$highlighted-nn: #555;
-$highlighted-nt: navy;
-$highlighted-nv: teal;
+$highlighted-nt: #000080;
+$highlighted-nv: #008080;
$highlighted-w: #bbb;
$highlighted-mf: #099;
$highlighted-mh: #099;
@@ -67,9 +67,9 @@ $highlighted-sr: #009926;
$highlighted-s1: #d14;
$highlighted-ss: #990073;
$highlighted-bp: #999;
-$highlighted-vc: teal;
-$highlighted-vg: teal;
-$highlighted-vi: teal;
+$highlighted-vc: #008080;
+$highlighted-vg: #008080;
+$highlighted-vi: #008080;
$highlighted-il: #099;
$highlighted-gc: #999;
$highlighted-gc-bg: #eaf2f5;
@@ -151,14 +151,27 @@ span.highlight_word {
}
.hll { background-color: $highlighted-hll-bg; }
-.c { color: $highlighted-c; font-style: italic; }
-.err { color: $highlighted-err; background-color: $highlighted-err-bg; }
+
+.c { color: $highlighted-c;
+ font-style: italic; }
+
+.err { color: $highlighted-err;
+ background-color: $highlighted-err-bg; }
.k { font-weight: $gl-font-weight-bold; }
.o { font-weight: $gl-font-weight-bold; }
-.cm { color: $highlighted-cm; font-style: italic; }
-.cp { color: $highlighted-cp; font-weight: $gl-font-weight-bold; }
-.c1 { color: $highlighted-c1; font-style: italic; }
-.cs { color: $highlighted-cs; font-weight: $gl-font-weight-bold; font-style: italic; }
+
+.cm { color: $highlighted-cm;
+ font-style: italic; }
+
+.cp { color: $highlighted-cp;
+ font-weight: $gl-font-weight-bold; }
+
+.c1 { color: $highlighted-c1;
+ font-style: italic; }
+
+.cs { color: $highlighted-cs;
+ font-weight: $gl-font-weight-bold;
+ font-style: italic; }
.gd {
color: $highlighted-gd;
@@ -187,24 +200,34 @@ span.highlight_word {
.go { color: $highlighted-go; }
.gp { color: $highlighted-gp; }
.gs { font-weight: $gl-font-weight-bold; }
-.gu { color: $highlighted-gu; font-weight: $gl-font-weight-bold; }
+
+.gu { color: $highlighted-gu;
+ font-weight: $gl-font-weight-bold; }
.gt { color: $highlighted-gt; }
.kc { font-weight: $gl-font-weight-bold; }
.kd { font-weight: $gl-font-weight-bold; }
.kn { font-weight: $gl-font-weight-bold; }
.kp { font-weight: $gl-font-weight-bold; }
.kr { font-weight: $gl-font-weight-bold; }
-.kt { color: $highlighted-kt; font-weight: $gl-font-weight-bold; }
+
+.kt { color: $highlighted-kt;
+ font-weight: $gl-font-weight-bold; }
.m { color: $highlighted-m; }
.s { color: $highlighted-s; }
.n { color: $highlighted-n; }
.na { color: $highlighted-na; }
.nb { color: $highlighted-nb; }
-.nc { color: $highlighted-nc; font-weight: $gl-font-weight-bold; }
+
+.nc { color: $highlighted-nc;
+ font-weight: $gl-font-weight-bold; }
.no { color: $highlighted-no; }
.ni { color: $highlighted-ni; }
-.ne { color: $highlighted-ne; font-weight: $gl-font-weight-bold; }
-.nf { color: $highlighted-nf; font-weight: $gl-font-weight-bold; }
+
+.ne { color: $highlighted-ne;
+ font-weight: $gl-font-weight-bold; }
+
+.nf { color: $highlighted-nf;
+ font-weight: $gl-font-weight-bold; }
.nn { color: $highlighted-nn; }
.nt { color: $highlighted-nt; }
.nv { color: $highlighted-nv; }
@@ -230,4 +253,6 @@ span.highlight_word {
.vg { color: $highlighted-vg; }
.vi { color: $highlighted-vi; }
.il { color: $highlighted-il; }
-.gc { color: $highlighted-gc; background-color: $highlighted-gc-bg; }
+
+.gc { color: $highlighted-gc;
+ background-color: $highlighted-gc-bg; }
diff --git a/app/assets/stylesheets/notify.scss b/app/assets/stylesheets/notify.scss
index f24c80bd81c..d77b7dfad68 100644
--- a/app/assets/stylesheets/notify.scss
+++ b/app/assets/stylesheets/notify.scss
@@ -1,4 +1,4 @@
-@import "framework/variables";
+@import 'framework/variables';
img {
max-width: 100%;
diff --git a/app/assets/stylesheets/page_bundles/_ide_mixins.scss b/app/assets/stylesheets/page_bundles/_ide_mixins.scss
index 896a3466cb4..9465dd5bed6 100644
--- a/app/assets/stylesheets/page_bundles/_ide_mixins.scss
+++ b/app/assets/stylesheets/page_bundles/_ide_mixins.scss
@@ -2,17 +2,17 @@
display: flex;
flex-direction: column;
height: 100%;
- margin-top: -$grid-size;
- margin-bottom: -$grid-size;
- &.build-page .top-bar {
+ .top-bar {
+ @include build-trace-bar(35px);
+
top: 0;
- height: auto;
font-size: 12px;
border-top-right-radius: $border-radius-default;
- }
-
- .top-bar {
margin-left: -$gl-padding;
+
+ .controllers {
+ @include build-controllers(15px, center, false, 0, inline, 0);
+ }
}
}
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index a80158943c6..f08fa80495d 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -179,6 +179,14 @@ $ide-commit-header-height: 48px;
display: none;
}
+ .monaco-editor .selected-text {
+ z-index: 1;
+ }
+
+ .monaco-editor .view-lines {
+ z-index: 2;
+ }
+
.is-readonly,
.editor.original {
.view-lines {
@@ -711,7 +719,7 @@ $ide-commit-header-height: 48px;
border: 1px solid $white-dark;
}
-.ide-commit-radios {
+.ide-commit-options {
label {
font-weight: normal;
diff --git a/app/assets/stylesheets/page_bundles/xterm.scss b/app/assets/stylesheets/page_bundles/xterm.scss
index 7f040ac9b96..de3f2a1177d 100644
--- a/app/assets/stylesheets/page_bundles/xterm.scss
+++ b/app/assets/stylesheets/page_bundles/xterm.scss
@@ -6,11 +6,11 @@
$black: #000;
$red: #ea1010;
- $green: #009900;
- $yellow: #999900;
+ $green: #090;
+ $yellow: #990;
$blue: #0073e6;
$magenta: #d411d4;
- $cyan: #009999;
+ $cyan: #099;
$white: #ccc;
$l-black: #373b41;
$l-red: #ff6161;
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index a9324ba2ed0..5e3652db48f 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -1,9 +1,4 @@
-[v-cloak] {
- display: none;
-}
-
.user-can-drag {
- cursor: -webkit-grab;
cursor: grab;
}
@@ -17,13 +12,13 @@
-ms-user-select: none;
user-select: none;
// !important to make sure no style can override this when dragging
- cursor: -webkit-grabbing !important;
cursor: grabbing !important;
}
}
.is-ghost {
opacity: 0.3;
+ pointer-events: none;
}
.dropdown-projects {
@@ -36,19 +31,15 @@
width: 320px;
.dropdown-content {
- max-height: 162px;
+ max-height: 140px;
}
}
.issue-board-dropdown-content {
- margin: 0 8px 10px;
- padding-bottom: 10px;
- border-bottom: 1px solid $dropdown-divider-bg;
-
- > p {
- margin: 0;
- font-size: 14px;
- }
+ margin: 0;
+ padding: $gl-padding-4 $gl-padding $gl-padding;
+ border-bottom: 0;
+ color: $gl-text-color-secondary;
}
.issue-boards-page {
@@ -58,8 +49,6 @@
}
.boards-app {
- position: relative;
-
@include media-breakpoint-up(sm) {
transition: width $sidebar-transition-duration;
width: 100%;
@@ -70,17 +59,9 @@
}
}
-.boards-app-loading {
- width: 100%;
- font-size: 34px;
-}
-
.boards-list {
height: calc(100vh - #{$issue-board-list-difference-xs});
- width: 100%;
- padding: $gl-padding ($gl-padding / 2);
overflow-x: scroll;
- white-space: nowrap;
min-height: 200px;
@include media-breakpoint-only(sm) {
@@ -105,13 +86,7 @@
}
.board {
- display: inline-block;
width: calc(85vw - 15px);
- height: 100%;
- padding-right: ($gl-padding / 2);
- padding-left: ($gl-padding / 2);
- white-space: normal;
- vertical-align: top;
@include media-breakpoint-up(sm) {
width: 400px;
@@ -126,23 +101,7 @@
&.is-collapsed {
width: 50px;
- .board-header {
- position: absolute;
- top: 0;
- right: 0;
- bottom: 0;
- left: 0;
-
- button {
- display: none;
- }
- }
-
.board-title {
- padding: 0;
- border-bottom: 0;
- justify-content: center;
-
> span {
width: 100%;
margin-top: -12px;
@@ -158,34 +117,16 @@
left: 50%;
margin-left: -10px;
}
-
- .board-list-component,
- .issue-count-badge {
- display: none;
- }
- }
-
- &:not(.is-collapsed) {
- .board-list-component {
- display: flex;
- flex-direction: column;
- }
}
}
.board-inner {
- position: relative;
- height: 100%;
font-size: $issue-boards-font-size;
background: $gray-light;
border: 1px solid $border-color;
- border-radius: $border-radius-default;
- flex: 1;
}
.board-header {
- position: relative;
-
&.has-border::before {
border-top: 3px solid;
border-color: inherit;
@@ -210,30 +151,19 @@
}
}
-.board-inner-container {
- border-bottom: 1px solid $border-color;
- padding: $gl-padding;
-}
-
.board-title {
- margin: 0;
- padding: $gl-padding-8 $gl-padding;
font-size: 1em;
border-bottom: 1px solid $border-color;
- display: flex;
- align-items: center;
}
.board-title-text {
- margin-right: auto;
+ margin: $gl-vert-padding auto $gl-vert-padding 0;
}
.board-delete {
margin-right: 10px;
- padding: 0;
color: $gray-darkest;
background-color: transparent;
- border: 0;
outline: 0;
&:hover {
@@ -241,8 +171,8 @@
}
}
-.board-blank-state {
- padding: $gl-padding;
+.board-blank-state,
+.board-promotion-state {
background-color: $white-light;
flex: 1;
overflow-y: auto;
@@ -250,35 +180,23 @@
}
.board-blank-state-list {
- list-style: none;
-
> li:not(:last-child) {
margin-bottom: 8px;
}
.label-color {
- position: relative;
top: 2px;
- display: inline-block;
width: 16px;
height: 16px;
margin-right: 3px;
- border-radius: $border-radius-default;
}
}
.board-list-component {
- position: relative;
- flex: 1;
min-height: 0; // firefox fix
}
.board-list {
- height: 100%;
- width: 100%;
- margin-bottom: 0;
- padding: $gl-padding-4;
- list-style: none;
overflow-y: auto;
overflow-x: hidden;
}
@@ -289,14 +207,11 @@
}
.board-card {
- position: relative;
- padding: $gl-padding;
background: $white-light;
- border-radius: $border-radius-default;
border: 1px solid $gray-200;
box-shadow: 0 1px 2px $issue-boards-card-shadow;
- list-style: none;
line-height: $gl-padding;
+ list-style: none;
&:not(:last-child) {
margin-bottom: $gl-padding-8;
@@ -323,10 +238,6 @@
}
}
- svg {
- vertical-align: top;
- }
-
.confidential-icon {
color: $orange-600;
cursor: help;
@@ -351,11 +262,10 @@
}
.board-card-header {
- display: flex;
+ text-align: initial;
}
.board-card-assignee {
- display: flex;
margin-top: -$gl-padding-4;
margin-bottom: -$gl-padding-4;
@@ -415,34 +325,16 @@
.board-card-number {
font-size: $gl-font-size-xs;
color: $gl-text-color-secondary;
- overflow: hidden;
@include media-breakpoint-up(md) {
font-size: $label-font-size;
}
}
-.board-card-number-container {
- overflow: hidden;
-}
-
-.issue-boards-search {
- width: 395px;
-
- .form-control {
- display: inline-block;
- width: 210px;
- }
-}
-
.board-list-count {
padding: 10px 0;
color: $gl-text-color-secondary;
font-size: 13px;
-
- > .fa {
- margin-right: 5px;
- }
}
.board-new-issue-form {
@@ -450,16 +342,9 @@
margin: 5px;
}
-.page-with-contextual-sidebar.layout-page .issue-boards-sidebar {
- .issuable-sidebar-header {
- position: relative;
- }
-
+.issue-boards-sidebar {
.gutter-toggle {
- position: absolute;
- top: 0;
bottom: 15px;
- right: 0;
width: 22px;
color: $gray-darkest;
@@ -479,10 +364,6 @@
.issuable-header-text {
@include overflow-break-word();
padding-right: 35px;
-
- > strong {
- font-weight: $gl-font-weight-bold;
- }
}
}
@@ -501,51 +382,25 @@
}
.add-issues-modal {
- display: -webkit-flex;
- display: flex;
- position: fixed;
- top: 0;
- right: 0;
- bottom: 0;
- left: 0;
background-color: rgba($black, 0.3);
z-index: 9999;
}
.add-issues-container {
- display: -webkit-flex;
- display: flex;
- -webkit-flex-direction: column;
- flex-direction: column;
width: 90vw;
height: 85vh;
max-width: 1100px;
min-height: 500px;
- margin: auto;
padding: 25px 15px 0;
background-color: $white-light;
- border-radius: $border-radius-default;
box-shadow: 0 2px 12px rgba($black, 0.5);
.empty-state {
- display: -webkit-flex;
- display: flex;
- -webkit-flex: 1;
- flex: 1;
- margin-top: 0;
-
&.add-issues-empty-state-filter {
- -webkit-flex-direction: column;
flex-direction: column;
- -webkit-justify-content: center;
justify-content: center;
}
- > .row {
- width: 100%;
- margin: auto 0;
- }
-
.svg-content {
margin-top: -40px;
}
@@ -554,27 +409,15 @@
.add-issues-header {
margin: -25px -15px -5px;
- border-top: 0;
border-bottom: 1px solid $border-color;
border-top-right-radius: $border-radius-default;
border-top-left-radius: $border-radius-default;
> h2 {
- margin: 0;
font-size: 18px;
}
}
-.add-issues-search {
- display: -webkit-flex;
- display: flex;
-
- .issues-filters {
- -webkit-flex: 1;
- flex: 1;
- }
-}
-
.add-issues-list-column {
width: 100%;
@@ -588,10 +431,6 @@
}
.add-issues-list {
- display: -webkit-flex;
- display: flex;
- -webkit-flex: 1;
- flex: 1;
padding-top: 3px;
margin-left: -$gl-vert-padding;
margin-right: -$gl-vert-padding;
@@ -608,15 +447,6 @@
}
}
-.add-issues-list-loading {
- -webkit-align-self: center;
- align-self: center;
- width: 100%;
- padding-left: $gl-vert-padding;
- padding-right: $gl-vert-padding;
- font-size: 35px;
-}
-
.add-issues-footer {
margin: auto -15px 0;
padding-left: 15px;
@@ -644,27 +474,6 @@
border-radius: 50%;
}
-.modal-filters {
- display: flex;
-
- > .dropdown {
- display: none;
- margin-right: 10px;
-
- @include media-breakpoint-up(sm) {
- display: block;
- }
- }
-
- .dropdown-menu-toggle {
- width: 100px;
-
- @include media-breakpoint-up(md) {
- width: 140px;
- }
- }
-}
-
.board-card-info {
color: $gl-text-color-secondary;
white-space: nowrap;
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index fa5a182243c..6fc742871e7 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -46,11 +46,6 @@
}
.build-page {
- .build-trace-container {
- position: relative;
- }
-
-
.build-trace {
@include build-trace();
}
@@ -105,18 +100,6 @@
top: 0;
}
- .truncated-info {
- .truncated-info-size {
- margin: 0 5px;
- }
-
- .raw-link {
- color: $gl-text-color;
- margin-left: 5px;
- text-decoration: underline;
- }
- }
-
.controllers {
@include build-controllers(15px, center, false, 0, inline, 0);
}
@@ -143,12 +126,6 @@
}
}
-.with-performance-bar .build-page {
- .top-bar.affix {
- top: $header-height + $performance-bar-height;
- }
-}
-
.build-header {
.ci-header-container,
.header-action-buttons {
@@ -234,7 +211,6 @@
}
.trigger-variables-btn-container {
- @extend .d-flex;
justify-content: space-between;
align-items: center;
@@ -278,12 +254,6 @@
.retry-link {
display: block;
- .btn {
- i {
- margin-left: 5px;
- }
- }
-
.btn-inverted-secondary {
color: $blue-500;
@@ -330,16 +300,12 @@
}
}
- .build-job {
- position: relative;
-
- .icon-arrow-right {
- position: absolute;
- left: 15px;
- top: 20px;
- display: block;
- }
+ .icon-arrow-right {
+ left: 15px;
+ top: 20px;
+ }
+ .build-job {
&.active {
font-weight: $gl-font-weight-bold;
}
@@ -351,10 +317,6 @@
&:hover {
background-color: $gray-darker;
}
-
- .icon-retry {
- margin-left: 3px;
- }
}
}
@@ -392,3 +354,14 @@
right: 0;
margin-top: -17px;
}
+
+@include media-breakpoint-down(sm) {
+ .top-bar {
+ .truncated-info {
+ white-space: nowrap;
+ overflow: hidden;
+ max-width: 220px;
+ text-overflow: ellipsis;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss
index 809ba6d4953..255383d89c8 100644
--- a/app/assets/stylesheets/pages/clusters.scss
+++ b/app/assets/stylesheets/pages/clusters.scss
@@ -69,6 +69,8 @@
align-self: flex-start;
font-weight: 500;
font-size: 20px;
+ color: $orange-900;
+ opacity: 1;
margin: $gl-padding-8 14px 0 0;
}
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index 11966931a6c..77a36e59b03 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -58,14 +58,6 @@
display: inline-block;
vertical-align: middle;
- .stage-cell .stage-container {
- margin: 0 3px 3px 0;
- }
-
- .stage-container:last-child {
- margin-right: 0;
- }
-
.dropdown-menu {
margin-top: 11px;
}
@@ -128,18 +120,9 @@
}
.commit-row-title {
- .notes_count {
- float: right;
- margin-right: 10px;
- }
-
.str-truncated {
max-width: 70%;
}
-
- .commit-row-message {
- color: $gl-text-color;
- }
}
.text-expander {
@@ -171,8 +154,6 @@
}
.avatar-cell {
- width: 46px;
-
img {
margin-right: 0;
}
@@ -185,7 +166,7 @@
flex-grow: 1;
min-width: 0;
- .project_namespace {
+ .project-namespace {
color: $gl-text-color-secondary;
}
}
@@ -208,10 +189,6 @@
}
}
- .ci-status-link {
- display: inline-flex;
- }
-
.ci-status-icon svg {
vertical-align: text-bottom;
}
@@ -239,7 +216,6 @@
}
.label-monospace {
- @extend .monospace;
user-select: text;
color: $gl-text-color;
background-color: $gray-light;
@@ -266,7 +242,7 @@
}
.commit,
-.generic_commit_status {
+.generic-commit-status {
a,
button {
vertical-align: baseline;
@@ -278,37 +254,22 @@
&.autodevops-badge {
color: $white-light;
}
-
- &.autodevops-link {
- color: $blue-600;
- }
}
.commit-row-description {
@extend %commit-description-base;
display: none;
flex: 1;
-
- a {
- color: $gl-text-color;
- }
}
&.inline-commit {
.commit-row-title {
font-size: 13px;
}
-
- .committed_ago {
- @extend .cgray;
- float: right;
- }
}
}
.branch-commit {
- color: $gl-text-color;
-
.commit-icon {
text-align: center;
display: inline-block;
@@ -320,14 +281,15 @@
fill: $gl-text-color-secondary;
}
}
+}
+.commit,
+.generic-commit-status,
+.branch-commit {
+ .autodevops-link,
.commit-sha {
color: $blue-600;
}
-
- .commit-row-message {
- color: $gl-text-color;
- }
}
.gpg-status-box {
@@ -342,11 +304,11 @@
}
&.invalid {
- @include status-color($gray-dark, color("gray"), $gray-darkest);
+ @include status-color($gray-dark, color('gray'), $gray-darkest);
border-color: $gray-darkest;
&:not(span):hover {
- color: color("gray");
+ color: color('gray');
}
}
}
diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss
index ec2108b15be..2b932d164a5 100644
--- a/app/assets/stylesheets/pages/cycle_analytics.scss
+++ b/app/assets/stylesheets/pages/cycle_analytics.scss
@@ -330,7 +330,6 @@
// Custom Styles for stage items
.item-build-component {
-
.item-title {
.icon-build-status {
float: left;
diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss
index cb5f1a84005..c386493231c 100644
--- a/app/assets/stylesheets/pages/detail_page.scss
+++ b/app/assets/stylesheets/pages/detail_page.scss
@@ -21,7 +21,6 @@
.detail-page-header-body {
position: relative;
- line-height: 35px;
display: flex;
flex: 1 1;
min-width: 0;
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index d001dff7986..3b0d740def3 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -7,7 +7,9 @@
cursor: pointer;
@media (min-width: map-get($grid-breakpoints, md)) {
- $mr-file-header-top: $mr-version-controls-height + $header-height + $mr-tabs-height;
+ // The `-1` below is to prevent two borders from clashing up against eachother -
+ // the bottom of the compare-versions header and the top of the file header
+ $mr-file-header-top: $mr-version-controls-height + $header-height + $mr-tabs-height - 1;
position: -webkit-sticky;
position: sticky;
@@ -54,6 +56,11 @@
background-color: $gray-normal;
}
+ a,
+ button {
+ color: $gray-700;
+ }
+
svg {
vertical-align: middle;
top: -1px;
@@ -85,138 +92,6 @@
}
}
- .note-text {
- table {
- font-family: $font-family-sans-serif;
- }
- }
-
- table {
- width: 100%;
- font-family: $monospace-font;
- border: 0;
- border-collapse: separate;
- margin: 0;
- padding: 0;
- table-layout: fixed;
- border-radius: 0 0 $border-radius-default $border-radius-default;
-
- .diff-line-num {
- width: 50px;
- position: relative;
-
- a {
- transition: none;
- }
- }
-
- .line_holder td {
- line-height: $code-line-height;
- font-size: $code-font-size;
- vertical-align: top;
-
- &.noteable_line {
- position: relative;
- }
-
- span {
- white-space: pre-wrap;
-
- &.context-cell {
- display: inline-block;
- width: 100%;
- height: 100%;
- }
- }
-
- .line {
- word-wrap: break-word;
- }
- }
-
- &.left-side-selected {
- td.line_content.parallel.right-side {
- user-select: none;
- }
- }
-
- &.right-side-selected {
- td.line_content.parallel.left-side {
- user-select: none;
- }
- }
- }
-
- tr.line_holder.parallel {
- td.line_content.parallel {
- width: 46%;
- }
-
- .add-diff-note {
- margin-left: -55px;
- }
- }
-
- .old_line,
- .new_line {
- user-select: none;
- margin: 0;
- border: 0;
- padding: 0 5px;
- border-right: 1px solid;
- text-align: right;
- min-width: 35px;
- max-width: 50px;
- width: 35px;
-
- a {
- float: left;
- width: 35px;
- font-weight: $gl-font-weight-normal;
-
- &[disabled] {
- cursor: default;
-
- &:hover,
- &:active {
- text-decoration: none;
- }
- }
- }
- }
-
- .line_content {
- display: block;
- margin: 0;
- padding: 0 1.5em;
- border: 0;
- position: relative;
-
- &.parallel {
- display: table-cell;
-
- span {
- word-break: break-all;
- }
- }
-
- &.old {
- &::before {
- content: '-';
- position: absolute;
- left: 0.5em;
- }
- }
-
- &.new {
- &::before {
- content: '+';
- position: absolute;
- left: 0.5em;
- }
- }
- }
-
.diff-loading-error-block {
padding: $gl-padding * 2 $gl-padding;
text-align: center;
@@ -239,22 +114,18 @@
img {
border: 1px solid $white-light;
- background-image: linear-gradient(
- 45deg,
- $border-color 25%,
- transparent 25%,
- transparent 75%,
- $border-color 75%,
- $border-color 100%
- ),
- linear-gradient(
- 45deg,
- $border-color 25%,
- transparent 25%,
- transparent 75%,
- $border-color 75%,
- $border-color 100%
- );
+ background-image: linear-gradient(45deg,
+ $border-color 25%,
+ transparent 25%,
+ transparent 75%,
+ $border-color 75%,
+ $border-color 100%),
+ linear-gradient(45deg,
+ $border-color 25%,
+ transparent 25%,
+ transparent 75%,
+ $border-color 75%,
+ $border-color 100%);
background-size: 10px 10px;
background-position: 0 0, 5px 5px;
max-width: 100%;
@@ -286,11 +157,34 @@
.swipe-wrap {
overflow: hidden;
- border-left: 1px solid $gl-gray-400;
+ border-right: 1px solid $gl-gray-400;
position: absolute;
display: block;
top: 13px;
right: 7px;
+
+ &.left-oriented {
+ /* only for commit view (different swipe viewer) */
+ border-right: 0;
+ border-left: 1px solid $gl-gray-400;
+ }
+ }
+
+ .frame {
+ top: 0;
+ right: 0;
+
+ &.old-diff {
+ /* only for commit / compare view */
+ position: absolute;
+ }
+
+ &.deleted {
+ margin: 0;
+ display: block;
+ top: 13px;
+ right: 7px;
+ }
}
.swipe-bar {
@@ -443,10 +337,6 @@
}
}
- .line_content {
- white-space: pre-wrap;
- }
-
.diff-file-container {
.frame.deleted {
border: 1px solid $deleted;
@@ -508,12 +398,126 @@
}
}
+table.code {
+ width: 100%;
+ font-family: $monospace-font;
+ border: 0;
+ border-collapse: separate;
+ margin: 0;
+ padding: 0;
+ table-layout: fixed;
+ border-radius: 0 0 $border-radius-default $border-radius-default;
+
+ tr.line_holder td {
+ line-height: $code-line-height;
+ font-size: $code-font-size;
+ vertical-align: top;
+
+ span {
+ white-space: pre-wrap;
+
+ &.context-cell {
+ display: inline-block;
+ width: 100%;
+ height: 100%;
+ }
+
+ &.line {
+ word-wrap: break-word;
+ }
+ }
+
+ &.diff-line-num {
+ user-select: none;
+ margin: 0;
+ padding: 0 10px 0 5px;
+ border-right-width: 1px;
+ border-right-style: solid;
+ text-align: right;
+ width: 50px;
+ position: relative;
+
+ a {
+ transition: none;
+ float: left;
+ width: 100%;
+ font-weight: $gl-font-weight-normal;
+
+ &[disabled] {
+ cursor: default;
+
+ &:hover,
+ &:active {
+ text-decoration: none;
+ }
+ }
+ }
+
+ &:not(.js-unfold-bottom) a::before {
+ content: attr(data-linenumber);
+ }
+ }
+
+ &.line_content {
+ display: block;
+ margin: 0;
+ padding: 0 1.5em;
+ border: 0;
+ position: relative;
+ white-space: pre-wrap;
+
+ &.parallel {
+ display: table-cell;
+ width: 46%;
+
+ span {
+ word-break: break-all;
+ }
+ }
+
+ &.old {
+ &::before {
+ content: '-';
+ position: absolute;
+ left: 0.5em;
+ }
+ }
+
+ &.new {
+ &::before {
+ content: '+';
+ position: absolute;
+ left: 0.5em;
+ }
+ }
+ }
+ }
+
+ .line_holder:last-of-type {
+ td:first-child {
+ border-bottom-left-radius: $border-radius-default;
+ }
+ }
+
+ &.left-side-selected {
+ td.line_content.parallel.right-side {
+ user-select: none;
+ }
+ }
+
+ &.right-side-selected {
+ td.line_content.parallel.left-side {
+ user-select: none;
+ }
+ }
+}
+
.diff-stats {
align-items: center;
- padding: 0 .25rem;
+ padding: 0 0.25rem;
.diff-stats-group {
- padding: 0 .25rem;
+ padding: 0 0.25rem;
}
svg.diff-stats-icon {
@@ -522,7 +526,7 @@
&.is-compare-versions-header {
.diff-stats-group {
- padding: 0 .5rem;
+ padding: 0 0.5rem;
}
}
}
@@ -608,22 +612,11 @@
}
}
-.file-holder {
- .diff-line-num:not(.js-unfold-bottom) {
- a {
- &::before {
- content: attr(data-linenumber);
- }
- }
- }
-}
-
.diff-comment-avatar-holders {
position: absolute;
- height: 19px;
- width: 19px;
- margin-left: -15px;
+ margin-left: -$gl-padding;
z-index: 100;
+ @include code-icon-size();
&:hover {
.diff-comment-avatar,
@@ -657,26 +650,28 @@
.diff-comments-more-count {
position: absolute;
left: 0;
- width: 19px;
- height: 19px;
margin-right: 0;
border-color: $white-light;
cursor: pointer;
transition: all 0.1s ease-out;
+ @include code-icon-size();
@for $i from 1 through 4 {
&:nth-child(#{$i}) {
z-index: (4 - $i);
}
}
+
+ .avatar {
+ @include code-icon-size();
+ }
}
.diff-comments-more-count {
- width: 19px;
- min-width: 19px;
padding-left: 0;
padding-right: 0;
overflow: hidden;
+ @include code-icon-size();
}
.diff-comments-more-count,
@@ -685,12 +680,15 @@
}
.diff-notes-collapse {
- width: 24px;
- height: 24px;
+ border: 0;
border-radius: 50%;
padding: 0;
transition: transform 0.1s ease-out;
z-index: 100;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ @include code-icon-size();
.collapse-icon {
height: 50%;
@@ -834,34 +832,26 @@
width: 100%;
height: 10px;
background-color: $white-light;
- background-image: linear-gradient(
- 45deg,
- transparent,
- transparent 73%,
- $diff-jagged-border-gradient-color 75%,
- $white-light 80%
- ),
- linear-gradient(
- 225deg,
- transparent,
- transparent 73%,
- $diff-jagged-border-gradient-color 75%,
- $white-light 80%
- ),
- linear-gradient(
- 135deg,
- transparent,
- transparent 73%,
- $diff-jagged-border-gradient-color 75%,
- $white-light 80%
- ),
- linear-gradient(
- -45deg,
- transparent,
- transparent 73%,
- $diff-jagged-border-gradient-color 75%,
- $white-light 80%
- );
+ background-image: linear-gradient(45deg,
+ transparent,
+ transparent 73%,
+ $diff-jagged-border-gradient-color 75%,
+ $white-light 80%),
+ linear-gradient(225deg,
+ transparent,
+ transparent 73%,
+ $diff-jagged-border-gradient-color 75%,
+ $white-light 80%),
+ linear-gradient(135deg,
+ transparent,
+ transparent 73%,
+ $diff-jagged-border-gradient-color 75%,
+ $white-light 80%),
+ linear-gradient(-45deg,
+ transparent,
+ transparent 73%,
+ $diff-jagged-border-gradient-color 75%,
+ $white-light 80%);
background-position: 5px 5px, 0 5px, 0 5px, 5px 5px;
background-size: 10px 10px;
background-repeat: repeat;
@@ -904,7 +894,7 @@
}
}
-.files:not([data-can-create-note="true"]) .frame {
+.files:not([data-can-create-note='true']) .frame {
cursor: auto;
}
@@ -913,15 +903,14 @@
.btn-transparent.image-diff-overlay-add-comment {
position: relative;
cursor: image-url('illustrations/image_comment_light_cursor.svg')
- $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
+ $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
auto;
// Retina cursor
- cursor: -webkit-image-set(
- image-url('illustrations/image_comment_light_cursor.svg') 1x,
- image-url('illustrations/image_comment_light_cursor@2x.svg') 2x
- )
- $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
+ // scss-lint:disable DuplicateProperty
+ cursor: image-set(image-url('illustrations/image_comment_light_cursor.svg') 1x,
+ image-url('illustrations/image_comment_light_cursor@2x.svg') 2x)
+ $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
auto;
.comment-indicator {
@@ -1019,6 +1008,10 @@
display: block;
}
}
+
+ .note-edit-form {
+ margin-left: $note-icon-gutter-width;
+ }
}
.discussion-body .image .frame {
@@ -1106,7 +1099,10 @@
flex-direction: column;
.diff-tree-list {
- width: 100%;
+ position: relative;
+ top: 0;
+ // !important is required to override inline styles of resizable sidebar
+ width: 100% !important;
}
.tree-list-holder {
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index 61ecf133b02..93dffb5ff09 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -12,34 +12,6 @@
.environments-container {
.ci-table {
- .deployment-column {
- > span {
- word-break: break-all;
- }
-
- .avatar {
- float: none;
- }
- }
-
- .btn-group {
- > .btn:not(.btn-danger) {
- color: $gl-text-color-secondary;
- }
-
- svg path {
- fill: $gl-text-color-secondary;
- }
-
- .dropdown {
- outline: none;
- }
- }
-
- .btn .text-center {
- display: inline;
- }
-
.commit-title {
margin: 0;
}
@@ -49,47 +21,16 @@
color: $gl-text-color-secondary;
}
- .dropdown-menu {
- .fa {
- margin-right: 6px;
- color: $gl-text-color-secondary;
- }
- }
-
.build-link,
.ref-name {
color: $gl-text-color;
}
- .stop-env-link,
- .external-url {
- color: $gl-text-color-secondary;
-
- .stop-env-icon {
- font-size: 14px;
- }
- }
-
- .deployment .build-column {
- .build-link {
- color: $gl-text-color;
- }
-
- .avatar {
- float: none;
- margin-right: 0;
- }
- }
-
.folder-icon {
margin-right: 3px;
color: $gl-text-color-secondary;
display: inline-block;
vertical-align: text-top;
-
- .fa:nth-child(1) {
- margin-right: 3px;
- }
}
.folder-name {
@@ -103,12 +44,6 @@
text-align: center;
}
- .branch-commit {
- .commit-sha {
- margin-right: 0;
- }
- }
-
.no-btn {
border: 0;
background: none;
@@ -168,11 +103,6 @@
opacity: 0.25;
}
-.prometheus-graph-overlay {
- fill: none;
- opacity: 0;
- pointer-events: all;
-}
.rect-text-metric {
fill: $white-light;
@@ -203,274 +133,10 @@
stroke: $gray-darkest;
}
-.prometheus-graphs {
- .environments {
- .dropdown-menu-toggle {
- svg {
- position: absolute;
- right: 5%;
- top: 25%;
- }
- }
-
- .dropdown-menu-toggle,
- .dropdown-menu {
- width: 240px;
- }
- }
-}
-
.environments-actions {
.external-url,
.monitoring-url,
- .terminal-button,
- .stop-env-link {
+ .terminal-button {
width: 38px;
}
}
-
-.prometheus-panel {
- margin-top: 20px;
-}
-
-.prometheus-graph-group {
- display: flex;
- flex-wrap: wrap;
- padding: $gl-padding / 2;
-}
-
-.prometheus-graph {
- padding: $gl-padding / 2;
-}
-
-.prometheus-graph-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: $gl-padding-8;
-
- h5 {
- font-size: $gl-font-size-large;
- margin: 0;
- }
-}
-
-.prometheus-graph-cursor {
- position: absolute;
- background: $gray-600;
- width: 1px;
-}
-
-.prometheus-graph-flag {
- display: block;
- min-width: 160px;
- border: 0;
- box-shadow: 0 1px 4px 0 $black-transparent;
-
- h5 {
- padding: 0;
- margin: 0;
- font-size: 14px;
- line-height: 1.2;
- }
-
- .deploy-meta-content {
- border-bottom: 1px solid $white-dark;
-
- svg {
- height: 15px;
- vertical-align: bottom;
- }
- }
-
- &.popover {
- padding: 0;
-
- &.left {
- left: auto;
- right: 0;
- margin-right: 10px;
-
- > .arrow {
- right: -14px;
- border-left-color: $border-color;
- }
-
- > .arrow::after {
- border-top: 6px solid transparent;
- border-bottom: 6px solid transparent;
- border-left: 4px solid $gray-50;
- }
-
- .arrow-shadow {
- right: -3px;
- box-shadow: 1px 0 9px 0 $black-transparent;
- }
- }
-
- &.right {
- left: 0;
- right: auto;
- margin-left: 10px;
-
- > .arrow {
- left: -7px;
- border-right-color: $border-color;
- }
-
- > .arrow::after {
- border-top: 6px solid transparent;
- border-bottom: 6px solid transparent;
- border-right: 4px solid $gray-50;
- }
-
- .arrow-shadow {
- left: -3px;
- box-shadow: 1px 0 8px 0 $black-transparent;
- }
- }
-
- > .arrow {
- top: 10px;
- margin: 0;
- }
-
- .arrow-shadow {
- content: '';
- position: absolute;
- width: 7px;
- height: 7px;
- background-color: transparent;
- transform: rotate(45deg);
- top: 13px;
- }
-
- > .popover-title,
- > .popover-content {
- padding: 8px;
- font-size: 12px;
- white-space: nowrap;
- position: relative;
- }
-
- > .popover-title {
- background-color: $gray-50;
- border-radius: $border-radius-default $border-radius-default 0 0;
- }
- }
-
- strong {
- font-weight: 600;
- }
-}
-
-.prometheus-table {
- border-collapse: collapse;
- padding: 0;
- margin: 0;
-
- td {
- vertical-align: middle;
-
- + td {
- padding-left: 8px;
- vertical-align: top;
- }
- }
-
- .legend-metric-title {
- font-size: 12px;
- vertical-align: middle;
- }
-}
-
-.prometheus-svg-container {
- position: relative;
- height: 0;
- width: 100%;
- padding: 0;
- padding-bottom: 100%;
-
- .text-metric-usage {
- fill: $black;
- font-weight: $gl-font-weight-normal;
- font-size: 12px;
- }
-
- > svg {
- position: absolute;
- height: 100%;
- width: 100%;
- left: 0;
- top: 0;
-
- text {
- fill: $gl-text-color;
- stroke-width: 0;
- }
-
- .text-metric-bold {
- font-weight: $gl-font-weight-bold;
- }
-
- .label-axis-text {
- fill: $black;
- font-weight: $gl-font-weight-normal;
- font-size: 10px;
- }
-
- .legend-axis-text {
- fill: $black;
- }
-
- .tick {
- > line {
- stroke: $gray-darker;
- }
-
- > text {
- fill: $gray-600;
- font-size: 10px;
- }
- }
-
- .y-label-text,
- .x-label-text {
- fill: $gray-darkest;
- }
-
- .axis-tick {
- stroke: $gray-darker;
- }
-
- .deploy-info-text {
- dominant-baseline: text-before-edge;
- font-size: 12px;
- }
-
- .deploy-info-text-link {
- font-family: $monospace-font;
- fill: $blue-600;
-
- &:hover {
- fill: $blue-800;
- }
- }
-
- @include media-breakpoint-down(sm) {
- .label-axis-text,
- .text-metric-usage,
- .legend-axis-text {
- font-size: 8px;
- }
-
- .tick > text {
- font-size: 8px;
- }
- }
- }
-}
-
-.prometheus-table-row-highlight {
- background-color: $gray-100;
-}
diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss
index 618f23d81b1..500f5816d38 100644
--- a/app/assets/stylesheets/pages/events.scss
+++ b/app/assets/stylesheets/pages/events.scss
@@ -8,7 +8,7 @@
border-bottom: 1px solid $white-normal;
color: $gl-text-color-secondary;
position: relative;
- line-height: $gl-line-height;
+ line-height: $gl-line-height-20;
.system-note-image {
position: absolute;
@@ -48,7 +48,7 @@
}
.event-user-info {
- margin-bottom: $gl-padding-8;
+ margin-bottom: $gl-padding-4;
.author_name {
a {
@@ -67,7 +67,7 @@
}
.event-body {
- margin-top: $gl-padding-8;
+ margin-top: $gl-padding-4;
margin-right: 174px;
color: $gl-text-color;
@@ -156,6 +156,10 @@
&:hover {
background: none;
}
+
+ a {
+ color: $blue-600;
+ }
}
}
}
diff --git a/app/assets/stylesheets/pages/graph.scss b/app/assets/stylesheets/pages/graph.scss
index 83b1680512d..3febf4cf826 100644
--- a/app/assets/stylesheets/pages/graph.scss
+++ b/app/assets/stylesheets/pages/graph.scss
@@ -71,12 +71,10 @@
.svg-graph-container-with-grab {
cursor: grab;
- cursor: -webkit-grab;
}
.svg-graph-container-grabbed {
cursor: grabbing;
- cursor: -webkit-grabbing;
}
@keyframes flickerAnimation {
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index 8ade995525a..656202f4e58 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -15,6 +15,11 @@
word-wrap: nowrap;
}
+.content-list .group-name {
+ font-weight: $gl-font-weight-bold;
+ color: $pages-group-name-color;
+}
+
.group-row {
@include basic-list-stats;
@@ -30,9 +35,6 @@
}
.group-nav-container .nav-controls {
- align-items: flex-start;
- padding: $gl-padding-top 0 0;
-
.group-filter-form {
flex: 1 1 auto;
margin-right: $gl-padding-8;
@@ -172,6 +174,50 @@
}
}
+.card {
+ .shared_runners_limit_under_quota {
+ color: $green-500;
+ }
+
+ .shared_runners_limit_over_quota {
+ color: $red-500;
+ }
+}
+
+.pipeline-quota {
+ border-top: 1px solid $table-border-color;
+ border-bottom: 1px solid $table-border-color;
+ margin: 0 0 $gl-padding;
+
+ .row {
+ padding-top: 10px;
+ padding-bottom: 10px;
+ }
+
+ .right {
+ text-align: right;
+ }
+
+ .progress {
+ height: 6px;
+ width: 100%;
+ margin-bottom: 0;
+ margin-top: 4px;
+ }
+}
+
+.user-settings-pipeline-quota {
+ margin-top: $gl-padding;
+
+ .pipeline-quota {
+ border-top: 0;
+ }
+}
+
+table.pipeline-project-metrics tr td {
+ padding: $gl-padding;
+}
+
.mattermost-icon svg {
width: 16px;
height: 16px;
diff --git a/app/assets/stylesheets/pages/help.scss b/app/assets/stylesheets/pages/help.scss
index 2c23f31c240..7610c5cf6f3 100644
--- a/app/assets/stylesheets/pages/help.scss
+++ b/app/assets/stylesheets/pages/help.scss
@@ -30,19 +30,11 @@
.key {
@extend .badge.badge-pill;
background-color: $label-inverse-bg;
- font: 11px Consolas, "Liberation Mono", Menlo, Courier, monospace;
+ font: 11px Consolas, 'Liberation Mono', Menlo, Courier, monospace;
padding: 3px 5px;
}
}
.documentation {
padding: 7px;
-
- // Border around images in the help pages.
- img:not(.emoji) {
- border: 1px solid $white-normal;
- padding: 5px;
- margin: 5px;
- max-height: calc(100vh - 100px);
- }
}
diff --git a/app/assets/stylesheets/pages/import.scss b/app/assets/stylesheets/pages/import.scss
index 7f800367cad..74f80a11471 100644
--- a/app/assets/stylesheets/pages/import.scss
+++ b/app/assets/stylesheets/pages/import.scss
@@ -18,8 +18,6 @@
}
.import-namespace-select {
- width: auto !important;
-
> .select2-choice {
border-radius: $border-radius-default 0 0 $border-radius-default;
position: relative;
@@ -49,3 +47,15 @@
.import-projects-loading-icon {
margin-top: $gl-padding-32;
}
+
+.btn-import {
+ .loading-icon {
+ display: none;
+ }
+
+ &.is-loading {
+ .loading-icon {
+ display: inline-block;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index e0bdc1341b1..79282f9043c 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -1,8 +1,3 @@
-// Limit MR description for side-by-side diff view
-.fixed-width-container {
- @include fixed-width-container;
-}
-
.issuable-warning-icon {
background-color: $orange-100;
border-radius: $border-radius-default;
@@ -22,11 +17,12 @@
.detail-page-header,
.page-content-header,
.commit-box,
+ .info-well,
.commit-ci-menu,
.files-changed-inner,
.limited-header-width,
.limited-width-notes {
- @extend .fixed-width-container;
+ @include fixed-width-container;
}
.issuable-details {
@@ -34,13 +30,13 @@
.mr-source-target,
.mr-state-widget,
.merge-manually {
- @extend .fixed-width-container;
+ @include fixed-width-container;
}
}
.merge-request-details {
.emoji-list-container {
- @extend .fixed-width-container;
+ @include fixed-width-container;
}
}
}
@@ -64,6 +60,7 @@
overflow-wrap: break-word;
min-width: 0;
width: 100%;
+ text-align: initial;
}
.btn-edit {
@@ -71,16 +68,12 @@
height: $gl-padding * 2;
}
- // Border around images in issue and MR descriptions.
- .description img:not(.emoji) {
- border: 1px solid $white-normal;
- padding: 5px;
- max-height: calc(100vh - 100px);
- max-width: 100%;
- }
-
.emoji-block {
- padding: 10px 0;
+ padding: $gl-padding-4 0;
+
+ @include media-breakpoint-down(md) {
+ padding: $gl-padding-8 0;
+ }
}
}
@@ -117,6 +110,20 @@
font-size: 0;
margin-bottom: -5px;
}
+
+ .scoped-label-wrapper {
+ > a {
+ max-width: 100%;
+ }
+
+ .color-label {
+ padding-right: $gl-padding-24;
+ }
+
+ .scoped-label {
+ right: 12px;
+ }
+ }
}
.right-sidebar {
@@ -129,6 +136,10 @@
z-index: 200;
overflow: hidden;
+ @include media-breakpoint-down(sm) {
+ z-index: 251;
+ }
+
a:not(.btn) {
color: inherit;
@@ -136,7 +147,7 @@
color: $blue-800;
.avatar {
- border-color: rgba($gray-normal, .2);
+ border-color: rgba($gray-normal, 0.2);
}
}
@@ -215,7 +226,7 @@
.title {
color: $gl-text-color;
- margin-bottom: 10px;
+ margin-bottom: $gl-padding-8;
line-height: 1;
.avatar {
@@ -223,7 +234,7 @@
}
a.edit-link:not([href]):hover {
- color: rgba($gray-normal, .2);
+ color: rgba($gray-normal, 0.2);
}
.lock-edit, // uses same style, different js behaviour
@@ -263,6 +274,10 @@
.selectbox {
display: none;
+
+ &.show {
+ display: block;
+ }
}
.btn-clipboard:hover {
@@ -316,6 +331,7 @@
}
.no-value,
+ .btn-default-hover-link,
.btn-secondary-hover-link {
color: $gl-text-color-secondary;
}
@@ -596,10 +612,8 @@
.participants-list {
display: flex;
flex-wrap: wrap;
- margin: -7px;
}
-
.user-list {
display: flex;
flex-wrap: wrap;
@@ -607,7 +621,7 @@
.participants-author {
display: inline-block;
- padding: 7px;
+ padding: 0 $gl-padding-8 $gl-padding-8 0;
&:nth-of-type(7n) {
padding-right: 0;
@@ -634,7 +648,6 @@
.participants-more,
.user-list-more {
- margin-top: 5px;
margin-left: 5px;
a,
@@ -711,14 +724,11 @@
.issuable-list {
li {
-
.issue-box {
- display: -webkit-flex;
display: flex;
}
.issuable-info-container {
- -webkit-flex: 1;
flex: 1;
display: flex;
padding-right: $gl-padding;
@@ -726,6 +736,7 @@
.issuable-main-info {
flex: 1 auto;
margin-right: 10px;
+ min-width: 0;
.issue-weight-icon {
vertical-align: sub;
@@ -787,6 +798,7 @@
@media(max-width: map-get($grid-breakpoints, lg)-1) {
.task-status,
.issuable-due-date,
+ .issuable-weight,
.project-ref-path {
display: none;
}
@@ -813,7 +825,6 @@
.sidebar-collapsed-icon {
-
> .stopwatch-svg {
display: inline-block;
}
@@ -871,11 +882,11 @@
}
.help-state-toggle-enter-active {
- transition: all .8s ease;
+ transition: all 0.8s ease;
}
.help-state-toggle-leave-active {
- transition: all .5s ease;
+ transition: all 0.5s ease;
}
.help-state-toggle-enter,
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index 0037364978c..48289c8f381 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -58,8 +58,6 @@ form.edit-issue {
}
ul.related-merge-requests > li {
- display: -ms-flexbox;
- display: -webkit-flex;
display: flex;
align-items: center;
@@ -147,6 +145,11 @@ ul.related-merge-requests > li {
}
}
+.issues-footer {
+ padding-top: $gl-padding;
+ padding-bottom: 37px;
+}
+
.issues-nav-controls {
font-size: 0;
@@ -255,8 +258,15 @@ ul.related-merge-requests > li {
}
}
-.discussion-reply-holder .note-edit-form {
- display: block;
+.discussion-reply-holder {
+ .avatar-note-form-holder .note-edit-form {
+ display: block;
+ margin-left: $note-icon-gutter-width;
+
+ @include media-breakpoint-down(xs) {
+ margin-left: 0;
+ }
+ }
}
.issue-sort-dropdown {
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index 2372640277e..11e8a32389f 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -34,7 +34,7 @@
.dropdown-new-label {
.dropdown-content {
- max-height: 136px;
+ max-height: initial;
}
}
@@ -75,7 +75,7 @@
padding: 0;
margin-bottom: 0;
- > li:not(.empty-message):not(.is-not-draggable) {
+ > li:not(.empty-message):not(.no-border) {
background-color: $white-light;
margin-bottom: 5px;
display: flex;
@@ -92,16 +92,14 @@
opacity: 0.3;
}
- .prioritized-labels & {
+ .prioritized-labels:not(.is-not-draggable) & {
box-shadow: 0 1px 2px $issue-boards-card-shadow;
cursor: move;
- cursor: -webkit-grab;
- cursor: -moz-grab;
+ cursor: grab;
border: 0;
&:active {
- cursor: -webkit-grabbing;
- cursor: -moz-grabbing;
+ cursor: grabbing;
}
}
}
@@ -355,7 +353,7 @@
@media (max-width: map-get($grid-breakpoints, md)-1) {
.manage-labels-list {
- > li:not(.empty-message):not(.is-not-draggable) {
+ > li:not(.empty-message):not(.no-border) {
flex-wrap: wrap;
}
@@ -404,3 +402,67 @@
.priority-labels-empty-state .svg-content img {
max-width: $priority-label-empty-state-width;
}
+
+.scoped-label-tooltip-title {
+ color: $indigo-300;
+}
+
+.scoped-label-wrapper {
+ max-width: 100%;
+ vertical-align: top;
+
+ .badge {
+ text-overflow: ellipsis;
+ overflow-x: hidden;
+ }
+
+ &.label-link .color-label a {
+ color: inherit;
+ }
+
+ .color-label {
+ padding-right: $gl-padding-24;
+ max-width: 100%;
+ }
+
+ .scoped-label {
+ position: absolute;
+ top: 4px;
+ right: 8px;
+ padding: 0;
+ margin: 0;
+ line-height: $gl-line-height;
+ }
+
+ &.board-label {
+ .scoped-label {
+ top: 1px;
+ }
+ }
+}
+
+// Label inside title of Delete Label Modal
+.modal-header .page-title {
+ .scoped-label-wrapper {
+ .scoped-label {
+ line-height: 20px;
+ }
+
+ span.color-label {
+ padding-right: $gl-padding-24;
+ }
+ }
+}
+
+// Don't hide the overflow in system messages
+.system-note-message,
+.issuable-details,
+.md-preview-holder,
+.referenced-commands,
+.note-body {
+ .scoped-label-wrapper {
+ .badge {
+ overflow: initial;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss
index 67d7a8175ac..d8aabecc036 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -21,13 +21,6 @@
color: $login-brand-holder-color;
}
- h1:first-child {
- font-weight: $gl-font-weight-normal;
- margin-bottom: 0.68em;
- margin-top: 0;
- font-size: 34px;
- }
-
h3 {
font-size: 22px;
}
@@ -49,8 +42,8 @@
.login-box,
.omniauth-container {
box-shadow: 0 0 0 1px $border-color;
- border-bottom-right-radius: $border-radius-small;
- border-bottom-left-radius: $border-radius-small;
+ border-bottom-right-radius: $border-radius;
+ border-bottom-left-radius: $border-radius;
padding: 15px;
.login-heading h3 {
@@ -80,7 +73,8 @@
.login-body {
font-size: 13px;
- input + p {
+ input + p,
+ input ~ p.field-validation {
margin-top: 5px;
}
@@ -95,7 +89,7 @@
}
.omniauth-container {
- border-radius: $border-radius-small;
+ border-radius: $border-radius;
font-size: 13px;
p {
@@ -120,7 +114,6 @@
}
.new-session-tabs {
- display: -webkit-flex;
display: flex;
box-shadow: 0 0 0 1px $border-color;
border-top-right-radius: $border-radius-default;
@@ -190,7 +183,7 @@
margin-top: 16px;
}
- input[type="submit"] {
+ input[type='submit'] {
@extend .btn-block;
margin-bottom: 0;
}
diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss
index 99609a96976..68af01f9ccc 100644
--- a/app/assets/stylesheets/pages/members.scss
+++ b/app/assets/stylesheets/pages/members.scss
@@ -14,20 +14,14 @@
}
.member {
- .list-item-name {
- @include media-breakpoint-up(sm) {
- float: left;
- width: 50%;
- }
-
- strong {
- font-weight: $gl-font-weight-bold;
+ &.is-overridden {
+ .btn-ldap-override {
+ display: none !important;
}
}
.controls {
@include media-breakpoint-up(sm) {
- display: -webkit-flex;
display: flex;
}
@@ -38,10 +32,11 @@
.form-group {
margin-bottom: 0;
+ }
- @include media-breakpoint-down(sm) {
- display: block;
- margin-left: 5px;
+ .member-controls {
+ .fa {
+ line-height: inherit;
}
}
@@ -61,23 +56,12 @@
}
.member-form-control {
- @include media-breakpoint-down(sm) {
- width: $dropdown-member-form-control-width;
- margin-left: 0;
- padding-bottom: 5px;
- }
-
@include media-breakpoint-down(xs) {
margin-right: 0;
width: auto;
}
}
-.member-access-text {
- margin-left: auto;
- line-height: 43px;
-}
-
.member-search-form {
position: relative;
@@ -123,6 +107,46 @@
outline: 0;
}
+.members-ldap {
+ align-self: center;
+}
+
+.alert-member-ldap {
+ background-color: $orange-50;
+
+ @include media-breakpoint-up(sm) {
+ line-height: 40px;
+ }
+
+ > p {
+ float: left;
+ margin-bottom: 10px;
+ color: $orange-600;
+
+ @include media-breakpoint-up(sm) {
+ padding-left: 55px;
+ margin-bottom: 0;
+ }
+ }
+
+ .controls {
+ width: 100%;
+
+ @include media-breakpoint-up(sm) {
+ width: auto;
+ }
+ }
+}
+
+.btn-ldap-override {
+ width: 100%;
+
+ @include media-breakpoint-up(sm) {
+ margin-left: 10px;
+ width: auto;
+ }
+}
+
.flex-project-members-panel {
display: flex;
flex-direction: row;
@@ -176,9 +200,6 @@
}
.content-list.members-list li {
- display: flex;
- justify-content: space-between;
-
.list-item-name {
float: none;
display: flex;
@@ -207,33 +228,24 @@
align-self: flex-start;
}
+ @include media-breakpoint-down(sm) {
+ .member-access-text {
+ margin: 0 0 $gl-padding-4 ($grid-size * 6);
+ }
+ }
+
@include media-breakpoint-down(xs) {
display: block;
- .controls > .btn {
- margin-left: 0;
- margin-right: 0;
+ .controls > .btn,
+ .controls .member-form-control {
+ margin: 0 0 $gl-padding-8;
display: block;
}
- .controls > .btn:last-child {
- margin-left: 5px;
- margin-right: 5px;
- width: auto;
- }
-
.form-control {
width: 100%;
}
-
- .member-access-text {
- line-height: 0;
- margin-left: 50px;
- }
-
- .member-controls {
- margin-top: 5px;
- }
}
}
diff --git a/app/assets/stylesheets/pages/merge_conflicts.scss b/app/assets/stylesheets/pages/merge_conflicts.scss
index e0f7d075fc7..278a9014458 100644
--- a/app/assets/stylesheets/pages/merge_conflicts.scss
+++ b/app/assets/stylesheets/pages/merge_conflicts.scss
@@ -20,81 +20,81 @@ $colors: (
white-header-not-chosen : #f0f0f0,
white-line-not-chosen : $gray-light,
- dark-header-head-neutral : rgba(#3f3, .2),
- dark-line-head-neutral : rgba(#3f3, .1),
+ dark-header-head-neutral : rgba(#3f3, 0.2),
+ dark-line-head-neutral : rgba(#3f3, 0.1),
dark-button-head-neutral : #40874f,
- dark-header-head-chosen : rgba(#3f3, .33),
- dark-line-head-chosen : rgba(#3f3, .2),
+ dark-header-head-chosen : rgba(#3f3, 0.33),
+ dark-line-head-chosen : rgba(#3f3, 0.2),
dark-button-head-chosen : #258537,
- dark-header-origin-neutral : rgba(#2878c9, .4),
- dark-line-origin-neutral : rgba(#2878c9, .3),
+ dark-header-origin-neutral : rgba(#2878c9, 0.4),
+ dark-line-origin-neutral : rgba(#2878c9, 0.3),
dark-button-origin-neutral : #2a5c8c,
- dark-header-origin-chosen : rgba(#2878c9, .6),
- dark-line-origin-chosen : rgba(#2878c9, .4),
+ dark-header-origin-chosen : rgba(#2878c9, 0.6),
+ dark-line-origin-chosen : rgba(#2878c9, 0.4),
dark-button-origin-chosen : #1d6cbf,
- dark-header-not-chosen : rgba(#fff, .25),
- dark-line-not-chosen : rgba(#fff, .1),
+ dark-header-not-chosen : rgba(#fff, 0.25),
+ dark-line-not-chosen : rgba(#fff, 0.1),
- monokai-header-head-neutral : rgba(#a6e22e, .25),
- monokai-line-head-neutral : rgba(#a6e22e, .1),
+ monokai-header-head-neutral : rgba(#a6e22e, 0.25),
+ monokai-line-head-neutral : rgba(#a6e22e, 0.1),
monokai-button-head-neutral : #376b20,
- monokai-header-head-chosen : rgba(#a6e22e, .4),
- monokai-line-head-chosen : rgba(#a6e22e, .25),
+ monokai-header-head-chosen : rgba(#a6e22e, 0.4),
+ monokai-line-head-chosen : rgba(#a6e22e, 0.25),
monokai-button-head-chosen : #39800d,
- monokai-header-origin-neutral : rgba(#60d9f1, .35),
- monokai-line-origin-neutral : rgba(#60d9f1, .15),
+ monokai-header-origin-neutral : rgba(#60d9f1, 0.35),
+ monokai-line-origin-neutral : rgba(#60d9f1, 0.15),
monokai-button-origin-neutral : #38848c,
- monokai-header-origin-chosen : rgba(#60d9f1, .5),
- monokai-line-origin-chosen : rgba(#60d9f1, .35),
+ monokai-header-origin-chosen : rgba(#60d9f1, 0.5),
+ monokai-line-origin-chosen : rgba(#60d9f1, 0.35),
monokai-button-origin-chosen : #3ea4b2,
- monokai-header-not-chosen : rgba(#76715d, .24),
- monokai-line-not-chosen : rgba(#76715d, .1),
+ monokai-header-not-chosen : rgba(#76715d, 0.24),
+ monokai-line-not-chosen : rgba(#76715d, 0.1),
- solarized-light-header-head-neutral : rgba(#859900, .37),
- solarized-light-line-head-neutral : rgba(#859900, .2),
+ solarized-light-header-head-neutral : rgba(#859900, 0.37),
+ solarized-light-line-head-neutral : rgba(#859900, 0.2),
solarized-light-button-head-neutral : #afb262,
- solarized-light-header-head-chosen : rgba(#859900, .5),
- solarized-light-line-head-chosen : rgba(#859900, .37),
+ solarized-light-header-head-chosen : rgba(#859900, 0.5),
+ solarized-light-line-head-chosen : rgba(#859900, 0.37),
solarized-light-button-head-chosen : #94993d,
- solarized-light-header-origin-neutral : rgba(#2878c9, .37),
- solarized-light-line-origin-neutral : rgba(#2878c9, .15),
+ solarized-light-header-origin-neutral : rgba(#2878c9, 0.37),
+ solarized-light-line-origin-neutral : rgba(#2878c9, 0.15),
solarized-light-button-origin-neutral : #60a1bf,
- solarized-light-header-origin-chosen : rgba(#2878c9, .6),
- solarized-light-line-origin-chosen : rgba(#2878c9, .37),
+ solarized-light-header-origin-chosen : rgba(#2878c9, 0.6),
+ solarized-light-line-origin-chosen : rgba(#2878c9, 0.37),
solarized-light-button-origin-chosen : #2482b2,
- solarized-light-header-not-chosen : rgba(#839496, .37),
- solarized-light-line-not-chosen : rgba(#839496, .2),
+ solarized-light-header-not-chosen : rgba(#839496, 0.37),
+ solarized-light-line-not-chosen : rgba(#839496, 0.2),
- solarized-dark-header-head-neutral : rgba(#859900, .35),
- solarized-dark-line-head-neutral : rgba(#859900, .15),
+ solarized-dark-header-head-neutral : rgba(#859900, 0.35),
+ solarized-dark-line-head-neutral : rgba(#859900, 0.15),
solarized-dark-button-head-neutral : #376b20,
- solarized-dark-header-head-chosen : rgba(#859900, .5),
- solarized-dark-line-head-chosen : rgba(#859900, .35),
+ solarized-dark-header-head-chosen : rgba(#859900, 0.5),
+ solarized-dark-line-head-chosen : rgba(#859900, 0.35),
solarized-dark-button-head-chosen : #39800d,
- solarized-dark-header-origin-neutral : rgba(#2878c9, .35),
- solarized-dark-line-origin-neutral : rgba(#2878c9, .15),
+ solarized-dark-header-origin-neutral : rgba(#2878c9, 0.35),
+ solarized-dark-line-origin-neutral : rgba(#2878c9, 0.15),
solarized-dark-button-origin-neutral : #086799,
- solarized-dark-header-origin-chosen : rgba(#2878c9, .6),
- solarized-dark-line-origin-chosen : rgba(#2878c9, .35),
+ solarized-dark-header-origin-chosen : rgba(#2878c9, 0.6),
+ solarized-dark-line-origin-chosen : rgba(#2878c9, 0.35),
solarized-dark-button-origin-chosen : #0082cc,
- solarized_dark_header_not_chosen : rgba(#839496, .25),
- solarized_dark_line_not_chosen : rgba(#839496, .15),
+ solarized_dark_header_not_chosen : rgba(#839496, 0.25),
+ solarized_dark_line_not_chosen : rgba(#839496, 0.15),
none_header_head_neutral : $gray-normal,
none_line_head_neutral : $gray-normal,
@@ -210,26 +210,20 @@ $colors: (
}
#conflicts {
-
.white {
- @include color-scheme('white')
- }
+ @include color-scheme('white'); }
.dark {
- @include color-scheme('dark')
- }
+ @include color-scheme('dark'); }
.monokai {
- @include color-scheme('monokai')
- }
+ @include color-scheme('monokai'); }
.solarized-light {
- @include color-scheme('solarized-light')
- }
+ @include color-scheme('solarized-light'); }
.solarized-dark {
- @include color-scheme('solarized-dark')
- }
+ @include color-scheme('solarized-dark'); }
.diff-wrap-lines .line_content {
white-space: normal;
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index cfd3faab122..8cb3fab74e0 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -87,6 +87,11 @@
padding: $gl-padding;
}
+.mr-widget-info {
+ padding-left: $gl-padding-50 - $gl-padding-32;
+ padding-right: $gl-padding;
+}
+
.mr-state-widget {
color: $gl-text-color;
@@ -166,6 +171,7 @@
float: left;
.accept-merge-request {
+ &.ci-preparing,
&.ci-pending,
&.ci-running {
@include btn-blue;
@@ -179,46 +185,6 @@
}
}
}
-
- .accept-control {
- display: inline-block;
- float: left;
- margin: 0;
- margin-left: 20px;
- padding: 5px;
- padding-top: 8px;
- line-height: 20px;
-
- &.right {
- float: right;
- padding-right: 0;
- }
-
- .modify-merge-commit-link {
- padding: 0;
- background-color: transparent;
- border: 0;
- color: $gl-text-color;
-
- &:hover,
- &:focus {
- text-decoration: underline;
- }
- }
-
- .merge-param-checkbox {
- margin: 0;
- }
-
- a .fa-question-circle {
- color: $gl-text-color-secondary;
-
- &:hover,
- &:focus {
- color: $link-hover-color;
- }
- }
- }
}
.ci-widget {
@@ -401,12 +367,6 @@
width: 100%;
text-align: center;
}
-
- .accept-control {
- width: 100%;
- text-align: center;
- margin: 0;
- }
}
.commit-message-editor {
@@ -491,14 +451,22 @@
.merge-request {
padding: 10px 0 10px 15px;
position: relative;
- display: -webkit-flex;
display: flex;
.issuable-info-container {
- -webkit-flex: 1;
flex: 1;
}
+ .issuable-meta {
+ .author-link {
+ display: inline-block;
+ }
+
+ .issuable-comments {
+ height: 18px;
+ }
+ }
+
.merge-request-title {
margin-bottom: 2px;
@@ -551,6 +519,10 @@
.mr-links {
padding-left: $status-icon-size + $gl-btn-padding;
+
+ &:last-child {
+ padding-bottom: $gl-padding;
+ }
}
.mr-info-list {
@@ -596,7 +568,6 @@
color: $gl-text-color;
}
-
.git-merge-container {
justify-content: space-between;
flex: 1;
@@ -738,7 +709,6 @@
background: $gray-light;
color: $gl-text-color;
margin-top: -1px;
- border-top: 1px solid $border-color;
.mr-version-menus-container {
display: flex;
@@ -761,6 +731,7 @@
.content-block {
padding: $gl-padding-top $gl-padding;
+ border-bottom: 0;
}
.comments-disabled-notif {
@@ -785,16 +756,18 @@
padding-right: 5px;
}
+ // Shortening button height by 1px to make compare-versions
+ // header 56px and fit into our 8px design grid
+ button {
+ height: 34px;
+ }
+
@include media-breakpoint-up(md) {
position: -webkit-sticky;
position: sticky;
top: $header-height + $mr-tabs-height;
- width: 100%;
-
- &.is-fileTreeOpen {
- margin-left: -16px;
- width: calc(100% + 32px);
- }
+ margin-left: -16px;
+ width: calc(100% + 32px);
.mr-version-menus-container {
flex-wrap: nowrap;
@@ -806,15 +779,16 @@
}
}
-.merge-request-tabs-holder {
+.merge-request-tabs-holder,
+.epic-tabs-holder {
top: $header-height;
- z-index: 300;
+ z-index: 250;
background-color: $white-light;
border-bottom: 1px solid $border-color;
@include media-breakpoint-up(sm) {
- position: sticky;
position: -webkit-sticky;
+ position: sticky;
}
&.affix {
@@ -824,11 +798,6 @@
@include media-breakpoint-down(xs) {
right: 0;
}
-
- .merge-request-tabs-container {
- padding-left: $gl-padding;
- padding-right: $gl-padding;
- }
}
.nav-links {
@@ -836,11 +805,21 @@
}
}
-.with-performance-bar .merge-request-tabs-holder {
- top: $header-height + $performance-bar-height;
+.merge-request-tabs-holder.affix .merge-request-tabs-container,
+.epic-tabs-holder.affix .epic-tabs-container {
+ padding-left: $gl-padding;
+ padding-right: $gl-padding;
+}
+
+.with-performance-bar {
+ .merge-request-tabs-holder,
+ .epic-tabs-holder {
+ top: $header-height + $performance-bar-height;
+ }
}
-.merge-request-tabs {
+.merge-request-tabs,
+.epic-tabs {
display: flex;
flex-wrap: nowrap;
margin-bottom: 0;
@@ -848,7 +827,8 @@
}
.limit-container-width {
- .merge-request-tabs-container {
+ .merge-request-tabs-container,
+ .epic-tabs-container {
max-width: $limited-layout-width;
margin-left: auto;
margin-right: auto;
@@ -861,31 +841,56 @@
}
}
-.merge-request-tabs-container {
+.merge-request-tabs-container,
+.epic-tabs-container {
display: flex;
justify-content: space-between;
- @include media-breakpoint-down(sm) {
- flex-direction: column-reverse;
+ @include media-breakpoint-down(xs) {
+ .discussion-filter-container,
+ .line-resolve-all-container {
+ margin-bottom: $gl-padding-4;
+ }
}
.discussion-filter-container {
- margin-top: $gl-padding-8;
-
&:not(:only-child) {
- padding-right: $gl-padding-8;
+ margin: $gl-padding-4;
}
}
+
+ .merge-request-tabs {
+ height: $grid-size * 6;
+ }
}
-.limit-container-width:not(.container-limited) {
- .merge-request-tabs-holder:not(.affix) {
- .merge-request-tabs-container {
- max-width: $limited-layout-width - ($gl-padding * 2);
+// Wrap MR tabs/buttons so you don't have to scroll on desktop
+@include media-breakpoint-down(md) {
+ .merge-request-tabs-container,
+ .epic-tabs-container {
+ flex-direction: column-reverse;
+ padding-top: $gl-padding-8;
+ }
+}
+
+@include media-breakpoint-down(lg) {
+ .right-sidebar-expanded {
+ .merge-request-tabs-container,
+ .epic-tabs-container {
+ flex-direction: column-reverse;
+ align-items: flex-start;
+ padding-top: $gl-padding-8;
}
}
}
+.limit-container-width:not(.container-limited) {
+ .merge-request-tabs-holder:not(.affix) .merge-request-tabs-container,
+ .epic-tabs-holder:not(.affix) .epic-tabs-container {
+ max-width: $limited-layout-width - ($gl-padding * 2);
+ }
+}
+
.mr-memory-usage {
width: 100%;
@@ -955,10 +960,6 @@
}
}
- .btn svg {
- fill: $gray-700;
- }
-
.dropdown-menu {
width: 400px;
}
@@ -1017,7 +1018,12 @@
background: $black-transparent;
}
-.source-branch-removal-status {
- padding-left: 50px;
- padding-bottom: $gl-padding;
+.mr-compare {
+ .diff-file .file-title-flex-parent {
+ top: $header-height + 51px;
+
+ .with-performance-bar & {
+ top: $performance-bar-height + $header-height + 51px;
+ }
+ }
}
diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss
index 15f3a2ef4a8..49608a3964f 100644
--- a/app/assets/stylesheets/pages/milestone.scss
+++ b/app/assets/stylesheets/pages/milestone.scss
@@ -67,18 +67,14 @@ $status-box-line-height: 26px;
.card-header {
line-height: $line-height-base;
padding: 14px 16px;
- display: -webkit-flex;
display: flex;
.title {
- -webkit-flex: 1;
- -webkit-flex-grow: 1;
flex: 1;
flex-grow: 2;
}
.counter {
- -webkit-flex: 1;
flex: 0;
padding-left: 16px;
}
@@ -239,6 +235,7 @@ $status-box-line-height: 26px;
padding: 0;
}
+ .popover-body,
.popover-content {
padding: 0;
}
diff --git a/app/assets/stylesheets/pages/monitor.scss b/app/assets/stylesheets/pages/monitor.scss
new file mode 100644
index 00000000000..25ff5abd774
--- /dev/null
+++ b/app/assets/stylesheets/pages/monitor.scss
@@ -0,0 +1,5 @@
+.chart-tooltip > .popover {
+ min-width: 0;
+ width: max-content;
+ max-width: $chart-tooltip-max-width;
+}
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 51f755c67af..c6bac33e888 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -58,7 +58,8 @@
border: 1px solid $border-color;
border-radius: $border-radius-base;
transition: border-color ease-in-out 0.15s,
- box-shadow ease-in-out 0.15s;
+ box-shadow ease-in-out 0.15s;
+ background-color: $white-light;
&.is-focused {
@extend .form-control:focus;
@@ -72,7 +73,7 @@
&.is-dropzone-hover {
border-color: $green-500;
box-shadow: 0 0 2px $black-transparent,
- 0 0 4px $green-500-focus;
+ 0 0 4px $green-500-focus;
.comment-toolbar,
.nav-links {
@@ -84,9 +85,7 @@
.md-header .nav-links {
display: flex;
- display: -webkit-flex;
flex-flow: row wrap;
- -webkit-flex-flow: row wrap;
width: 100%;
.float-right {
@@ -105,6 +104,11 @@
margin: auto;
align-items: center;
+ a {
+ color: $orange-600;
+ text-decoration: underline;
+ }
+
.icon {
margin-right: $issuable-warning-icon-margin;
vertical-align: text-bottom;
@@ -170,6 +174,16 @@
.discussion-form {
background-color: $white-light;
+
+ @include media-breakpoint-down(xs) {
+ .user-avatar-link {
+ display: none;
+ }
+
+ .note-edit-form {
+ margin-left: 0;
+ }
+ }
}
table {
@@ -236,13 +250,25 @@ table {
.diff-file,
.commit-diff {
.discussion-reply-holder {
- background-color: $white-light;
+ background-color: $gray-light;
border-radius: 0 0 3px 3px;
padding: $gl-padding;
+ border-top: 1px solid $gray-100;
+
+ + .new-note {
+ background-color: $gray-light;
+ border-top: 1px solid $gray-100;
+ }
&.is-replying {
padding-bottom: $gl-padding;
}
+
+ .user-avatar-link {
+ img {
+ margin-top: -3px;
+ }
+ }
}
}
@@ -336,7 +362,7 @@ table {
.toolbar-button-icon {
position: relative;
top: 1px;
- margin-right: 3px;
+ margin-right: $gl-padding-4;
color: inherit;
font-size: 16px;
}
@@ -444,7 +470,7 @@ table {
.uploading-error-message {
@include media-breakpoint-down(xs) {
&::after {
- content: "\a";
+ content: '\a';
white-space: pre;
}
}
@@ -463,6 +489,15 @@ table {
border: 0;
font-size: 14px;
line-height: 16px;
+
+ &:hover,
+ &:focus {
+ text-decoration: none;
+
+ .text-attach-file {
+ text-decoration: underline;
+ }
+ }
}
.markdown-selector {
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 7e7eff1346a..69dd583bc5b 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -68,7 +68,7 @@ $note-form-margin-left: 72px;
}
}
- .notes_content {
+ .notes-content {
border: 0;
border-top: 1px solid $border-color;
}
@@ -80,21 +80,17 @@ $note-form-margin-left: 72px;
}
}
- li.note {
- border-bottom: 1px solid $border-color;
- }
-
.replies-toggle {
background-color: $gray-light;
padding: $gl-padding-8 $gl-padding;
+ border-top: 1px solid $gray-100;
+ border-bottom: 1px solid $gray-100;
.collapse-replies-btn:hover {
color: $blue-600;
}
&.expanded {
- border-bottom: 1px solid $border-color;
-
span {
cursor: pointer;
}
@@ -107,6 +103,7 @@ $note-form-margin-left: 72px;
&.collapsed {
color: $gl-text-color-secondary;
+ border-radius: 0 0 $border-radius-default $border-radius-default;
svg {
float: left;
@@ -210,8 +207,13 @@ $note-form-margin-left: 72px;
display: none;
}
+ .user-avatar-link img {
+ margin-top: $gl-padding-8;
+ }
+
.note-edit-form {
display: block;
+ margin-left: 0;
&.current-note-edit-form + .note-awards {
display: none;
@@ -224,14 +226,7 @@ $note-form-margin-left: 72px;
overflow-y: hidden;
.note-text {
- @include md-typography;
- // Reset ul style types since we're nested inside a ul already
- @include bulleted-list;
word-wrap: break-word;
-
- table {
- @include markdown-table;
- }
}
}
@@ -270,8 +265,8 @@ $note-form-margin-left: 72px;
}
.system-note {
- padding: 6px 21px;
- margin: $gl-padding-24 0;
+ padding: $gl-padding-4 20px;
+ margin: $gl-padding 0;
background-color: transparent;
.note-header-info {
@@ -283,8 +278,6 @@ $note-form-margin-left: 72px;
}
.system-note-message {
- display: inline;
-
&::first-letter {
text-transform: lowercase;
}
@@ -303,26 +296,6 @@ $note-form-margin-left: 72px;
}
}
- .timeline-icon {
- float: left;
- display: flex;
- align-items: center;
- background-color: $white-light;
- width: $system-note-icon-size;
- height: $system-note-icon-size;
- border: 1px solid $border-color;
- border-radius: $system-note-icon-size;
- margin: -6px $gl-padding 0 0;
-
- svg {
- width: $system-note-svg-size;
- height: $system-note-svg-size;
- fill: $gray-darkest;
- display: block;
- margin: 0 auto;
- }
- }
-
.timeline-content {
@include notes-media('min', map-get($grid-breakpoints, sm)) {
margin-left: 30px;
@@ -380,6 +353,37 @@ $note-form-margin-left: 72px;
}
}
}
+
+ .system-note,
+ .discussion-filter-note {
+ .timeline-icon {
+ float: left;
+ display: flex;
+ align-items: center;
+ background-color: $white-light;
+ width: $system-note-icon-size;
+ height: $system-note-icon-size;
+ border: 1px solid $border-color;
+ border-radius: $system-note-icon-size;
+ margin: -6px 20px 0 0;
+
+ svg {
+ width: $system-note-svg-size;
+ height: $system-note-svg-size;
+ fill: $gray-darkest;
+ display: block;
+ margin: 0 auto;
+ }
+ }
+ }
+
+ .discussion-filter-note {
+ .timeline-icon {
+ width: $system-note-icon-size + 6;
+ height: $system-note-icon-size + 6;
+ margin-top: -8px;
+ }
+ }
}
// Diff code in discussion view
@@ -434,7 +438,9 @@ $note-form-margin-left: 72px;
.diff-file {
.is-over {
.add-diff-note {
- display: inline-block;
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
}
}
@@ -451,7 +457,7 @@ $note-form-margin-left: 72px;
// Merge request notes in diffs
// Diff is inline
- .notes_content .note-header .note-headline-light {
+ .notes-content .note-header .note-headline-light {
display: inline-block;
position: relative;
}
@@ -463,12 +469,17 @@ $note-form-margin-left: 72px;
border: 1px solid $border-color;
border-left: 0;
- &.notes_content {
+ &.notes-content {
border-width: 1px 0;
padding: 0;
vertical-align: top;
white-space: normal;
+ // Fixes subpixel rounding issue https://gitlab.com/gitlab-org/gitlab-ce/issues/53973
+ // background-color is needed for dark code preference
+ padding-bottom: 1px;
+ background-color: $white-light;
+
&.parallel {
border-width: 1px;
@@ -509,12 +520,30 @@ $note-form-margin-left: 72px;
}
}
-.commit-diff {
- .notes_content {
- background-color: $white-light;
+.code-commit .notes-content,
+.diff-viewer > .image ~ .note-container {
+ background-color: $white-light;
+
+ .avatar-note-form-holder {
+ .user-avatar-link img {
+ margin: 13px $gl-padding $gl-padding;
+ }
+
+ form,
+ ~ .discussion-form-container {
+ padding: $gl-padding;
+
+ @include media-breakpoint-up(sm) {
+ margin-left: $note-icon-gutter-width;
+ }
+ }
}
}
+.diff-viewer > .image ~ .note-container form.new-note {
+ margin-left: 0;
+}
+
.discussion-header,
.note-header-info {
a {
@@ -540,7 +569,7 @@ $note-form-margin-left: 72px;
}
.discussion-header {
- min-height: 72px;
+ min-height: 74px;
.note-header-info {
padding-bottom: 0;
@@ -553,8 +582,10 @@ $note-form-margin-left: 72px;
}
.unresolved {
- .note-header-info {
- margin-top: $gl-padding-8;
+ .discussion-header {
+ .note-header-info {
+ margin-top: $gl-padding-8;
+ }
}
}
@@ -596,12 +627,6 @@ $note-form-margin-left: 72px;
}
.note-headline-meta {
- display: inline-block;
-
- .system-note-message {
- white-space: normal;
- }
-
.system-note-separator {
color: $gl-text-color-disabled;
}
@@ -643,7 +668,7 @@ $note-form-margin-left: 72px;
display: inline-flex;
align-items: center;
margin-left: 10px;
- color: $gray-darkest;
+ color: $gray-600;
@include notes-media('max', map-get($grid-breakpoints, sm) - 1) {
float: none;
@@ -739,7 +764,7 @@ $note-form-margin-left: 72px;
.add-diff-note {
@include btn-comment-icon;
opacity: 0;
- margin-left: -55px;
+ margin-left: -52px;
position: absolute;
top: 50%;
transform: translateY(-50%);
@@ -758,15 +783,13 @@ $note-form-margin-left: 72px;
background-color: $white-light;
}
- a {
+ a:not(.learn-more) {
color: $blue-600;
}
}
.line-resolve-all-container {
- @include notes-media('min', map-get($grid-breakpoints, sm)) {
- margin-right: 0;
- }
+ margin: $gl-padding-4;
> div {
white-space: nowrap;
@@ -782,6 +805,8 @@ $note-form-margin-left: 72px;
}
.btn {
+ line-height: $gl-line-height;
+
svg {
fill: $gray-darkest;
}
@@ -807,10 +832,11 @@ $note-form-margin-left: 72px;
.line-resolve-all {
vertical-align: middle;
display: inline-block;
- padding: 6px 10px;
+ padding: $gl-padding-4 10px;
background-color: $gray-light;
border: 1px solid $border-color;
border-radius: $border-radius-default;
+ font-size: $gl-btn-small-font-size;
&.has-next-btn {
border-top-right-radius: 0;
@@ -820,11 +846,16 @@ $note-form-margin-left: 72px;
.line-resolve-btn {
margin-right: 5px;
+ color: $gray-darkest;
svg {
vertical-align: middle;
}
}
+
+ @include media-breakpoint-down(xs) {
+ flex: 1;
+ }
}
.line-resolve-btn {
@@ -834,7 +865,6 @@ $note-form-margin-left: 72px;
background-color: transparent;
border: 0;
outline: 0;
- color: $gray-darkest;
transition: color $general-hover-transition-duration $general-hover-transition-curve;
&.is-disabled {
@@ -898,14 +928,9 @@ $note-form-margin-left: 72px;
.diff-comment-form {
display: block;
}
-
- .add-diff-note svg {
- margin-top: 4px;
- }
}
.discussion-filter-container {
-
.btn > svg {
width: $gl-col-padding;
height: $gl-col-padding;
@@ -927,7 +952,6 @@ $note-form-margin-left: 72px;
//This needs to be deleted when Snippet/Commit comments are convered to Vue
// See https://gitlab.com/gitlab-org/gitlab-ce/issues/53918#note_117038785
.unstyled-comments {
-
.discussion-header {
padding: $gl-padding;
border-bottom: 1px solid $border-color;
diff --git a/app/assets/stylesheets/pages/notifications.scss b/app/assets/stylesheets/pages/notifications.scss
index e98cb444f0a..e1cbf0e6654 100644
--- a/app/assets/stylesheets/pages/notifications.scss
+++ b/app/assets/stylesheets/pages/notifications.scss
@@ -4,6 +4,34 @@
.dropdown-menu {
@extend .dropdown-menu-right;
}
+
+ @include media-breakpoint-down(sm) {
+ .notification-dropdown {
+ width: 100%;
+ }
+
+ .notification-form {
+ display: block;
+ }
+
+ .notifications-btn,
+ .btn-group {
+ width: 100%;
+ }
+
+ .table-section {
+ border-top: 0;
+ min-height: unset;
+
+ &:not(:first-child) {
+ padding-top: 0;
+ }
+ }
+
+ .update-notifications {
+ width: 100%;
+ }
+ }
}
.notification {
diff --git a/app/assets/stylesheets/pages/pipeline_schedules.scss b/app/assets/stylesheets/pages/pipeline_schedules.scss
index 617b3db2fae..85c4902eee2 100644
--- a/app/assets/stylesheets/pages/pipeline_schedules.scss
+++ b/app/assets/stylesheets/pages/pipeline_schedules.scss
@@ -1,9 +1,4 @@
-.js-pipeline-schedule-form {
- .dropdown-select,
- .dropdown-menu-toggle {
- width: 100% !important;
- }
-
+.pipeline-schedule-form {
.gl-field-error {
margin: 10px 0 0;
}
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index e676d48c1f4..aa6bbc8e473 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -341,13 +341,15 @@
&.builds .ci-table tr {
height: 71px;
}
-}
-.build-failures {
- th {
- border-top: 0;
+ .ci-table {
+ thead th {
+ border-top: 0;
+ }
}
+}
+.build-failures {
.build-state {
padding: 20px 2px;
@@ -496,7 +498,8 @@
list-style: none;
}
- &:last-child {
+ // when downstream pipelines are present, the last stage isn't the last column
+ &:last-child:not(.has-downstream) {
.build {
// Remove right connecting horizontal line from first build in last stage
&:first-child::after {
@@ -513,7 +516,8 @@
}
}
- &:first-child {
+ // when upstream pipelines are present, the first stage isn't the first column
+ &:first-child:not(.has-upstream) {
.build {
// Remove left curved connectors from all builds in first stage
&:not(:first-child)::before {
@@ -561,6 +565,7 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+ line-height: 2.2em;
}
.build {
@@ -697,6 +702,11 @@
}
}
}
+
+ .stage-action svg {
+ left: 1px;
+ top: -2px;
+ }
}
// Triggers the dropdown in the big pipeline graph
@@ -708,15 +718,9 @@
top: 8px;
}
+.ci-build-text,
.ci-status-text {
- max-width: 110px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- vertical-align: bottom;
- display: inline-block;
- position: relative;
- font-weight: $gl-font-weight-normal;
+ font-weight: 200;
}
@mixin mini-pipeline-graph-color(
@@ -787,10 +791,11 @@
}
&.ci-status-icon-pending,
- &.ci-status-icon-success_with_warnings {
+ &.ci-status-icon-success-with-warnings {
@include mini-pipeline-graph-color($white, $orange-100, $orange-200, $orange-500, $orange-600, $orange-700);
}
+ &.ci-status-icon-preparing,
&.ci-status-icon-running {
@include mini-pipeline-graph-color($white, $blue-100, $blue-200, $blue-500, $blue-600, $blue-700);
}
@@ -898,7 +903,7 @@ button.mini-pipeline-graph-dropdown-toggle {
// Match dropdown.scss for all `a` tags
&.non-details-job-component {
- padding: 8px 16px;
+ padding: $gl-padding-8 $gl-btn-horz-padding;
}
.ci-job-name-component {
@@ -907,26 +912,6 @@ button.mini-pipeline-graph-dropdown-toggle {
flex: 1;
}
- // build name
- .ci-build-text,
- .ci-status-text {
- font-weight: 200;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- max-width: 70%;
- margin-left: 2px;
- display: inline-block;
-
- &::after {
- content: '';
- display: block;
- }
-
- @include media-breakpoint-down(xs) {
- max-width: 60%;
- }
- }
.ci-status-icon {
@extend .append-right-8;
@@ -994,7 +979,6 @@ button.mini-pipeline-graph-dropdown-toggle {
* Top arrow in the dropdown in the mini pipeline graph
*/
.mini-pipeline-graph-dropdown-menu {
-
&::before,
&::after {
content: '';
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index a1e847009fc..87cef43b923 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -266,23 +266,6 @@
padding-top: 20px;
}
- .cover-controls {
- position: static;
- padding: 0 16px;
- margin-bottom: 20px;
- display: -webkit-flex;
- display: flex;
-
- .btn {
- -webkit-flex-grow: 1;
- flex-grow: 1;
-
- &:first-child {
- margin-left: 0;
- }
- }
- }
-
.user-profile-nav {
a {
margin-right: 0;
@@ -322,6 +305,7 @@ table.u2f-registrations {
margin: 20px -5px 0;
.bordered-box {
+ padding: 32px;
border: 1px solid $blue-300;
border-radius: $border-radius-default;
background-color: $blue-50;
@@ -455,8 +439,17 @@ table.u2f-registrations {
}
}
+ .form-group > label {
+ font-weight: $gl-font-weight-bold;
+ }
+
+ .form-group > .form-text {
+ font-size: $gl-font-size;
+ }
+
.emoji-menu-toggle-button {
@include emoji-menu-toggle-button;
+ padding: 6px 10px;
.no-emoji-placeholder {
position: relative;
@@ -478,3 +471,41 @@ table.u2f-registrations {
.help-block {
color: $gl-text-color-secondary;
}
+
+.gitlab-slack-gif {
+ width: 100%;
+ max-width: $add-to-slack-gif-max-width;
+}
+
+.gitlab-slack-well {
+ background-color: $white-light;
+ box-shadow: none;
+ max-width: $add-to-slack-well-max-width;
+}
+
+.gitlab-slack-logo {
+ width: $add-to-slack-logo-size;
+ height: $add-to-slack-logo-size;
+}
+
+.gitlab-slack-popup {
+ width: 100%;
+ max-width: $add-to-slack-popup-max-width;
+}
+
+.gitlab-slack-right-arrow svg {
+ fill: $white-dark;
+ width: $right-arrow-size;
+ height: $right-arrow-size;
+ vertical-align: text-bottom;
+}
+
+.gitlab-slack-double-headed-arrow {
+ vertical-align: text-top;
+
+ svg {
+ fill: $gray-darker;
+ width: $double-headed-arrow-width;
+ height: $double-headed-arrow-height;
+ }
+}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 277030ad3af..151af843c95 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -18,12 +18,9 @@
}
.input-group {
- display: flex;
-
.select2-container {
display: unset;
max-width: unset;
- width: unset !important;
flex-grow: 1;
}
@@ -70,6 +67,10 @@
}
}
+.classification-label {
+ background-color: $red-500;
+}
+
.toggle-wrapper {
margin-top: 5px;
}
@@ -212,8 +213,7 @@
}
}
- .access-request-link,
- .home-panel-topic-list {
+ .access-request-link {
padding-left: $gl-padding-8;
border-left: 1px solid $gl-text-color-secondary;
}
@@ -571,9 +571,7 @@
.import-buttons {
padding-left: 0;
- display: -webkit-flex;
display: flex;
- -webkit-flex-wrap: wrap;
flex-wrap: wrap;
.btn {
@@ -695,10 +693,6 @@
}
}
-.project-empty-note-panel {
- border-bottom: 1px solid $border-color;
-}
-
.project-stats,
.project-buttons {
.scrolling-tabs-container {
@@ -1168,6 +1162,8 @@ pre.light-well {
.cannot-be-merged:hover {
color: $red-500;
margin-top: 2px;
+ position: relative;
+ z-index: 2;
}
.private-forks-notice .private-fork-icon {
@@ -1450,3 +1446,86 @@ pre.light-well {
}
}
}
+
+.project-filters {
+ .btn svg {
+ color: $gl-gray-700;
+ }
+
+ .button-filter-group {
+ .btn {
+ width: 96px;
+ }
+
+ a {
+ color: $black;
+ }
+
+ .active {
+ background: $btn-active-gray;
+ }
+ }
+
+ .filtered-search-dropdown-label {
+ min-width: 68px;
+
+ @include media-breakpoint-down(xs) {
+ min-width: 60px;
+ }
+ }
+
+ .filtered-search {
+ min-width: 30%;
+ flex-basis: 0;
+
+ .project-filter-form .project-filter-form-field {
+ padding-right: $gl-padding-8;
+ }
+
+ .filtered-search,
+ .filtered-search-nav,
+ .filtered-search-dropdown {
+ flex-basis: 0;
+ }
+
+ @include media-breakpoint-down(lg) {
+ min-width: 15%;
+
+ .project-filter-form-field {
+ min-width: 150px;
+ }
+ }
+
+ @include media-breakpoint-down(md) {
+ min-width: 30%;
+ }
+ }
+
+ .filtered-search-box {
+ border-radius: 3px 0 0 3px;
+ }
+
+ .dropdown-menu-toggle {
+ margin-left: $gl-padding-8;
+ }
+
+ @include media-breakpoint-down(md) {
+ .extended-filtered-search-box {
+ min-width: 55%;
+ }
+
+ .filtered-search-dropdown {
+ width: 50%;
+
+ .dropdown-menu-toggle {
+ width: 100%;
+ }
+ }
+ }
+
+ @include media-breakpoint-down(xs) {
+ .filtered-search-dropdown {
+ width: 100%;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss
new file mode 100644
index 00000000000..c03554b287f
--- /dev/null
+++ b/app/assets/stylesheets/pages/prometheus.scss
@@ -0,0 +1,270 @@
+.prometheus-graphs {
+ .dropdowns {
+ .dropdown-menu-toggle {
+ svg {
+ position: absolute;
+ right: 5%;
+ top: 25%;
+ }
+ }
+
+ .dropdown-menu-toggle,
+ .dropdown-menu {
+ width: 240px;
+ }
+ }
+}
+
+.prometheus-panel {
+ margin-top: 20px;
+}
+
+.prometheus-graph-group {
+ display: flex;
+ flex-wrap: wrap;
+ padding: $gl-padding / 2;
+}
+
+.prometheus-graph {
+ padding: $gl-padding / 2;
+}
+
+.prometheus-graph-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: $gl-padding-8;
+
+ h5 {
+ font-size: $gl-font-size-large;
+ margin: 0;
+ }
+}
+
+.prometheus-graph-cursor {
+ position: absolute;
+ background: $gray-600;
+ width: 1px;
+}
+
+.prometheus-graph-flag {
+ display: block;
+ min-width: 160px;
+ border: 0;
+ box-shadow: 0 1px 4px 0 $black-transparent;
+
+ h5 {
+ padding: 0;
+ margin: 0;
+ font-size: 14px;
+ line-height: 1.2;
+ }
+
+ .deploy-meta-content {
+ border-bottom: 1px solid $white-dark;
+
+ svg {
+ height: 15px;
+ vertical-align: bottom;
+ }
+ }
+
+ &.popover {
+ padding: 0;
+
+ &.left {
+ left: auto;
+ right: 0;
+ margin-right: 10px;
+
+ > .arrow {
+ right: -14px;
+ border-left-color: $border-color;
+ }
+
+ > .arrow::after {
+ border-top: 6px solid transparent;
+ border-bottom: 6px solid transparent;
+ border-left: 4px solid $gray-50;
+ }
+
+ .arrow-shadow {
+ right: -3px;
+ box-shadow: 1px 0 9px 0 $black-transparent;
+ }
+ }
+
+ &.right {
+ left: 0;
+ right: auto;
+ margin-left: 10px;
+
+ > .arrow {
+ left: -7px;
+ border-right-color: $border-color;
+ }
+
+ > .arrow::after {
+ border-top: 6px solid transparent;
+ border-bottom: 6px solid transparent;
+ border-right: 4px solid $gray-50;
+ }
+
+ .arrow-shadow {
+ left: -3px;
+ box-shadow: 1px 0 8px 0 $black-transparent;
+ }
+ }
+
+ > .arrow {
+ top: 10px;
+ margin: 0;
+ }
+
+ .arrow-shadow {
+ content: '';
+ position: absolute;
+ width: 7px;
+ height: 7px;
+ background-color: transparent;
+ transform: rotate(45deg);
+ top: 13px;
+ }
+
+ > .popover-title,
+ > .popover-content,
+ > .popover-header,
+ > .popover-body {
+ padding: 8px;
+ font-size: 12px;
+ white-space: nowrap;
+ position: relative;
+ }
+
+ > .popover-title {
+ background-color: $gray-50;
+ border-radius: $border-radius-default $border-radius-default 0 0;
+ }
+ }
+
+ strong {
+ font-weight: 600;
+ }
+}
+
+.prometheus-table {
+ border-collapse: collapse;
+ padding: 0;
+ margin: 0;
+
+ td {
+ vertical-align: middle;
+
+ + td {
+ padding-left: 8px;
+ vertical-align: top;
+ }
+ }
+
+ .legend-metric-title {
+ font-size: 12px;
+ vertical-align: middle;
+ }
+}
+
+.prometheus-svg-container {
+ position: relative;
+ height: 0;
+ width: 100%;
+ padding: 0;
+ padding-bottom: 100%;
+
+ .text-metric-usage {
+ fill: $black;
+ font-weight: $gl-font-weight-normal;
+ font-size: 12px;
+ }
+
+ > svg {
+ position: absolute;
+ height: 100%;
+ width: 100%;
+ left: 0;
+ top: 0;
+
+ text {
+ fill: $gl-text-color;
+ stroke-width: 0;
+ }
+
+ .text-metric-bold {
+ font-weight: $gl-font-weight-bold;
+ }
+
+ .label-axis-text {
+ fill: $black;
+ font-weight: $gl-font-weight-normal;
+ font-size: 10px;
+ }
+
+ .legend-axis-text {
+ fill: $black;
+ }
+
+ .tick {
+ > line {
+ stroke: $gray-darker;
+ }
+
+ > text {
+ fill: $gray-600;
+ font-size: 10px;
+ }
+ }
+
+ .y-label-text,
+ .x-label-text {
+ fill: $gray-darkest;
+ }
+
+ .axis-tick {
+ stroke: $gray-darker;
+ }
+
+ .deploy-info-text {
+ dominant-baseline: text-before-edge;
+ font-size: 12px;
+ }
+
+ .deploy-info-text-link {
+ font-family: $monospace-font;
+ fill: $blue-600;
+
+ &:hover {
+ fill: $blue-800;
+ }
+ }
+
+ @include media-breakpoint-down(sm) {
+ .label-axis-text,
+ .text-metric-usage,
+ .legend-axis-text {
+ font-size: 8px;
+ }
+
+ .tick > text {
+ font-size: 8px;
+ }
+ }
+ }
+}
+
+.prometheus-table-row-highlight {
+ background-color: $gray-100;
+}
+
+.prometheus-graph-overlay {
+ fill: none;
+ opacity: 0;
+ pointer-events: all;
+}
diff --git a/app/assets/stylesheets/pages/reports.scss b/app/assets/stylesheets/pages/reports.scss
index f7619ccbd20..94da72622af 100644
--- a/app/assets/stylesheets/pages/reports.scss
+++ b/app/assets/stylesheets/pages/reports.scss
@@ -52,11 +52,6 @@
.report-block-list-icon .loading-container {
position: relative;
left: -2px;
- // needed to make the next element align with the
- // elements below that have a svg with 16px width
- .fa-spinner {
- width: 16px;
- }
}
}
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index 149c3254d84..dbf600df9d6 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -75,6 +75,8 @@ input[type='checkbox']:hover {
}
.search-input-wrap {
+ width: 100%;
+
.search-icon,
.clear-icon {
position: absolute;
@@ -87,6 +89,7 @@ input[type='checkbox']:hover {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
+ user-select: none;
}
.clear-icon {
@@ -185,13 +188,11 @@ input[type='checkbox']:hover {
.search-holder {
@include media-breakpoint-up(sm) {
- display: -webkit-flex;
display: flex;
}
.search-field-holder,
.project-filter-form {
- -webkit-flex: 1 0 auto;
flex: 1 0 auto;
position: relative;
margin-right: 0;
@@ -260,3 +261,13 @@ input[type='checkbox']:hover {
color: $blue-600;
}
}
+
+// Disable webkit input icons, link to solution: https://stackoverflow.com/questions/9421551/how-do-i-remove-all-default-webkit-search-field-styling
+/* stylelint-disable property-no-vendor-prefix */
+input[type='search']::-webkit-search-decoration,
+input[type='search']::-webkit-search-cancel-button,
+input[type='search']::-webkit-search-results-button,
+input[type='search']::-webkit-search-results-decoration {
+ -webkit-appearance: none;
+}
+/* stylelint-enable */
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index 811cc310a8f..0a9c56f5625 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -23,7 +23,10 @@
}
.settings {
- border-bottom: 1px solid $gray-darker;
+ // border-top for each item except the top one
+ + .settings {
+ border-top: 1px solid $border-color;
+ }
&:first-of-type {
margin-top: 10px;
@@ -36,7 +39,7 @@
.settings-header {
position: relative;
- padding: 20px 110px 10px 0;
+ padding: 20px 110px 0 0;
h4 {
margin-top: 0;
@@ -213,6 +216,31 @@
}
}
+.nested-settings {
+ padding-left: 20px;
+}
+
+.input-btn-group {
+ display: flex;
+
+ .input-large {
+ flex: 1;
+ }
+
+ .btn {
+ margin-left: 10px;
+ }
+}
+
+.content-list > .settings-flex-row {
+ display: flex;
+ align-items: center;
+
+ .float-right {
+ margin-left: auto;
+ }
+}
+
.prometheus-metrics-monitoring {
.card {
.card-toggle {
@@ -243,6 +271,27 @@
}
}
+ .custom-monitored-metrics {
+ .card-title {
+ display: flex;
+ align-items: center;
+
+ > .btn-success {
+ margin-left: auto;
+ }
+ }
+
+ .custom-metric {
+ display: flex;
+ align-items: center;
+ }
+
+ .custom-metric-link-bold {
+ font-weight: $gl-font-weight-bold;
+ text-decoration: none;
+ }
+ }
+
.loading-metrics,
.empty-metrics {
padding: 30px 10px;
@@ -277,6 +326,12 @@
}
}
+.saml-settings.info-well {
+ .form-control[readonly] {
+ background: $white-light;
+ }
+}
+
.modal-doorkeepr-auth {
.modal-body {
padding: $gl-padding;
@@ -316,8 +371,4 @@
.push-pull-table {
margin-top: 1em;
-
- .mirror-action-buttons {
- padding-right: 0;
- }
}
diff --git a/app/assets/stylesheets/pages/stat_graph.scss b/app/assets/stylesheets/pages/stat_graph.scss
index d331edaa302..31ccdacbc02 100644
--- a/app/assets/stylesheets/pages/stat_graph.scss
+++ b/app/assets/stylesheets/pages/stat_graph.scss
@@ -18,21 +18,15 @@
@include make-col-ready();
@include make-col(12);
}
-
- svg {
- width: 100%;
- }
}
#contributors {
+ flex: 1;
+
.contributors-list {
margin: 0 0 10px;
list-style: none;
padding: 0;
-
- svg {
- width: 100%;
- }
}
.person {
diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss
index 7d59dd3b5d1..613f643af3a 100644
--- a/app/assets/stylesheets/pages/status.scss
+++ b/app/assets/stylesheets/pages/status.scss
@@ -33,17 +33,18 @@
border-color: $gl-text-color;
&:not(span):hover {
- background-color: rgba($gl-text-color, .07);
+ background-color: rgba($gl-text-color, 0.07);
}
}
&.ci-pending,
- &.ci-failed_with_warnings,
- &.ci-success_with_warnings {
+ &.ci-failed-with-warnings,
+ &.ci-success-with-warnings {
@include status-color($orange-100, $orange-500, $orange-700);
}
&.ci-info,
+ &.ci-preparing,
&.ci-running {
@include status-color($blue-100, $blue-500, $blue-600);
}
@@ -54,7 +55,7 @@
border-color: $gl-text-color-secondary;
&:not(span):hover {
- background-color: rgba($gl-text-color-secondary, .07);
+ background-color: rgba($gl-text-color-secondary, 0.07);
}
}
}
diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss
index 3fc37e20c36..586365eb1ce 100644
--- a/app/assets/stylesheets/pages/todos.scss
+++ b/app/assets/stylesheets/pages/todos.scss
@@ -6,9 +6,7 @@
.todos-list > .todo {
// workaround because we cannot use border-colapse
border-top: 1px solid transparent;
- display: -webkit-flex;
display: flex;
- -webkit-flex-direction: row;
flex-direction: row;
&:hover {
@@ -29,23 +27,18 @@
.todo-avatar,
.todo-actions {
@include transition(opacity);
- -webkit-flex: 0 0 auto;
flex: 0 0 auto;
}
.todo-actions {
- display: -webkit-flex;
display: flex;
- -webkit-justify-content: center;
justify-content: center;
- -webkit-flex-direction: column;
flex-direction: column;
margin-left: 10px;
min-width: 55px;
}
.todo-item {
- -webkit-flex: 0 1 100%;
flex: 0 1 100%;
min-width: 0;
}
@@ -60,13 +53,13 @@
.todo-avatar,
.todo-item {
- opacity: .6;
+ opacity: 0.6;
}
}
.todo-avatar,
.todo-item {
- opacity: .2;
+ opacity: 0.2;
}
.btn {
@@ -82,7 +75,6 @@
display: flex;
> .title-item {
- -webkit-flex: 0 0 auto;
flex: 0 0 auto;
margin: 0 2px;
@@ -96,7 +88,6 @@
}
.todo-label {
- -webkit-flex: 0 1 auto;
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
@@ -119,45 +110,38 @@
}
.todo-body {
- .todo-note {
- word-wrap: break-word;
-
- .md {
- color: $gl-grayish-blue;
- font-size: $gl-font-size;
-
- .badge.badge-pill {
- color: $gl-text-color;
- }
+ .badge.badge-pill,
+ p {
+ color: $gl-text-color;
+ }
- p {
- color: $gl-text-color;
- }
- }
+ .md {
+ color: $gl-grayish-blue;
+ font-size: $gl-font-size;
+ }
- code {
- white-space: pre-wrap;
- }
+ code {
+ white-space: pre-wrap;
+ }
- pre {
- border: 0;
- background: $gray-light;
- border-radius: 0;
- color: $gl-gray-500;
- margin: 0 20px;
- overflow: hidden;
- }
+ pre {
+ border: 0;
+ background: $gray-light;
+ border-radius: 0;
+ color: $gl-gray-500;
+ margin: 0 20px;
+ overflow: hidden;
+ }
- .note-image-attach {
- margin-top: 4px;
- margin-left: 0;
- max-width: 200px;
- float: none;
- }
+ .note-image-attach {
+ margin-top: 4px;
+ margin-left: 0;
+ max-width: 200px;
+ float: none;
+ }
- p:last-child {
- margin-bottom: 0;
- }
+ p:last-child {
+ margin-bottom: 0;
}
}
}
@@ -222,23 +206,19 @@
}
.todos-empty {
- display: -webkit-flex;
display: flex;
- -webkit-flex-direction: column;
flex-direction: column;
max-width: 900px;
margin-left: auto;
margin-right: auto;
@include media-breakpoint-up(sm) {
- -webkit-flex-direction: row;
flex-direction: row;
padding-top: 80px;
}
}
.todos-empty-content {
- -webkit-align-self: center;
align-self: center;
max-width: 480px;
margin-right: 20px;
@@ -252,7 +232,6 @@
@include media-breakpoint-up(sm) {
width: 300px;
margin-right: 0;
- -webkit-order: 2;
order: 2;
}
}
diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss
index a46b8679a42..5664f46484e 100644
--- a/app/assets/stylesheets/pages/tree.scss
+++ b/app/assets/stylesheets/pages/tree.scss
@@ -172,26 +172,6 @@
text-decoration: inherit;
}
}
-
- .tree_commit {
- max-width: 320px;
-
- .str-truncated {
- max-width: 100%;
- }
- }
-
- .tree_time_ago {
- min-width: 135px;
- }
- }
-
- .tree_author {
- padding-right: 8px;
-
- .commit-author-name {
- color: $gl-text-color;
- }
}
.tree-truncated-warning {
diff --git a/app/assets/stylesheets/pages/ui_dev_kit.scss b/app/assets/stylesheets/pages/ui_dev_kit.scss
index 84c617c7ec0..7744fd814d0 100644
--- a/app/assets/stylesheets/pages/ui_dev_kit.scss
+++ b/app/assets/stylesheets/pages/ui_dev_kit.scss
@@ -10,7 +10,7 @@
margin-bottom: 15px;
&::before {
- content: "Example";
+ content: 'Example';
color: $ui-dev-kit-example-color;
}
}
diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss
index 82e887aa62a..3260aed143e 100644
--- a/app/assets/stylesheets/pages/wiki.scss
+++ b/app/assets/stylesheets/pages/wiki.scss
@@ -179,9 +179,3 @@ ul.wiki-pages-list.content-list {
}
}
}
-
-.wiki:not(.use-csslab) {
- table {
- @include markdown-table;
- }
-}
diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss
index 9c01a2f8bda..5a8940ffd6d 100644
--- a/app/assets/stylesheets/performance_bar.scss
+++ b/app/assets/stylesheets/performance_bar.scss
@@ -79,8 +79,12 @@
table {
color: $black;
- strong {
- color: $black;
+ td {
+ vertical-align: top;
+ }
+
+ .backtrace-row {
+ display: none;
}
}
diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss
index bb10928a037..9ed1600419d 100644
--- a/app/assets/stylesheets/print.scss
+++ b/app/assets/stylesheets/print.scss
@@ -1,21 +1,21 @@
-.wiki h1,
-.wiki h2,
-.wiki h3,
-.wiki h4,
-.wiki h5,
-.wiki h6 {
+.md h1,
+.md h2,
+.md h3,
+.md h4,
+.md h5,
+.md h6 {
margin-top: 17px;
}
-.wiki h1 {
+.md h1 {
font-size: 30px;
}
-.wiki h2 {
+.md h2 {
font-size: 22px;
}
-.wiki h3 {
+.md h3 {
font-size: 18px;
font-weight: 600;
}
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
new file mode 100644
index 00000000000..3648ec5e239
--- /dev/null
+++ b/app/assets/stylesheets/utilities.scss
@@ -0,0 +1,17 @@
+@each $variant, $range in $color-ranges {
+ @each $suffix, $color in $range {
+ #{'.bg-#{$variant}-#{$suffix}'} {
+ background-color: $color;
+ }
+
+ #{'.text-#{$variant}-#{$suffix}'} {
+ color: $color;
+ }
+ }
+}
+
+@each $index, $size in $type-scale {
+ #{'.text-#{$index}'} {
+ font-size: $size;
+ }
+}
diff --git a/app/assets/stylesheets/vendors/atwho.scss b/app/assets/stylesheets/vendors/atwho.scss
new file mode 100644
index 00000000000..ccf3824ea56
--- /dev/null
+++ b/app/assets/stylesheets/vendors/atwho.scss
@@ -0,0 +1,92 @@
+.atwho-view {
+ overflow-y: auto;
+ overflow-x: hidden;
+
+ .name,
+ small.aliases,
+ small.params {
+ float: left;
+ }
+
+ small.aliases,
+ small.params {
+ padding: 2px 5px;
+ }
+
+ small.description {
+ float: right;
+ padding: 3px 5px;
+ }
+
+ .avatar-inline {
+ margin-bottom: 0;
+ }
+
+ .has-warning {
+ .name,
+ .description {
+ color: $orange-700;
+ }
+ }
+
+ .cur {
+ .avatar {
+ @include disable-all-animation;
+ border: 1px solid $white-light;
+ }
+ }
+
+ ul > li {
+ @include clearfix;
+ white-space: nowrap;
+ }
+
+ // TODO: fallback to global style
+ .atwho-view-ul {
+ padding: 8px 1px;
+
+ li {
+ padding: 8px 16px;
+ border: 0;
+
+ &.cur {
+ background-color: $gray-darker;
+ color: $gl-text-color;
+
+ small {
+ color: inherit;
+ }
+
+ &.has-warning {
+ color: $orange-700;
+ background-color: $orange-100;
+ }
+ }
+
+ div.avatar {
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+
+ .center {
+ line-height: 14px;
+ }
+ }
+
+ strong {
+ color: $gl-text-color;
+ }
+ }
+ }
+}
+
+@include media-breakpoint-down(xs) {
+ .atwho-view-ul {
+ width: 350px;
+ }
+
+ .atwho-view ul li {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
diff --git a/app/controllers/abuse_reports_controller.rb b/app/controllers/abuse_reports_controller.rb
index 68e14f0c2e5..7d8016f763d 100644
--- a/app/controllers/abuse_reports_controller.rb
+++ b/app/controllers/abuse_reports_controller.rb
@@ -16,7 +16,7 @@ class AbuseReportsController < ApplicationController
if @abuse_report.save
@abuse_report.notify
- message = "Thank you for your report. A GitLab administrator will look into it shortly."
+ message = _("Thank you for your report. A GitLab administrator will look into it shortly.")
redirect_to @abuse_report.user, notice: message
else
render :new
@@ -37,9 +37,9 @@ class AbuseReportsController < ApplicationController
@user = User.find_by(id: params[:user_id])
if @user.nil?
- redirect_to root_path, alert: "Cannot create the abuse report. The user has been deleted."
+ redirect_to root_path, alert: _("Cannot create the abuse report. The user has been deleted.")
elsif @user.blocked?
- redirect_to @user, alert: "Cannot create the abuse report. This user has been blocked."
+ redirect_to @user, alert: _("Cannot create the abuse report. This user has been blocked.")
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/acme_challenges_controller.rb b/app/controllers/acme_challenges_controller.rb
new file mode 100644
index 00000000000..67a39d8870b
--- /dev/null
+++ b/app/controllers/acme_challenges_controller.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AcmeChallengesController < ActionController::Base
+ def show
+ if acme_order
+ render plain: acme_order.challenge_file_content, content_type: 'text/plain'
+ else
+ head :not_found
+ end
+ end
+
+ private
+
+ def acme_order
+ @acme_order ||= PagesDomainAcmeOrder.find_by_domain_and_token(params[:domain], params[:token])
+ end
+end
diff --git a/app/controllers/admin/appearances_controller.rb b/app/controllers/admin/appearances_controller.rb
index 2b9cae21da2..383ec2a7d16 100644
--- a/app/controllers/admin/appearances_controller.rb
+++ b/app/controllers/admin/appearances_controller.rb
@@ -14,7 +14,7 @@ class Admin::AppearancesController < Admin::ApplicationController
@appearance = Appearance.new(appearance_params)
if @appearance.save
- redirect_to admin_appearances_path, notice: 'Appearance was successfully created.'
+ redirect_to admin_appearances_path, notice: _('Appearance was successfully created.')
else
render action: 'show'
end
@@ -22,7 +22,7 @@ class Admin::AppearancesController < Admin::ApplicationController
def update
if @appearance.update(appearance_params)
- redirect_to admin_appearances_path, notice: 'Appearance was successfully updated.'
+ redirect_to admin_appearances_path, notice: _('Appearance was successfully updated.')
else
render action: 'show'
end
@@ -33,21 +33,21 @@ class Admin::AppearancesController < Admin::ApplicationController
@appearance.save
- redirect_to admin_appearances_path, notice: 'Logo was successfully removed.'
+ redirect_to admin_appearances_path, notice: _('Logo was successfully removed.')
end
def header_logos
@appearance.remove_header_logo!
@appearance.save
- redirect_to admin_appearances_path, notice: 'Header logo was successfully removed.'
+ redirect_to admin_appearances_path, notice: _('Header logo was successfully removed.')
end
def favicon
@appearance.remove_favicon!
@appearance.save
- redirect_to admin_appearances_path, notice: 'Favicon was successfully removed.'
+ redirect_to admin_appearances_path, notice: _('Favicon was successfully removed.')
end
private
@@ -78,6 +78,7 @@ class Admin::AppearancesController < Admin::ApplicationController
footer_message
message_background_color
message_font_color
+ email_header_and_footer_enabled
]
end
end
diff --git a/app/controllers/admin/application_controller.rb b/app/controllers/admin/application_controller.rb
index ef182b981f1..b742b7e19cf 100644
--- a/app/controllers/admin/application_controller.rb
+++ b/app/controllers/admin/application_controller.rb
@@ -4,10 +4,7 @@
#
# Automatically sets the layout and ensures an administrator is logged in
class Admin::ApplicationController < ApplicationController
- before_action :authenticate_admin!
- layout 'admin'
+ include EnforcesAdminAuthentication
- def authenticate_admin!
- render_404 unless current_user.admin?
- end
+ layout 'admin'
end
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 8f267eccc8a..d5bc723aa8c 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -48,7 +48,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
respond_to do |format|
if successful
format.json { head :ok }
- format.html { redirect_to redirect_path, notice: 'Application settings saved successfully' }
+ format.html { redirect_to redirect_path, notice: _('Application settings saved successfully') }
else
format.json { head :bad_request }
format.html { render :show }
@@ -70,13 +70,13 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
def reset_registration_token
@application_setting.reset_runners_registration_token!
- flash[:notice] = 'New runners registration token has been generated!'
+ flash[:notice] = _('New runners registration token has been generated!')
redirect_to admin_runners_path
end
def reset_health_check_token
@application_setting.reset_health_check_access_token!
- flash[:notice] = 'New health check access token has been generated!'
+ flash[:notice] = _('New health check access token has been generated!')
redirect_back_or_default
end
@@ -85,10 +85,17 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
redirect_to(
admin_application_settings_path,
- notice: 'Started asynchronous removal of all repository check states.'
+ notice: _('Started asynchronous removal of all repository check states.')
)
end
+ # Getting ToS url requires `directory` api call to Let's Encrypt
+ # which could result in 500 error/slow rendering on settings page
+ # Because of that we use separate controller action
+ def lets_encrypt_terms_of_service
+ redirect_to ::Gitlab::LetsEncrypt.terms_of_service_url
+ end
+
private
def set_application_setting
@@ -124,7 +131,10 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
end
def visible_application_setting_attributes
- ApplicationSettingsHelper.visible_attributes + [
+ [
+ *::ApplicationSettingsHelper.visible_attributes,
+ *::ApplicationSettingsHelper.external_authorization_service_attributes,
+ *lets_encrypt_visible_attributes,
:domain_blacklist_file,
disabled_oauth_sign_in_sources: [],
import_sources: [],
@@ -132,4 +142,13 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
restricted_visibility_levels: []
]
end
+
+ def lets_encrypt_visible_attributes
+ return [] unless Feature.enabled?(:pages_auto_ssl)
+
+ [
+ :lets_encrypt_notification_email,
+ :lets_encrypt_terms_of_service_accepted
+ ]
+ end
end
diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb
index 6fc336714b6..3648c8be426 100644
--- a/app/controllers/admin/applications_controller.rb
+++ b/app/controllers/admin/applications_controller.rb
@@ -34,7 +34,7 @@ class Admin::ApplicationsController < Admin::ApplicationController
def update
if @application.update(application_params)
- redirect_to admin_application_path(@application), notice: 'Application was successfully updated.'
+ redirect_to admin_application_path(@application), notice: _('Application was successfully updated.')
else
render :edit
end
@@ -42,7 +42,7 @@ class Admin::ApplicationsController < Admin::ApplicationController
def destroy
@application.destroy
- redirect_to admin_applications_url, status: 302, notice: 'Application was successfully destroyed.'
+ redirect_to admin_applications_url, status: 302, notice: _('Application was successfully destroyed.')
end
private
diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb
index a91d9a534cd..6e5dd1a1f55 100644
--- a/app/controllers/admin/broadcast_messages_controller.rb
+++ b/app/controllers/admin/broadcast_messages_controller.rb
@@ -19,7 +19,7 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController
@broadcast_message = BroadcastMessage.new(broadcast_message_params)
if @broadcast_message.save
- redirect_to admin_broadcast_messages_path, notice: 'Broadcast Message was successfully created.'
+ redirect_to admin_broadcast_messages_path, notice: _('Broadcast Message was successfully created.')
else
render :index
end
@@ -27,7 +27,7 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController
def update
if @broadcast_message.update(broadcast_message_params)
- redirect_to admin_broadcast_messages_path, notice: 'Broadcast Message was successfully updated.'
+ redirect_to admin_broadcast_messages_path, notice: _('Broadcast Message was successfully updated.')
else
render :edit
end
diff --git a/app/controllers/admin/clusters/applications_controller.rb b/app/controllers/admin/clusters/applications_controller.rb
new file mode 100644
index 00000000000..7400cc16175
--- /dev/null
+++ b/app/controllers/admin/clusters/applications_controller.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Admin::Clusters::ApplicationsController < Clusters::ApplicationsController
+ include EnforcesAdminAuthentication
+
+ private
+
+ def clusterable
+ @clusterable ||= InstanceClusterablePresenter.fabricate(Clusters::Instance.new, current_user: current_user)
+ end
+end
diff --git a/app/controllers/admin/clusters_controller.rb b/app/controllers/admin/clusters_controller.rb
new file mode 100644
index 00000000000..f54933de10f
--- /dev/null
+++ b/app/controllers/admin/clusters_controller.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class Admin::ClustersController < Clusters::ClustersController
+ include EnforcesAdminAuthentication
+
+ layout 'admin'
+
+ private
+
+ def clusterable
+ @clusterable ||= InstanceClusterablePresenter.fabricate(Clusters::Instance.new, current_user: current_user)
+ end
+end
diff --git a/app/controllers/admin/deploy_keys_controller.rb b/app/controllers/admin/deploy_keys_controller.rb
index 49ce275ad14..180f7d4c803 100644
--- a/app/controllers/admin/deploy_keys_controller.rb
+++ b/app/controllers/admin/deploy_keys_controller.rb
@@ -25,7 +25,7 @@ class Admin::DeployKeysController < Admin::ApplicationController
def update
if deploy_key.update(update_params)
- flash[:notice] = 'Deploy key was successfully updated.'
+ flash[:notice] = _('Deploy key was successfully updated.')
redirect_to admin_deploy_keys_path
else
render 'edit'
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index 46e85e1424f..15f7ef881c8 100644
--- a/app/controllers/admin/groups_controller.rb
+++ b/app/controllers/admin/groups_controller.rb
@@ -36,7 +36,7 @@ class Admin::GroupsController < Admin::ApplicationController
if @group.save
@group.add_owner(current_user)
- redirect_to [:admin, @group], notice: "Group '#{@group.name}' was successfully created."
+ redirect_to [:admin, @group], notice: _('Group %{group_name} was successfully created.') % { group_name: @group.name }
else
render "new"
end
@@ -44,7 +44,7 @@ class Admin::GroupsController < Admin::ApplicationController
def update
if @group.update(group_params)
- redirect_to [:admin, @group], notice: 'Group was successfully updated.'
+ redirect_to [:admin, @group], notice: _('Group was successfully updated.')
else
render "edit"
end
@@ -55,7 +55,7 @@ class Admin::GroupsController < Admin::ApplicationController
result = Members::CreateService.new(current_user, member_params.merge(limit: -1)).execute(@group)
if result[:status] == :success
- redirect_to [:admin, @group], notice: 'Users were successfully added.'
+ redirect_to [:admin, @group], notice: _('Users were successfully added.')
else
redirect_to [:admin, @group], alert: result[:message]
end
@@ -66,7 +66,7 @@ class Admin::GroupsController < Admin::ApplicationController
redirect_to admin_groups_path,
status: 302,
- alert: "Group '#{@group.name}' was scheduled for deletion."
+ alert: _('Group %{group_name} was scheduled for deletion.') % { group_name: @group.name }
end
private
@@ -89,7 +89,8 @@ class Admin::GroupsController < Admin::ApplicationController
:request_access_enabled,
:visibility_level,
:require_two_factor_authentication,
- :two_factor_grace_period
+ :two_factor_grace_period,
+ :project_creation_level
]
end
end
diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb
index d0abdec50ae..51b0f45c5be 100644
--- a/app/controllers/admin/hooks_controller.rb
+++ b/app/controllers/admin/hooks_controller.rb
@@ -14,7 +14,7 @@ class Admin::HooksController < Admin::ApplicationController
@hook = SystemHook.new(hook_params.to_h)
if @hook.save
- redirect_to admin_hooks_path, notice: 'Hook was successfully created.'
+ redirect_to admin_hooks_path, notice: _('Hook was successfully created.')
else
@hooks = SystemHook.all
render :index
@@ -26,7 +26,7 @@ class Admin::HooksController < Admin::ApplicationController
def update
if hook.update(hook_params)
- flash[:notice] = 'System hook was successfully updated.'
+ flash[:notice] = _('System hook was successfully updated.')
redirect_to admin_hooks_path
else
render 'edit'
diff --git a/app/controllers/admin/identities_controller.rb b/app/controllers/admin/identities_controller.rb
index b51c2f678ca..f518f7a657f 100644
--- a/app/controllers/admin/identities_controller.rb
+++ b/app/controllers/admin/identities_controller.rb
@@ -13,7 +13,7 @@ class Admin::IdentitiesController < Admin::ApplicationController
@identity.user_id = user.id
if @identity.save
- redirect_to admin_user_identities_path(@user), notice: 'User identity was successfully created.'
+ redirect_to admin_user_identities_path(@user), notice: _('User identity was successfully created.')
else
render :new
end
@@ -29,7 +29,7 @@ class Admin::IdentitiesController < Admin::ApplicationController
def update
if @identity.update(identity_params)
RepairLdapBlockedUserService.new(@user).execute
- redirect_to admin_user_identities_path(@user), notice: 'User identity was successfully updated.'
+ redirect_to admin_user_identities_path(@user), notice: _('User identity was successfully updated.')
else
render :edit
end
@@ -38,9 +38,9 @@ class Admin::IdentitiesController < Admin::ApplicationController
def destroy
if @identity.destroy
RepairLdapBlockedUserService.new(@user).execute
- redirect_to admin_user_identities_path(@user), status: 302, notice: 'User identity was successfully removed.'
+ redirect_to admin_user_identities_path(@user), status: 302, notice: _('User identity was successfully removed.')
else
- redirect_to admin_user_identities_path(@user), status: 302, alert: 'Failed to remove user identity.'
+ redirect_to admin_user_identities_path(@user), status: 302, alert: _('Failed to remove user identity.')
end
end
diff --git a/app/controllers/admin/impersonation_tokens_controller.rb b/app/controllers/admin/impersonation_tokens_controller.rb
index 706bcc1e549..c35619a944e 100644
--- a/app/controllers/admin/impersonation_tokens_controller.rb
+++ b/app/controllers/admin/impersonation_tokens_controller.rb
@@ -12,7 +12,7 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController
if @impersonation_token.save
PersonalAccessToken.redis_store!(current_user.id, @impersonation_token.token)
- redirect_to admin_user_impersonation_tokens_path, notice: "A new impersonation token has been created."
+ redirect_to admin_user_impersonation_tokens_path, notice: _("A new impersonation token has been created.")
else
set_index_vars
render :index
@@ -23,9 +23,9 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController
@impersonation_token = finder.find(params[:id])
if @impersonation_token.revoke!
- flash[:notice] = "Revoked impersonation token #{@impersonation_token.name}!"
+ flash[:notice] = _("Revoked impersonation token %{token_name}!") % { token_name: @impersonation_token.name }
else
- flash[:alert] = "Could not revoke impersonation token #{@impersonation_token.name}."
+ flash[:alert] = _("Could not revoke impersonation token %{token_name}.") % { token_name: @impersonation_token.name }
end
redirect_to admin_user_impersonation_tokens_path
@@ -49,7 +49,7 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def set_index_vars
- @scopes = Gitlab::Auth.available_scopes(current_user)
+ @scopes = Gitlab::Auth.available_scopes_for(current_user)
@impersonation_token ||= finder.build
@inactive_impersonation_tokens = finder(state: 'inactive').execute
diff --git a/app/controllers/admin/keys_controller.rb b/app/controllers/admin/keys_controller.rb
index 4e9262ccc96..340eecd7632 100644
--- a/app/controllers/admin/keys_controller.rb
+++ b/app/controllers/admin/keys_controller.rb
@@ -17,9 +17,9 @@ class Admin::KeysController < Admin::ApplicationController
respond_to do |format|
if key.destroy
- format.html { redirect_to keys_admin_user_path(user), status: 302, notice: 'User key was successfully removed.' }
+ format.html { redirect_to keys_admin_user_path(user), status: 302, notice: _('User key was successfully removed.') }
else
- format.html { redirect_to keys_admin_user_path(user), status: 302, alert: 'Failed to remove user key.' }
+ format.html { redirect_to keys_admin_user_path(user), status: 302, alert: _('Failed to remove user key.') }
end
end
end
diff --git a/app/controllers/admin/labels_controller.rb b/app/controllers/admin/labels_controller.rb
index aa5eae7a474..90c1694fd2e 100644
--- a/app/controllers/admin/labels_controller.rb
+++ b/app/controllers/admin/labels_controller.rb
@@ -21,7 +21,7 @@ class Admin::LabelsController < Admin::ApplicationController
@label = Labels::CreateService.new(label_params).execute(template: true)
if @label.persisted?
- redirect_to admin_labels_url, notice: "Label was created"
+ redirect_to admin_labels_url, notice: _("Label was created")
else
render :new
end
@@ -31,7 +31,7 @@ class Admin::LabelsController < Admin::ApplicationController
@label = Labels::UpdateService.new(label_params).execute(@label)
if @label.valid?
- redirect_to admin_labels_path, notice: 'Label was successfully updated.'
+ redirect_to admin_labels_path, notice: _('Label was successfully updated.')
else
render :edit
end
@@ -43,7 +43,7 @@ class Admin::LabelsController < Admin::ApplicationController
respond_to do |format|
format.html do
- redirect_to admin_labels_path, status: 302, notice: 'Label was removed'
+ redirect_to admin_labels_path, status: 302, notice: _('Label was removed')
end
format.js
end
diff --git a/app/controllers/admin/logs_controller.rb b/app/controllers/admin/logs_controller.rb
index 06b0e6a15a3..704e727b1da 100644
--- a/app/controllers/admin/logs_controller.rb
+++ b/app/controllers/admin/logs_controller.rb
@@ -15,7 +15,8 @@ class Admin::LogsController < Admin::ApplicationController
Gitlab::EnvironmentLogger,
Gitlab::SidekiqLogger,
Gitlab::RepositoryCheckLogger,
- Gitlab::ProjectServiceLogger
+ Gitlab::ProjectServiceLogger,
+ Gitlab::Kubernetes::Logger
]
end
end
diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb
index 550f29a58d2..70db15916b9 100644
--- a/app/controllers/admin/projects_controller.rb
+++ b/app/controllers/admin/projects_controller.rb
@@ -3,7 +3,7 @@
class Admin::ProjectsController < Admin::ApplicationController
include MembersPresentation
- before_action :project, only: [:show, :transfer, :repository_check]
+ before_action :project, only: [:show, :transfer, :repository_check, :destroy]
before_action :group, only: [:show, :transfer]
def index
@@ -15,7 +15,7 @@ class Admin::ProjectsController < Admin::ApplicationController
format.html
format.json do
render json: {
- html: view_to_html_string("admin/projects/_projects", locals: { projects: @projects })
+ html: view_to_html_string("admin/projects/_projects", projects: @projects)
}
end
end
@@ -35,12 +35,21 @@ class Admin::ProjectsController < Admin::ApplicationController
end
# rubocop: enable CodeReuse/ActiveRecord
+ def destroy
+ ::Projects::DestroyService.new(@project, current_user, {}).async_execute
+ flash[:notice] = _("Project '%{project_name}' is in the process of being deleted.") % { project_name: @project.full_name }
+
+ redirect_to admin_projects_path, status: :found
+ rescue Projects::DestroyService::DestroyError => ex
+ redirect_to admin_projects_path, status: 302, alert: ex.message
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def transfer
namespace = Namespace.find_by(id: params[:new_namespace_id])
::Projects::TransferService.new(@project, current_user, params.dup).execute(namespace)
- @project.reload
+ @project.reset
redirect_to admin_project_path(@project)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -50,7 +59,7 @@ class Admin::ProjectsController < Admin::ApplicationController
redirect_to(
admin_project_path(@project),
- notice: 'Repository check was triggered.'
+ notice: _('Repository check was triggered.')
)
end
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index 0b6ff491c66..783c59822f1 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Admin::RunnersController < Admin::ApplicationController
- before_action :runner, except: :index
+ before_action :runner, except: [:index, :tag_list]
def index
finder = Admin::RunnersFinder.new(params: params)
@@ -34,20 +34,26 @@ class Admin::RunnersController < Admin::ApplicationController
def resume
if Ci::UpdateRunnerService.new(@runner).update(active: true)
- redirect_to admin_runners_path, notice: 'Runner was successfully updated.'
+ redirect_to admin_runners_path, notice: _('Runner was successfully updated.')
else
- redirect_to admin_runners_path, alert: 'Runner was not updated.'
+ redirect_to admin_runners_path, alert: _('Runner was not updated.')
end
end
def pause
if Ci::UpdateRunnerService.new(@runner).update(active: false)
- redirect_to admin_runners_path, notice: 'Runner was successfully updated.'
+ redirect_to admin_runners_path, notice: _('Runner was successfully updated.')
else
- redirect_to admin_runners_path, alert: 'Runner was not updated.'
+ redirect_to admin_runners_path, alert: _('Runner was not updated.')
end
end
+ def tag_list
+ tags = Autocomplete::ActsAsTaggableOn::TagsFinder.new(params: params).execute
+
+ render json: ActsAsTaggableOn::TagSerializer.new.represent(tags)
+ end
+
private
def runner
diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb
index 18d22c95b61..45cf0d3207e 100644
--- a/app/controllers/admin/spam_logs_controller.rb
+++ b/app/controllers/admin/spam_logs_controller.rb
@@ -14,7 +14,7 @@ class Admin::SpamLogsController < Admin::ApplicationController
spam_log.remove_user(deleted_by: current_user)
redirect_to admin_spam_logs_path,
status: 302,
- notice: "User #{spam_log.user.username} was successfully removed."
+ notice: _('User %{username} was successfully removed.') % { username: spam_log.user.username }
else
spam_log.destroy
head :ok
@@ -25,9 +25,9 @@ class Admin::SpamLogsController < Admin::ApplicationController
spam_log = SpamLog.find(params[:id])
if HamService.new(spam_log).mark_as_ham!
- redirect_to admin_spam_logs_path, notice: 'Spam log successfully submitted as ham.'
+ redirect_to admin_spam_logs_path, notice: _('Spam log successfully submitted as ham.')
else
- redirect_to admin_spam_logs_path, alert: 'Error with Akismet. Please check the logs for more info.'
+ redirect_to admin_spam_logs_path, alert: _('Error with Akismet. Please check the logs for more info.')
end
end
end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index bfa7c7d0109..a02d0843615 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -39,19 +39,19 @@ class Admin::UsersController < Admin::ApplicationController
warden.set_user(user, scope: :user)
- Gitlab::AppLogger.info("User #{current_user.username} has started impersonating #{user.username}")
+ Gitlab::AppLogger.info(_("User %{current_user_username} has started impersonating %{username}") % { current_user_username: current_user.username, username: user.username })
- flash[:alert] = "You are now impersonating #{user.username}"
+ flash[:alert] = _("You are now impersonating %{username}") % { username: user.username }
redirect_to root_path
else
flash[:alert] =
if user.blocked?
- "You cannot impersonate a blocked user"
+ _("You cannot impersonate a blocked user")
elsif user.internal?
- "You cannot impersonate an internal user"
+ _("You cannot impersonate an internal user")
else
- "You cannot impersonate a user who cannot log in"
+ _("You cannot impersonate a user who cannot log in")
end
redirect_to admin_user_path(user)
@@ -60,35 +60,35 @@ class Admin::UsersController < Admin::ApplicationController
def block
if update_user { |user| user.block }
- redirect_back_or_admin_user(notice: "Successfully blocked")
+ redirect_back_or_admin_user(notice: _("Successfully blocked"))
else
- redirect_back_or_admin_user(alert: "Error occurred. User was not blocked")
+ redirect_back_or_admin_user(alert: _("Error occurred. User was not blocked"))
end
end
def unblock
if user.ldap_blocked?
- redirect_back_or_admin_user(alert: "This user cannot be unlocked manually from GitLab")
+ redirect_back_or_admin_user(alert: _("This user cannot be unlocked manually from GitLab"))
elsif update_user { |user| user.activate }
- redirect_back_or_admin_user(notice: "Successfully unblocked")
+ redirect_back_or_admin_user(notice: _("Successfully unblocked"))
else
- redirect_back_or_admin_user(alert: "Error occurred. User was not unblocked")
+ redirect_back_or_admin_user(alert: _("Error occurred. User was not unblocked"))
end
end
def unlock
if update_user { |user| user.unlock_access! }
- redirect_back_or_admin_user(alert: "Successfully unlocked")
+ redirect_back_or_admin_user(alert: _("Successfully unlocked"))
else
- redirect_back_or_admin_user(alert: "Error occurred. User was not unlocked")
+ redirect_back_or_admin_user(alert: _("Error occurred. User was not unlocked"))
end
end
def confirm
if update_user { |user| user.confirm }
- redirect_back_or_admin_user(notice: "Successfully confirmed")
+ redirect_back_or_admin_user(notice: _("Successfully confirmed"))
else
- redirect_back_or_admin_user(alert: "Error occurred. User was not confirmed")
+ redirect_back_or_admin_user(alert: _("Error occurred. User was not confirmed"))
end
end
@@ -96,7 +96,7 @@ class Admin::UsersController < Admin::ApplicationController
update_user { |user| user.disable_two_factor! }
redirect_to admin_user_path(user),
- notice: 'Two-factor Authentication has been disabled for this user'
+ notice: _('Two-factor Authentication has been disabled for this user')
end
def create
@@ -109,7 +109,7 @@ class Admin::UsersController < Admin::ApplicationController
respond_to do |format|
if @user.persisted?
- format.html { redirect_to [:admin, @user], notice: 'User was successfully created.' }
+ format.html { redirect_to [:admin, @user], notice: _('User was successfully created.') }
format.json { render json: @user, status: :created, location: @user }
else
format.html { render "new" }
@@ -138,7 +138,7 @@ class Admin::UsersController < Admin::ApplicationController
end
if result[:status] == :success
- format.html { redirect_to [:admin, user], notice: 'User was successfully updated.' }
+ format.html { redirect_to [:admin, user], notice: _('User was successfully updated.') }
format.json { head :ok }
else
# restore username to keep form action url.
@@ -153,7 +153,7 @@ class Admin::UsersController < Admin::ApplicationController
user.delete_async(deleted_by: current_user, params: params.permit(:hard_delete))
respond_to do |format|
- format.html { redirect_to admin_users_path, status: 302, notice: "The user is being deleted." }
+ format.html { redirect_to admin_users_path, status: 302, notice: _("The user is being deleted.") }
format.json { head :ok }
end
end
@@ -164,11 +164,11 @@ class Admin::UsersController < Admin::ApplicationController
respond_to do |format|
if success
- format.html { redirect_back_or_admin_user(notice: 'Successfully removed email.') }
+ format.html { redirect_back_or_admin_user(notice: _('Successfully removed email.')) }
format.json { head :ok }
else
- format.html { redirect_back_or_admin_user(alert: 'There was an error removing the e-mail.') }
- format.json { render json: 'There was an error removing the e-mail.', status: :bad_request }
+ format.html { redirect_back_or_admin_user(alert: _('There was an error removing the e-mail.')) }
+ format.json { render json: _('There was an error removing the e-mail.'), status: :bad_request }
end
end
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index af0b0c64814..7321f719deb 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -27,6 +27,7 @@ class ApplicationController < ActionController::Base
before_action :check_impersonation_availability
around_action :set_locale
+ around_action :set_session_storage
after_action :set_page_title_header, if: :json_request?
after_action :limit_unauthenticated_session_times
@@ -41,9 +42,12 @@ class ApplicationController < ActionController::Base
:bitbucket_server_import_enabled?,
:google_code_import_enabled?, :fogbugz_import_enabled?,
:git_import_enabled?, :gitlab_project_import_enabled?,
- :manifest_import_enabled?
+ :manifest_import_enabled?, :phabricator_import_enabled?
+ # Adds `no-store` to the DEFAULT_CACHE_CONTROL, to prevent security
+ # concerns due to caching private data.
DEFAULT_GITLAB_CACHE_CONTROL = "#{ActionDispatch::Http::Cache::Response::DEFAULT_CACHE_CONTROL}, no-store".freeze
+ DEFAULT_GITLAB_CONTROL_NO_CACHE = "#{DEFAULT_GITLAB_CACHE_CONTROL}, no-cache".freeze
rescue_from Encoding::CompatibilityError do |exception|
log_exception(exception)
@@ -125,7 +129,7 @@ class ApplicationController < ActionController::Base
payload[:ua] = request.env["HTTP_USER_AGENT"]
payload[:remote_ip] = request.remote_ip
- payload[Gitlab::CorrelationId::LOG_KEY] = Gitlab::CorrelationId.current_id
+ payload[Labkit::Correlation::CorrelationId::LOG_KEY] = Labkit::Correlation::CorrelationId.current_id
logged_user = auth_user
@@ -235,9 +239,9 @@ class ApplicationController < ActionController::Base
end
def no_cache_headers
- response.headers["Cache-Control"] = "no-cache, no-store, max-age=0, must-revalidate"
- response.headers["Pragma"] = "no-cache"
- response.headers["Expires"] = "Fri, 01 Jan 1990 00:00:00 GMT"
+ headers['Cache-Control'] = DEFAULT_GITLAB_CONTROL_NO_CACHE
+ headers['Pragma'] = 'no-cache' # HTTP 1.0 compatibility
+ headers['Expires'] = 'Fri, 01 Jan 1990 00:00:00 GMT'
end
def default_headers
@@ -247,10 +251,16 @@ class ApplicationController < ActionController::Base
headers['X-Content-Type-Options'] = 'nosniff'
if current_user
- # Adds `no-store` to the DEFAULT_CACHE_CONTROL, to prevent security
- # concerns due to caching private data.
- headers['Cache-Control'] = DEFAULT_GITLAB_CACHE_CONTROL
- headers["Pragma"] = "no-cache" # HTTP 1.0 compatibility
+ headers['Cache-Control'] = default_cache_control
+ headers['Pragma'] = 'no-cache' # HTTP 1.0 compatibility
+ end
+ end
+
+ def default_cache_control
+ if request.xhr?
+ ActionDispatch::Http::Cache::Response::DEFAULT_CACHE_CONTROL
+ else
+ DEFAULT_GITLAB_CACHE_CONTROL
end
end
@@ -284,7 +294,7 @@ class ApplicationController < ActionController::Base
unless Gitlab::Auth::LDAP::Access.allowed?(current_user)
sign_out current_user
- flash[:alert] = "Access denied for your LDAP account."
+ flash[:alert] = _("Access denied for your LDAP account.")
redirect_to new_user_session_path
end
end
@@ -331,7 +341,7 @@ class ApplicationController < ActionController::Base
def require_email
if current_user && current_user.temp_oauth_email? && session[:impersonator_id].nil?
- return redirect_to profile_path, notice: 'Please complete your profile with email address'
+ return redirect_to profile_path, notice: _('Please complete your profile with email address')
end
end
@@ -414,6 +424,10 @@ class ApplicationController < ActionController::Base
Group.supports_nested_objects? && Gitlab::CurrentSettings.import_sources.include?('manifest')
end
+ def phabricator_import_enabled?
+ Gitlab::PhabricatorImport.available?
+ end
+
# U2F (universal 2nd factor) devices need a unique identifier for the application
# to perform authentication.
# https://developers.yubico.com/U2F/App_ID.html
@@ -425,6 +439,12 @@ class ApplicationController < ActionController::Base
Gitlab::I18n.with_user_locale(current_user, &block)
end
+ def set_session_storage(&block)
+ return yield if sessionless_user?
+
+ Gitlab::Session.with_session(session, &block)
+ end
+
def set_page_title_header
# Per https://tools.ietf.org/html/rfc5987, headers need to be ISO-8859-1, not UTF-8
response.headers['Page-Title'] = URI.escape(page_title('GitLab'))
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index 0d5c8657c9e..091327931c2 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class AutocompleteController < ApplicationController
- skip_before_action :authenticate_user!, only: [:users, :award_emojis]
+ skip_before_action :authenticate_user!, only: [:users, :award_emojis, :merge_request_target_branches]
def users
project = Autocomplete::ProjectFinder
@@ -38,4 +38,11 @@ class AutocompleteController < ApplicationController
def award_emojis
render json: AwardedEmojiFinder.new(current_user).execute
end
+
+ def merge_request_target_branches
+ merge_requests = MergeRequestsFinder.new(current_user, params).execute
+ target_branches = merge_requests.recent_target_branches
+
+ render json: target_branches.map { |target_branch| { title: target_branch } }
+ end
end
diff --git a/app/controllers/clusters/applications_controller.rb b/app/controllers/clusters/applications_controller.rb
index c4e7fc950f9..16c2365f85d 100644
--- a/app/controllers/clusters/applications_controller.rb
+++ b/app/controllers/clusters/applications_controller.rb
@@ -3,26 +3,54 @@
class Clusters::ApplicationsController < Clusters::BaseController
before_action :cluster
before_action :authorize_create_cluster!, only: [:create]
+ before_action :authorize_update_cluster!, only: [:update]
+ before_action :authorize_admin_cluster!, only: [:destroy]
def create
- Clusters::Applications::CreateService
- .new(@cluster, current_user, create_cluster_application_params)
- .execute(request)
+ request_handler do
+ Clusters::Applications::CreateService
+ .new(@cluster, current_user, cluster_application_params)
+ .execute(request)
+ end
+ end
+
+ def update
+ request_handler do
+ Clusters::Applications::UpdateService
+ .new(@cluster, current_user, cluster_application_params)
+ .execute(request)
+ end
+ end
+
+ def destroy
+ request_handler do
+ Clusters::Applications::DestroyService
+ .new(@cluster, current_user, cluster_application_destroy_params)
+ .execute(request)
+ end
+ end
+
+ private
+
+ def request_handler
+ yield
head :no_content
- rescue Clusters::Applications::CreateService::InvalidApplicationError
+ rescue Clusters::Applications::BaseService::InvalidApplicationError
render_404
rescue StandardError
head :bad_request
end
- private
-
def cluster
@cluster ||= clusterable.clusters.find(params[:id]) || render_404
end
- def create_cluster_application_params
+ def cluster_application_params
params.permit(:application, :hostname, :email)
end
+
+ def cluster_application_destroy_params
+ params.permit(:application)
+ end
end
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index 68a2a83f0de..80ee7c35906 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -123,25 +123,25 @@ class Clusters::ClustersController < Clusters::BaseController
private
def update_params
- if cluster.managed?
+ if cluster.provided_by_user?
params.require(:cluster).permit(
:enabled,
+ :name,
:environment_scope,
:base_domain,
platform_kubernetes_attributes: [
+ :api_url,
+ :token,
+ :ca_cert,
:namespace
]
)
else
params.require(:cluster).permit(
:enabled,
- :name,
:environment_scope,
:base_domain,
platform_kubernetes_attributes: [
- :api_url,
- :token,
- :ca_cert,
:namespace
]
)
@@ -153,6 +153,7 @@ class Clusters::ClustersController < Clusters::BaseController
:enabled,
:name,
:environment_scope,
+ :managed,
provider_gcp_attributes: [
:gcp_project_id,
:zone,
@@ -171,6 +172,7 @@ class Clusters::ClustersController < Clusters::BaseController
:enabled,
:name,
:environment_scope,
+ :managed,
platform_kubernetes_attributes: [
:namespace,
:api_url,
diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb
index 5507328f8ae..4926062f9ca 100644
--- a/app/controllers/concerns/authenticates_with_two_factor.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor.rb
@@ -8,13 +8,6 @@
module AuthenticatesWithTwoFactor
extend ActiveSupport::Concern
- included do
- # This action comes from DeviseController, but because we call `sign_in`
- # manually, not skipping this action would cause a "You are already signed
- # in." error message to be shown upon successful login.
- skip_before_action :require_no_authentication, only: [:create], raise: false
- end
-
# Store the user's ID in the session for later retrieval and render the
# two factor code prompt
#
@@ -36,7 +29,7 @@ module AuthenticatesWithTwoFactor
end
def locked_user_redirect(user)
- flash.now[:alert] = 'Invalid Login or password'
+ flash.now[:alert] = _('Invalid Login or password')
render 'devise/sessions/new'
end
@@ -66,7 +59,7 @@ module AuthenticatesWithTwoFactor
else
user.increment_failed_attempts!
Gitlab::AppLogger.info("Failed Login: user=#{user.username} ip=#{request.remote_ip} method=OTP")
- flash.now[:alert] = 'Invalid two-factor code.'
+ flash.now[:alert] = _('Invalid two-factor code.')
prompt_for_two_factor(user)
end
end
@@ -83,7 +76,7 @@ module AuthenticatesWithTwoFactor
else
user.increment_failed_attempts!
Gitlab::AppLogger.info("Failed Login: user=#{user.username} ip=#{request.remote_ip} method=U2F")
- flash.now[:alert] = 'Authentication via U2F device failed.'
+ flash.now[:alert] = _('Authentication via U2F device failed.')
prompt_for_two_factor(user)
end
end
diff --git a/app/controllers/concerns/boards_actions.rb b/app/controllers/concerns/boards_actions.rb
new file mode 100644
index 00000000000..ed7ea2f0e04
--- /dev/null
+++ b/app/controllers/concerns/boards_actions.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module BoardsActions
+ include Gitlab::Utils::StrongMemoize
+ extend ActiveSupport::Concern
+
+ included do
+ include BoardsResponses
+
+ before_action :boards, only: :index
+ before_action :board, only: :show
+ end
+
+ def index
+ respond_with_boards
+ end
+
+ def show
+ # Add / update the board in the recent visits table
+ Boards::Visits::CreateService.new(parent, current_user).execute(board) if request.format.html?
+
+ respond_with_board
+ end
+
+ private
+
+ def boards
+ strong_memoize(:boards) do
+ Boards::ListService.new(parent, current_user).execute
+ end
+ end
+
+ def board
+ strong_memoize(:board) do
+ boards.find(params[:id])
+ end
+ end
+end
diff --git a/app/controllers/concerns/continue_params.rb b/app/controllers/concerns/continue_params.rb
index f0e6adf4dec..54c0510497f 100644
--- a/app/controllers/concerns/continue_params.rb
+++ b/app/controllers/concerns/continue_params.rb
@@ -6,7 +6,7 @@ module ContinueParams
def continue_params
continue_params = params[:continue]
- return nil unless continue_params
+ return unless continue_params
continue_params = continue_params.permit(:to, :notice, :notice_now)
continue_params[:to] = safe_redirect_path(continue_params[:to])
diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb
index b3777fd2b0f..e8e681ce649 100644
--- a/app/controllers/concerns/creates_commit.rb
+++ b/app/controllers/concerns/creates_commit.rb
@@ -31,7 +31,7 @@ module CreatesCommit
respond_to do |format|
format.html { redirect_to success_path }
- format.json { render json: { message: "success", filePath: success_path } }
+ format.json { render json: { message: _("success"), filePath: success_path } }
end
else
flash[:alert] = result[:message]
@@ -45,7 +45,7 @@ module CreatesCommit
redirect_to failure_path
end
end
- format.json { render json: { message: "failed", filePath: failure_path } }
+ format.json { render json: { message: _("failed"), filePath: failure_path } }
end
end
end
@@ -60,15 +60,22 @@ module CreatesCommit
private
def update_flash_notice(success_notice)
- flash[:notice] = success_notice || "Your changes have been successfully committed."
+ flash[:notice] = success_notice || _("Your changes have been successfully committed.")
if create_merge_request?
- if merge_request_exists?
- flash[:notice] = nil
- else
- target = different_project? ? "project" : "branch"
- flash[:notice] = flash[:notice] + " You can now submit a merge request to get this change into the original #{target}."
- end
+ flash[:notice] =
+ if merge_request_exists?
+ nil
+ else
+ mr_message =
+ if different_project?
+ _("You can now submit a merge request to get this change into the original project.")
+ else
+ _("You can now submit a merge request to get this change into the original branch.")
+ end
+
+ flash[:notice] += " " + mr_message
+ end
end
end
diff --git a/app/controllers/concerns/enforces_admin_authentication.rb b/app/controllers/concerns/enforces_admin_authentication.rb
new file mode 100644
index 00000000000..3ef92730df6
--- /dev/null
+++ b/app/controllers/concerns/enforces_admin_authentication.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+# == EnforcesAdminAuthentication
+#
+# Controller concern to enforce that users are authenticated as admins
+#
+# Upon inclusion, adds `authenticate_admin!` as a before_action
+#
+module EnforcesAdminAuthentication
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :authenticate_admin!
+ end
+
+ def authenticate_admin!
+ render_404 unless current_user.admin?
+ end
+end
diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb
index 71bdef8ce03..0fddf15d197 100644
--- a/app/controllers/concerns/enforces_two_factor_authentication.rb
+++ b/app/controllers/concerns/enforces_two_factor_authentication.rb
@@ -16,7 +16,7 @@ module EnforcesTwoFactorAuthentication
end
def check_two_factor_requirement
- if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled? && !skip_two_factor?
+ if two_factor_authentication_required? && current_user && !current_user.temp_oauth_email? && !current_user.two_factor_enabled? && !skip_two_factor?
redirect_to profile_two_factor_auth_path
end
end
diff --git a/app/controllers/concerns/import_url_params.rb b/app/controllers/concerns/import_url_params.rb
new file mode 100644
index 00000000000..e51e4157f50
--- /dev/null
+++ b/app/controllers/concerns/import_url_params.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module ImportUrlParams
+ def import_url_params
+ return {} unless params.dig(:project, :import_url).present?
+
+ { import_url: import_params_to_full_url(params[:project]) }
+ end
+
+ def import_params_to_full_url(params)
+ Gitlab::UrlSanitizer.new(
+ params[:import_url],
+ credentials: {
+ user: params[:import_url_user],
+ password: params[:import_url_password]
+ }
+ ).full_url
+ end
+end
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index cd3fa641e89..065d2d3a4ec 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -8,7 +8,7 @@ module IssuableActions
before_action :authorize_destroy_issuable!, only: :destroy
before_action :authorize_admin_issuable!, only: :bulk_update
before_action only: :show do
- push_frontend_feature_flag(:reply_to_individual_notes)
+ push_frontend_feature_flag(:scoped_labels, default_enabled: true)
end
end
@@ -192,12 +192,7 @@ module IssuableActions
def bulk_update_params
permitted_keys_array = permitted_keys.dup
-
- if resource_name == 'issue'
- permitted_keys_array << { assignee_ids: [] }
- else
- permitted_keys_array.unshift(:assignee_id)
- end
+ permitted_keys_array << { assignee_ids: [] }
params.require(:update).permit(permitted_keys_array)
end
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index c529aabf797..9cf25915e92 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -41,6 +41,7 @@ module IssuableCollections
return if pagination_disabled?
@issuables = @issuables.page(params[:page])
+ @issuables = per_page_for_relative_position if params[:sort] == 'relative_position'
@issuable_meta_data = issuable_meta_data(@issuables, collection_type)
@total_pages = issuable_page_count
end
@@ -80,6 +81,11 @@ module IssuableCollections
(row_count.to_f / limit).ceil
end
+ # manual / relative_position sorting allows for 100 items on the page
+ def per_page_for_relative_position
+ @issuables.per(100) # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
def issuable_finder_for(finder_class)
finder_class.new(current_user, finder_options)
end
@@ -100,6 +106,7 @@ module IssuableCollections
if @project
options[:project_id] = @project.id
+ options[:attempt_project_search_optimizations] = true
elsif @group
options[:group_id] = @group.id
options[:include_subgroups] = true
@@ -189,15 +196,15 @@ module IssuableCollections
end
end
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def preload_for_collection
+ common_attributes = [:author, :assignees, :labels, :milestone]
@preload_for_collection ||= case collection_type
when 'Issue'
- [:project, :author, :assignees, :labels, :milestone, project: :namespace]
+ common_attributes + [:project, project: :namespace]
when 'MergeRequest'
- [
- :target_project, :author, :assignee, :labels, :milestone,
- source_project: :route, head_pipeline: :project, target_project: :namespace, latest_merge_request_diff: :merge_request_diff_commits
- ]
+ common_attributes + [:target_project, source_project: :route, head_pipeline: :project, target_project: :namespace, latest_merge_request_diff: :merge_request_diff_commits]
end
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
end
diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb
index 57e444319e0..f7137a04437 100644
--- a/app/controllers/concerns/lfs_request.rb
+++ b/app/controllers/concerns/lfs_request.rb
@@ -26,7 +26,7 @@ module LfsRequest
render(
json: {
- message: 'Git LFS is not enabled on this GitLab server, contact your admin.',
+ message: _('Git LFS is not enabled on this GitLab server, contact your admin.'),
documentation_url: help_url
},
status: :not_implemented
@@ -51,7 +51,7 @@ module LfsRequest
def render_lfs_forbidden
render(
json: {
- message: 'Access forbidden. Check your access level.',
+ message: _('Access forbidden. Check your access level.'),
documentation_url: help_url
},
content_type: CONTENT_TYPE,
@@ -62,7 +62,7 @@ module LfsRequest
def render_lfs_not_found
render(
json: {
- message: 'Not found.',
+ message: _('Not found.'),
documentation_url: help_url
},
content_type: CONTENT_TYPE,
diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb
index 6402e01ddc0..0b2756c0c6a 100644
--- a/app/controllers/concerns/membership_actions.rb
+++ b/app/controllers/concerns/membership_actions.rb
@@ -9,7 +9,7 @@ module MembershipActions
result = Members::CreateService.new(current_user, create_params).execute(membershipable)
if result[:status] == :success
- redirect_to members_page_url, notice: 'Users were successfully added.'
+ redirect_to members_page_url, notice: _('Users were successfully added.')
else
redirect_to members_page_url, alert: result[:message]
end
@@ -35,9 +35,16 @@ module MembershipActions
respond_to do |format|
format.html do
- source = source_type == 'group' ? 'group and any subresources' : source_type
+ message =
+ begin
+ case membershipable
+ when Namespace
+ _("User was successfully removed from group and any subresources.")
+ else
+ _("User was successfully removed from project.")
+ end
+ end
- message = "User was successfully removed from #{source}."
redirect_to members_page_url, notice: message
end
@@ -49,7 +56,7 @@ module MembershipActions
membershipable.request_access(current_user)
redirect_to polymorphic_path(membershipable),
- notice: 'Your request for access has been queued for review.'
+ notice: _('Your request for access has been queued for review.')
end
def approve_access_request
@@ -68,9 +75,9 @@ module MembershipActions
notice =
if member.request?
- "Your access request to the #{source_type} has been withdrawn."
+ _("Your access request to the %{source_type} has been withdrawn.") % { source_type: source_type }
else
- "You left the \"#{membershipable.human_name}\" #{source_type}."
+ _("You left the \"%{membershipable_human_name}\" %{source_type}.") % { membershipable_human_name: membershipable.human_name, source_type: source_type }
end
respond_to do |format|
@@ -90,9 +97,9 @@ module MembershipActions
if member.invite?
member.resend_invite
- redirect_to members_page_url, notice: 'The invitation was successfully resent.'
+ redirect_to members_page_url, notice: _('The invitation was successfully resent.')
else
- redirect_to members_page_url, alert: 'The invitation has already been accepted.'
+ redirect_to members_page_url, alert: _('The invitation has already been accepted.')
end
end
@@ -125,6 +132,16 @@ module MembershipActions
end
def source_type
- @source_type ||= membershipable.class.to_s.humanize(capitalize: false)
+ @source_type ||=
+ begin
+ case membershipable
+ when Namespace
+ _("group")
+ when Project
+ _("project")
+ else
+ raise "Unknown membershipable type: #{membershipable}!"
+ end
+ end
end
end
diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb
index eccbe35577b..8b8b7db72f8 100644
--- a/app/controllers/concerns/milestone_actions.rb
+++ b/app/controllers/concerns/milestone_actions.rb
@@ -8,7 +8,7 @@ module MilestoneActions
format.html { redirect_to milestone_redirect_path }
format.json do
render json: tabs_json("shared/milestones/_merge_requests_tab", {
- merge_requests: @milestone.sorted_merge_requests, # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ merge_requests: @milestone.sorted_merge_requests(current_user), # rubocop:disable Gitlab/ModuleWithInstanceVariables
show_project_name: true
})
end
@@ -26,16 +26,22 @@ module MilestoneActions
end
end
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def labels
respond_to do |format|
format.html { redirect_to milestone_redirect_path }
format.json do
+ milestone_labels = @milestone.issue_labels_visible_by_user(current_user)
+
render json: tabs_json("shared/milestones/_labels_tab", {
- labels: @milestone.labels # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ labels: milestone_labels.map do |label|
+ label.present(issuable_subject: @milestone.parent)
+ end
})
end
end
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
private
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index b4fee93713b..f96d1821095 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -48,7 +48,7 @@ module NotesActions
respond_to do |format|
format.json do
json = {
- commands_changes: @note.commands_changes
+ commands_changes: @note.commands_changes&.slice(:emoji_award, :time_estimate, :spend_time)
}
if @note.persisted? && return_discussion?
diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb
index f72d25fc54c..2a9729b6ffd 100644
--- a/app/controllers/concerns/preview_markdown.rb
+++ b/app/controllers/concerns/preview_markdown.rb
@@ -20,7 +20,7 @@ module PreviewMarkdown
body: view_context.markdown(result[:text], markdown_params),
references: {
users: result[:users],
- suggestions: result[:suggestions],
+ suggestions: SuggestionSerializer.new.represent_diff(result[:suggestions]),
commands: view_context.markdown(result[:commands])
}
}
diff --git a/app/controllers/concerns/project_unauthorized.rb b/app/controllers/concerns/project_unauthorized.rb
index f59440dbc59..7238840440f 100644
--- a/app/controllers/concerns/project_unauthorized.rb
+++ b/app/controllers/concerns/project_unauthorized.rb
@@ -1,10 +1,21 @@
# frozen_string_literal: true
module ProjectUnauthorized
- extend ActiveSupport::Concern
+ module ControllerActions
+ def self.on_routable_not_found
+ lambda do |routable|
+ return unless routable.is_a?(Project)
- # EE would override this
- def project_unauthorized_proc
- # no-op
+ label = routable.external_authorization_classification_label
+ rejection_reason = nil
+
+ unless ::Gitlab::ExternalAuthorization.access_allowed?(current_user, label)
+ rejection_reason = ::Gitlab::ExternalAuthorization.rejection_reason(current_user, label)
+ rejection_reason ||= _('External authorization denied access to this project')
+ end
+
+ access_denied!(rejection_reason) if rejection_reason
+ end
+ end
end
end
diff --git a/app/controllers/concerns/renders_notes.rb b/app/controllers/concerns/renders_notes.rb
index ce36da6b715..18015b1de88 100644
--- a/app/controllers/concerns/renders_notes.rb
+++ b/app/controllers/concerns/renders_notes.rb
@@ -16,7 +16,7 @@ module RendersNotes
private
def preload_max_access_for_authors(notes, project)
- return nil unless project
+ return unless project
user_ids = notes.map(&:author_id)
project.team.max_member_access_for_user_ids(user_ids)
diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb
index 5624eb3aa45..ff9b0332c97 100644
--- a/app/controllers/concerns/routable_actions.rb
+++ b/app/controllers/concerns/routable_actions.rb
@@ -3,15 +3,13 @@
module RoutableActions
extend ActiveSupport::Concern
- def find_routable!(routable_klass, requested_full_path, extra_authorization_proc: nil, not_found_or_authorized_proc: nil)
+ def find_routable!(routable_klass, requested_full_path, extra_authorization_proc: nil)
routable = routable_klass.find_by_full_path(requested_full_path, follow_redirects: request.get?)
if routable_authorized?(routable, extra_authorization_proc)
ensure_canonical_path(routable, requested_full_path)
routable
else
- if not_found_or_authorized_proc
- not_found_or_authorized_proc.call(routable)
- end
+ perform_not_found_actions(routable, not_found_actions)
route_not_found unless performed?
@@ -19,6 +17,18 @@ module RoutableActions
end
end
+ def not_found_actions
+ [ProjectUnauthorized::ControllerActions.on_routable_not_found]
+ end
+
+ def perform_not_found_actions(routable, actions)
+ actions.each do |action|
+ break if performed?
+
+ instance_exec(routable, &action)
+ end
+ end
+
def routable_authorized?(routable, extra_authorization_proc)
return false unless routable
diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb
index c3a1b12af84..a8ffa33f1c7 100644
--- a/app/controllers/concerns/spammable_actions.rb
+++ b/app/controllers/concerns/spammable_actions.rb
@@ -12,9 +12,9 @@ module SpammableActions
def mark_as_spam
if SpamService.new(spammable).mark_as_spam!
- redirect_to spammable_path, notice: "#{spammable.spammable_entity_type.titlecase} was submitted to Akismet successfully."
+ redirect_to spammable_path, notice: _("%{spammable_titlecase} was submitted to Akismet successfully.") % { spammable_titlecase: spammable.spammable_entity_type.titlecase }
else
- redirect_to spammable_path, alert: 'Error with Akismet. Please check the logs for more info.'
+ redirect_to spammable_path, alert: _('Error with Akismet. Please check the logs for more info.')
end
end
@@ -33,7 +33,7 @@ module SpammableActions
ensure_spam_config_loaded!
if params[:recaptcha_verification]
- flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.'
+ flash[:alert] = _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.')
end
respond_to do |format|
diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb
index 4ec0e94df9a..59f6d3452a3 100644
--- a/app/controllers/concerns/uploads_actions.rb
+++ b/app/controllers/concerns/uploads_actions.rb
@@ -16,7 +16,7 @@ module UploadsActions
end
else
format.json do
- render json: 'Invalid file.', status: :unprocessable_entity
+ render json: _('Invalid file.'), status: :unprocessable_entity
end
end
end
@@ -57,7 +57,7 @@ module UploadsActions
render json: authorized
rescue SocketError
- render json: "Error uploading file", status: :internal_server_error
+ render json: _("Error uploading file"), status: :internal_server_error
end
private
diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb
index 2c4aab67448..2ae500a2fdf 100644
--- a/app/controllers/confirmations_controller.rb
+++ b/app/controllers/confirmations_controller.rb
@@ -22,7 +22,7 @@ class ConfirmationsController < Devise::ConfirmationsController
after_sign_in(resource)
else
Gitlab::AppLogger.info("Email Confirmed: username=#{resource.username} email=#{resource.email} ip=#{request.remote_ip}")
- flash[:notice] = flash[:notice] + " Please sign in."
+ flash[:notice] = flash[:notice] + _(" Please sign in.")
new_session_path(:user, anchor: 'login-pane')
end
end
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index b1d224d026f..65d14781d92 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -6,21 +6,22 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) }
before_action :set_non_archived_param
+ before_action :projects, only: [:index]
before_action :default_sorting
skip_cross_project_access_check :index, :starred
def index
- @projects = load_projects(params.merge(non_public: true))
-
respond_to do |format|
- format.html
+ format.html do
+ render_projects
+ end
format.atom do
load_events
render layout: 'xml.atom'
end
format.json do
render json: {
- html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects })
+ html: view_to_html_string("dashboard/projects/_projects", projects: @projects)
}
end
end
@@ -37,7 +38,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
format.html
format.json do
render json: {
- html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects })
+ html: view_to_html_string("dashboard/projects/_projects", projects: @projects)
}
end
end
@@ -46,6 +47,17 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
private
+ def projects
+ @projects ||= load_projects(params.merge(non_public: true))
+ end
+
+ def render_projects
+ # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/40260
+ Gitlab::GitalyClient.allow_n_plus_1_calls do
+ render
+ end
+ end
+
def default_sorting
params[:sort] ||= 'latest_activity_desc'
@sort = params[:sort]
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index 3fa582cf25b..f173c263474 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -21,7 +21,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
format.html do
redirect_to dashboard_todos_path,
status: 302,
- notice: 'Todo was successfully marked as done.'
+ notice: _('Todo was successfully marked as done.')
end
format.js { head :ok }
format.json { render json: todos_counts }
@@ -32,7 +32,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
updated_ids = TodoService.new.mark_todos_as_done(@todos, current_user)
respond_to do |format|
- format.html { redirect_to dashboard_todos_path, status: 302, notice: 'All todos were marked as done.' }
+ format.html { redirect_to dashboard_todos_path, status: 302, notice: _('All todos were marked as done.') }
format.js { head :ok }
format.json { render json: todos_counts.merge(updated_ids: updated_ids) }
end
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 75329b05a6f..1a97b39d3ae 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -46,7 +46,10 @@ class DashboardController < Dashboard::ApplicationController
end
def check_filters_presence!
- @no_filters_set = finder_type.scalar_params.none? { |k| params.key?(k) }
+ no_scalar_filters_set = finder_type.scalar_params.none? { |k| params.key?(k) }
+ no_array_filters_set = finder_type.array_params.none? { |k, _| params.key?(k) }
+
+ @no_filters_set = no_scalar_filters_set && no_array_filters_set
return unless @no_filters_set
diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb
index f3d76c5a478..ef86d5f981a 100644
--- a/app/controllers/explore/projects_controller.rb
+++ b/app/controllers/explore/projects_controller.rb
@@ -15,7 +15,7 @@ class Explore::ProjectsController < Explore::ApplicationController
format.html
format.json do
render json: {
- html: view_to_html_string("explore/projects/_projects", locals: { projects: @projects })
+ html: view_to_html_string("explore/projects/_projects", projects: @projects)
}
end
end
@@ -30,7 +30,7 @@ class Explore::ProjectsController < Explore::ApplicationController
format.html
format.json do
render json: {
- html: view_to_html_string("explore/projects/_projects", locals: { projects: @projects })
+ html: view_to_html_string("explore/projects/_projects", projects: @projects)
}
end
end
@@ -44,7 +44,7 @@ class Explore::ProjectsController < Explore::ApplicationController
format.html
format.json do
render json: {
- html: view_to_html_string("explore/projects/_projects", locals: { projects: @projects })
+ html: view_to_html_string("explore/projects/_projects", projects: @projects)
}
end
end
diff --git a/app/controllers/google_api/authorizations_controller.rb b/app/controllers/google_api/authorizations_controller.rb
index dd9f5af61b3..ed0995e7ffd 100644
--- a/app/controllers/google_api/authorizations_controller.rb
+++ b/app/controllers/google_api/authorizations_controller.rb
@@ -2,6 +2,10 @@
module GoogleApi
class AuthorizationsController < ApplicationController
+ include Gitlab::Utils::StrongMemoize
+
+ before_action :validate_session_key!
+
def callback
token, expires_at = GoogleApi::CloudPlatform::Client
.new(nil, callback_google_api_auth_url)
@@ -11,21 +15,27 @@ module GoogleApi
session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] =
expires_at.to_s
- state_redirect_uri = redirect_uri_from_session_key(params[:state])
-
- if state_redirect_uri
- redirect_to state_redirect_uri
- else
- redirect_to root_path
- end
+ redirect_to redirect_uri_from_session
end
private
- def redirect_uri_from_session_key(state)
- key = GoogleApi::CloudPlatform::Client
- .session_key_for_redirect_uri(params[:state])
- session[key] if key
+ def validate_session_key!
+ access_denied! unless redirect_uri_from_session.present?
+ end
+
+ def redirect_uri_from_session
+ strong_memoize(:redirect_uri_from_session) do
+ if params[:state].present?
+ session[session_key_for_redirect_uri(params[:state])]
+ else
+ nil
+ end
+ end
+ end
+
+ def session_key_for_redirect_uri(state)
+ GoogleApi::CloudPlatform::Client.session_key_for_redirect_uri(state)
end
end
end
diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb
index 3ef03bc9622..1ce0afac83b 100644
--- a/app/controllers/graphql_controller.rb
+++ b/app/controllers/graphql_controller.rb
@@ -3,18 +3,21 @@
class GraphqlController < ApplicationController
# Unauthenticated users have access to the API for public data
skip_before_action :authenticate_user!
- prepend_before_action(only: [:execute]) { authenticate_sessionless_user!(:api) }
+
+ # Allow missing CSRF tokens, this would mean that if a CSRF is invalid or missing,
+ # the user won't be authenticated but can proceed as an anonymous user.
+ #
+ # If a CSRF is valid, the user is authenticated. This makes it easier to play
+ # around in GraphiQL.
+ protect_from_forgery with: :null_session, only: :execute
before_action :check_graphql_feature_flag!
+ before_action :authorize_access_api!
+ before_action(only: [:execute]) { authenticate_sessionless_user!(:api) }
def execute
- variables = Gitlab::Graphql::Variables.new(params[:variables]).to_h
- query = params[:query]
- operation_name = params[:operationName]
- context = {
- current_user: current_user
- }
- result = GitlabSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
+ result = multiplex? ? execute_multiplex : execute_query
+
render json: result
end
@@ -30,6 +33,48 @@ class GraphqlController < ApplicationController
private
+ def execute_multiplex
+ GitlabSchema.multiplex(multiplex_queries, context: context)
+ end
+
+ def execute_query
+ variables = build_variables(params[:variables])
+ operation_name = params[:operationName]
+
+ GitlabSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
+ end
+
+ def query
+ params[:query]
+ end
+
+ def multiplex_queries
+ params[:_json].map do |single_query_info|
+ {
+ query: single_query_info[:query],
+ variables: build_variables(single_query_info[:variables]),
+ operation_name: single_query_info[:operationName],
+ context: context
+ }
+ end
+ end
+
+ def context
+ @context ||= { current_user: current_user }
+ end
+
+ def build_variables(variable_info)
+ Gitlab::Graphql::Variables.new(variable_info).to_h
+ end
+
+ def multiplex?
+ params[:_json].present?
+ end
+
+ def authorize_access_api!
+ access_denied!("API not accessible for user.") unless can?(current_user, :access_api)
+ end
+
# Overridden from the ApplicationController to make the response look like
# a GraphQL response. That is nicely picked up in Graphiql.
def render_404
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index 51fdb6c05fb..40b8d5ed72c 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -1,53 +1,16 @@
# frozen_string_literal: true
class Groups::BoardsController < Groups::ApplicationController
- include BoardsResponses
+ include BoardsActions
include RecordUserLastActivity
before_action :assign_endpoint_vars
- before_action :boards, only: :index
- before_action :redirect_to_recent_board, only: :index
-
- def index
- respond_with_boards
- end
-
- def show
- @board = boards.find(params[:id])
-
- # add/update the board in the recent visited table
- Boards::Visits::CreateService.new(@board.group, current_user).execute(@board) if request.format.html?
-
- respond_with_board
- end
private
- def boards
- @boards ||= Boards::ListService.new(group, current_user).execute
- end
-
def assign_endpoint_vars
@boards_endpoint = group_boards_url(group)
@namespace_path = group.to_param
@labels_endpoint = group_labels_url(group)
end
-
- def serialize_as_json(resource)
- resource.as_json(only: [:id])
- end
-
- def includes_board?(board_id)
- boards.any? { |board| board.id == board_id }
- end
-
- def redirect_to_recent_board
- return if request.format.json?
-
- recently_visited = Boards::Visits::LatestService.new(group, current_user).execute
-
- if recently_visited && includes_board?(recently_visited.board_id)
- redirect_to(group_board_path(id: recently_visited.board_id), status: :found)
- end
- end
end
diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb
index 0bc082246a1..f1d6fb00cfc 100644
--- a/app/controllers/groups/group_members_controller.rb
+++ b/app/controllers/groups/group_members_controller.rb
@@ -12,6 +12,7 @@ class Groups::GroupMembersController < Groups::ApplicationController
# Authorize
before_action :authorize_admin_group_member!, except: admin_not_required_endpoints
+ skip_before_action :check_two_factor_requirement, only: :leave
skip_cross_project_access_check :index, :create, :update, :destroy, :request_access,
:approve_access_request, :leave, :resend_invite,
:override
diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb
index dd8fbf7a029..f8e32451b02 100644
--- a/app/controllers/groups/runners_controller.rb
+++ b/app/controllers/groups/runners_controller.rb
@@ -16,7 +16,7 @@ class Groups::RunnersController < Groups::ApplicationController
def update
if Ci::UpdateRunnerService.new(@runner).update(runner_params)
- redirect_to group_runner_path(@group, @runner), notice: 'Runner was successfully updated.'
+ redirect_to group_runner_path(@group, @runner), notice: _('Runner was successfully updated.')
else
render 'edit'
end
@@ -30,17 +30,17 @@ class Groups::RunnersController < Groups::ApplicationController
def resume
if Ci::UpdateRunnerService.new(@runner).update(active: true)
- redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), notice: 'Runner was successfully updated.'
+ redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), notice: _('Runner was successfully updated.')
else
- redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), alert: 'Runner was not updated.'
+ redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), alert: _('Runner was not updated.')
end
end
def pause
if Ci::UpdateRunnerService.new(@runner).update(active: false)
- redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), notice: 'Runner was successfully updated.'
+ redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), notice: _('Runner was successfully updated.')
else
- redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), alert: 'Runner was not updated.'
+ redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), alert: _('Runner was not updated.')
end
end
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index f476f428fdb..c465e622de0 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -13,7 +13,17 @@ module Groups
def reset_registration_token
@group.reset_runners_token!
- flash[:notice] = 'New runners registration token has been generated!'
+ flash[:notice] = _('GroupSettings|New runners registration token has been generated!')
+ redirect_to group_settings_ci_cd_path
+ end
+
+ def update_auto_devops
+ if auto_devops_service.execute
+ flash[:notice] = s_('GroupSettings|Auto DevOps pipeline was updated for the group')
+ else
+ flash[:alert] = s_("GroupSettings|There was a problem updating Auto DevOps pipeline: %{error_messages}." % { error_messages: group.errors.full_messages })
+ end
+
redirect_to group_settings_ci_cd_path
end
@@ -29,6 +39,14 @@ module Groups
def authorize_admin_group!
return render_404 unless can?(current_user, :admin_group, group)
end
+
+ def auto_devops_params
+ params.require(:group).permit(:auto_devops_enabled)
+ end
+
+ def auto_devops_service
+ Groups::AutoDevopsService.new(group, current_user, auto_devops_params)
+ end
end
end
end
diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb
index 4f641de0357..11e3cfb01e4 100644
--- a/app/controllers/groups/variables_controller.rb
+++ b/app/controllers/groups/variables_controller.rb
@@ -41,7 +41,7 @@ module Groups
end
def variable_params_attributes
- %i[id key secret_value protected _destroy]
+ %i[id variable_type key secret_value protected masked _destroy]
end
def authorize_admin_build!
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 4e50106398a..e936d771502 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -58,11 +58,24 @@ class GroupsController < Groups::ApplicationController
def show
respond_to do |format|
- format.html
+ format.html do
+ render_show_html
+ end
format.atom do
- load_events
- render layout: 'xml.atom'
+ render_details_view_atom
+ end
+ end
+ end
+
+ def details
+ respond_to do |format|
+ format.html do
+ render_details_html
+ end
+
+ format.atom do
+ render_details_view_atom
end
end
end
@@ -111,14 +124,27 @@ class GroupsController < Groups::ApplicationController
flash[:notice] = "Group '#{@group.name}' was successfully transferred."
redirect_to group_path(@group)
else
- flash.now[:alert] = service.error
- render :edit
+ flash[:alert] = service.error
+ redirect_to edit_group_path(@group)
end
end
# rubocop: enable CodeReuse/ActiveRecord
protected
+ def render_show_html
+ render 'groups/show'
+ end
+
+ def render_details_html
+ render 'groups/show'
+ end
+
+ def render_details_view_atom
+ load_events
+ render layout: 'xml.atom', template: 'groups/show'
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def authorize_create_group!
allowed = if params[:parent_id].present?
@@ -161,7 +187,8 @@ class GroupsController < Groups::ApplicationController
:create_chat_team,
:chat_team_name,
:require_two_factor_authentication,
- :two_factor_grace_period
+ :two_factor_grace_period,
+ :project_creation_level
]
end
@@ -178,8 +205,8 @@ class GroupsController < Groups::ApplicationController
.includes(:namespace)
@events = EventCollection
- .new(@projects, offset: params[:offset].to_i, filter: event_filter)
- .to_a
+ .new(@projects, offset: params[:offset].to_i, filter: event_filter)
+ .to_a
Events::RenderService
.new(current_user)
diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb
index a9d6addd4a4..837c26c630a 100644
--- a/app/controllers/help_controller.rb
+++ b/app/controllers/help_controller.rb
@@ -7,7 +7,7 @@ class HelpController < ApplicationController
# Taken from Jekyll
# https://github.com/jekyll/jekyll/blob/3.5-stable/lib/jekyll/document.rb#L13
- YAML_FRONT_MATTER_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m
+ YAML_FRONT_MATTER_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m.freeze
def index
# Remove YAML frontmatter so that it doesn't look weird
@@ -22,7 +22,7 @@ class HelpController < ApplicationController
end
def show
- @path = clean_path_info(path_params[:path])
+ @path = Rack::Utils.clean_path_info(path_params[:path])
respond_to do |format|
format.any(:markdown, :md, :html) do
@@ -75,35 +75,4 @@ class HelpController < ApplicationController
params
end
-
- PATH_SEPS = Regexp.union(*[::File::SEPARATOR, ::File::ALT_SEPARATOR].compact)
-
- # Taken from ActionDispatch::FileHandler
- # Cleans up the path, to prevent directory traversal outside the doc folder.
- def clean_path_info(path_info)
- parts = path_info.split(PATH_SEPS)
-
- clean = []
-
- # Walk over each part of the path
- parts.each do |part|
- # Turn `one//two` or `one/./two` into `one/two`.
- next if part.empty? || part == '.'
-
- if part == '..'
- # Turn `one/two/../` into `one`
- clean.pop
- else
- # Add simple folder names to the clean path.
- clean << part
- end
- end
-
- # If the path was an absolute path (i.e. `/` or `/one/two`),
- # add `/` to the front of the clean path.
- clean.unshift '/' if parts.empty? || parts.first.empty?
-
- # Join all the clean path parts by the path separator.
- ::File.join(*clean)
- end
end
diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb
index 2b1395f364f..293d76ea765 100644
--- a/app/controllers/import/bitbucket_controller.rb
+++ b/app/controllers/import/bitbucket_controller.rb
@@ -62,7 +62,7 @@ class Import::BitbucketController < Import::BaseController
render json: { errors: project_save_error(project) }, status: :unprocessable_entity
end
else
- render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity
+ render json: { errors: _('This namespace has already been taken! Please choose another one.') }, status: :unprocessable_entity
end
end
diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb
index f333e43b892..f71ea8642cd 100644
--- a/app/controllers/import/bitbucket_server_controller.rb
+++ b/app/controllers/import/bitbucket_server_controller.rb
@@ -15,8 +15,8 @@ class Import::BitbucketServerController < Import::BaseController
# (https://community.atlassian.com/t5/Answers-Developer-Questions/stash-repository-names/qaq-p/499054)
#
# Bitbucket Server starts personal project names with a tilde.
- VALID_BITBUCKET_PROJECT_CHARS = /\A~?[\w\-\.\s]+\z/
- VALID_BITBUCKET_CHARS = /\A[\w\-\.\s]+\z/
+ VALID_BITBUCKET_PROJECT_CHARS = /\A~?[\w\-\.\s]+\z/.freeze
+ VALID_BITBUCKET_CHARS = /\A[\w\-\.\s]+\z/.freeze
def new
end
@@ -25,7 +25,7 @@ class Import::BitbucketServerController < Import::BaseController
repo = bitbucket_client.repo(@project_key, @repo_slug)
unless repo
- return render json: { errors: "Project #{@project_key}/#{@repo_slug} could not be found" }, status: :unprocessable_entity
+ return render json: { errors: _("Project %{project_repo} could not be found") % { project_repo: "#{@project_key}/#{@repo_slug}" } }, status: :unprocessable_entity
end
project_name = params[:new_name].presence || repo.name
@@ -41,10 +41,10 @@ class Import::BitbucketServerController < Import::BaseController
render json: { errors: project_save_error(project) }, status: :unprocessable_entity
end
else
- render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity
+ render json: { errors: _('This namespace has already been taken! Please choose another one.') }, status: :unprocessable_entity
end
- rescue BitbucketServer::Connection::ConnectionError => e
- render json: { errors: "Unable to connect to server: #{e}" }, status: :unprocessable_entity
+ rescue BitbucketServer::Connection::ConnectionError => error
+ render json: { errors: _("Unable to connect to server: %{error}") % { error: error } }, status: :unprocessable_entity
end
def configure
@@ -65,8 +65,8 @@ class Import::BitbucketServerController < Import::BaseController
already_added_projects_names = @already_added_projects.pluck(:import_source)
@repos.reject! { |repo| already_added_projects_names.include?(repo.browse_url) }
- rescue BitbucketServer::Connection::ConnectionError => e
- flash[:alert] = "Unable to connect to server: #{e}"
+ rescue BitbucketServer::Connection::ConnectionError => error
+ flash[:alert] = _("Unable to connect to server: %{error}") % { error: error }
clear_session_data
redirect_to new_import_bitbucket_server_path
end
diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb
index 5a439e6de78..a37ba682b91 100644
--- a/app/controllers/import/fogbugz_controller.rb
+++ b/app/controllers/import/fogbugz_controller.rb
@@ -14,7 +14,7 @@ class Import::FogbugzController < Import::BaseController
res = Gitlab::FogbugzImport::Client.new(import_params.symbolize_keys)
rescue
# If the URI is invalid various errors can occur
- return redirect_to new_import_fogbugz_path, alert: 'Could not connect to FogBugz, check your URL'
+ return redirect_to new_import_fogbugz_path, alert: _('Could not connect to FogBugz, check your URL')
end
session[:fogbugz_token] = res.get_token
session[:fogbugz_uri] = params[:uri]
@@ -29,14 +29,14 @@ class Import::FogbugzController < Import::BaseController
user_map = params[:users]
unless user_map.is_a?(Hash) && user_map.all? { |k, v| !v[:name].blank? }
- flash.now[:alert] = 'All users must have a name.'
+ flash.now[:alert] = _('All users must have a name.')
return render 'new_user_map'
end
session[:fogbugz_user_map] = user_map
- flash[:notice] = 'The user map has been saved. Continue by selecting the projects you want to import.'
+ flash[:notice] = _('The user map has been saved. Continue by selecting the projects you want to import.')
redirect_to status_import_fogbugz_path
end
diff --git a/app/controllers/import/gitea_controller.rb b/app/controllers/import/gitea_controller.rb
index 68ad8650dba..a23b2f8139e 100644
--- a/app/controllers/import/gitea_controller.rb
+++ b/app/controllers/import/gitea_controller.rb
@@ -46,7 +46,7 @@ class Import::GiteaController < Import::GithubController
def provider_auth
if session[access_token_key].blank? || provider_url.blank?
redirect_to new_import_gitea_url,
- alert: 'You need to specify both an Access Token and a Host URL.'
+ alert: _('You need to specify both an Access Token and a Host URL.')
end
end
diff --git a/app/controllers/import/gitlab_controller.rb b/app/controllers/import/gitlab_controller.rb
index 498de0b07b8..5ec8e9e6fc5 100644
--- a/app/controllers/import/gitlab_controller.rb
+++ b/app/controllers/import/gitlab_controller.rb
@@ -42,7 +42,7 @@ class Import::GitlabController < Import::BaseController
render json: { errors: project_save_error(project) }, status: :unprocessable_entity
end
else
- render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity
+ render json: { errors: _('This namespace has already been taken! Please choose another one.') }, status: :unprocessable_entity
end
end
diff --git a/app/controllers/import/gitlab_projects_controller.rb b/app/controllers/import/gitlab_projects_controller.rb
index 354fba5d204..89889141be6 100644
--- a/app/controllers/import/gitlab_projects_controller.rb
+++ b/app/controllers/import/gitlab_projects_controller.rb
@@ -13,7 +13,7 @@ class Import::GitlabProjectsController < Import::BaseController
def create
unless file_is_valid?
- return redirect_back_or_default(options: { alert: "You need to upload a GitLab project export archive (ending in .gz)." })
+ return redirect_back_or_default(options: { alert: _("You need to upload a GitLab project export archive (ending in .gz).") })
end
@project = ::Projects::GitlabProjectsImportService.new(current_user, project_params).execute
@@ -21,7 +21,7 @@ class Import::GitlabProjectsController < Import::BaseController
if @project.saved?
redirect_to(
project_path(@project),
- notice: "Project '#{@project.name}' is being imported."
+ notice: _("Project '%{project_name}' is being imported.") % { project_name: @project.name }
)
else
redirect_back_or_default(options: { alert: "Project could not be imported: #{@project.errors.full_messages.join(', ')}" })
diff --git a/app/controllers/import/google_code_controller.rb b/app/controllers/import/google_code_controller.rb
index 331f06c3dd6..4dddfbcd20d 100644
--- a/app/controllers/import/google_code_controller.rb
+++ b/app/controllers/import/google_code_controller.rb
@@ -11,18 +11,18 @@ class Import::GoogleCodeController < Import::BaseController
dump_file = params[:dump_file]
unless dump_file.respond_to?(:read)
- return redirect_back_or_default(options: { alert: "You need to upload a Google Takeout archive." })
+ return redirect_back_or_default(options: { alert: _("You need to upload a Google Takeout archive.") })
end
begin
dump = JSON.parse(dump_file.read)
rescue
- return redirect_back_or_default(options: { alert: "The uploaded file is not a valid Google Takeout archive." })
+ return redirect_back_or_default(options: { alert: _("The uploaded file is not a valid Google Takeout archive.") })
end
client = Gitlab::GoogleCodeImport::Client.new(dump)
unless client.valid?
- return redirect_back_or_default(options: { alert: "The uploaded file is not a valid Google Takeout archive." })
+ return redirect_back_or_default(options: { alert: _("The uploaded file is not a valid Google Takeout archive.") })
end
session[:google_code_dump] = dump
@@ -44,13 +44,13 @@ class Import::GoogleCodeController < Import::BaseController
begin
user_map = JSON.parse(user_map_json)
rescue
- flash.now[:alert] = "The entered user map is not a valid JSON user map."
+ flash.now[:alert] = _("The entered user map is not a valid JSON user map.")
return render "new_user_map"
end
unless user_map.is_a?(Hash) && user_map.all? { |k, v| k.is_a?(String) && v.is_a?(String) }
- flash.now[:alert] = "The entered user map is not a valid JSON user map."
+ flash.now[:alert] = _("The entered user map is not a valid JSON user map.")
return render "new_user_map"
end
@@ -62,7 +62,7 @@ class Import::GoogleCodeController < Import::BaseController
session[:google_code_user_map] = user_map
- flash[:notice] = "The user map has been saved. Continue by selecting the projects you want to import."
+ flash[:notice] = _("The user map has been saved. Continue by selecting the projects you want to import.")
redirect_to status_import_google_code_path
end
diff --git a/app/controllers/import/phabricator_controller.rb b/app/controllers/import/phabricator_controller.rb
new file mode 100644
index 00000000000..d1c04817689
--- /dev/null
+++ b/app/controllers/import/phabricator_controller.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class Import::PhabricatorController < Import::BaseController
+ include ImportHelper
+
+ before_action :verify_import_enabled
+
+ def new
+ end
+
+ def create
+ @project = Gitlab::PhabricatorImport::ProjectCreator
+ .new(current_user, import_params).execute
+
+ if @project&.persisted?
+ redirect_to @project
+ else
+ @name = params[:name]
+ @path = params[:path]
+ @errors = @project&.errors&.full_messages || [_("Invalid import params")]
+
+ render :new
+ end
+ end
+
+ def verify_import_enabled
+ render_404 unless phabricator_import_enabled?
+ end
+
+ private
+
+ def import_params
+ params.permit(:path, :phabricator_server_url, :api_token, :name, :namespace_id)
+ end
+end
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 315d1375e02..a78d87eceea 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -13,9 +13,9 @@ class InvitesController < ApplicationController
if member.accept_invite!(current_user)
label, path = source_info(member.source)
- redirect_to path, notice: "You have been granted #{member.human_access} access to #{label}."
+ redirect_to path, notice: _("You have been granted %{member_human_access} access to %{label}.") % { member_human_access: member.human_access, label: label }
else
- redirect_back_or_default(options: { alert: "The invitation could not be accepted." })
+ redirect_back_or_default(options: { alert: _("The invitation could not be accepted.") })
end
end
@@ -30,9 +30,9 @@ class InvitesController < ApplicationController
new_user_session_path
end
- redirect_to path, notice: "You have declined the invitation to join #{label}."
+ redirect_to path, notice: _("You have declined the invitation to join %{label}.") % { label: label }
else
- redirect_back_or_default(options: { alert: "The invitation could not be declined." })
+ redirect_back_or_default(options: { alert: _("The invitation could not be declined.") })
end
end
diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb
index f9008a5b67e..5ecf4f114cf 100644
--- a/app/controllers/jwt_controller.rb
+++ b/app/controllers/jwt_controller.rb
@@ -22,7 +22,7 @@ class JwtController < ApplicationController
private
def authenticate_project_or_user
- @authentication_result = Gitlab::Auth::Result.new(nil, nil, :none, Gitlab::Auth.read_authentication_abilities)
+ @authentication_result = Gitlab::Auth::Result.new(nil, nil, :none, Gitlab::Auth.read_only_authentication_abilities)
authenticate_with_http_basic do |login, password|
@authentication_result = Gitlab::Auth.find_for_git_client(login, password, project: nil, ip: request.ip)
@@ -39,9 +39,9 @@ class JwtController < ApplicationController
render json: {
errors: [
{ code: 'UNAUTHORIZED',
- message: "HTTP Basic: Access denied\n" \
- "You must use a personal access token with 'api' scope for Git over HTTP.\n" \
- "You can generate one at #{profile_personal_access_tokens_url}" }
+ message: _('HTTP Basic: Access denied\n' \
+ 'You must use a personal access token with \'api\' scope for Git over HTTP.\n' \
+ 'You can generate one at %{profile_personal_access_tokens_url}') % { profile_personal_access_tokens_url: profile_personal_access_tokens_url } }
]
}, status: :unauthorized
end
diff --git a/app/controllers/ldap/omniauth_callbacks_controller.rb b/app/controllers/ldap/omniauth_callbacks_controller.rb
index 5e872804448..9a5a45939e0 100644
--- a/app/controllers/ldap/omniauth_callbacks_controller.rb
+++ b/app/controllers/ldap/omniauth_callbacks_controller.rb
@@ -26,7 +26,7 @@ class Ldap::OmniauthCallbacksController < OmniauthCallbacksController
override :fail_login
def fail_login(user)
- flash[:alert] = 'Access denied for your LDAP account.'
+ flash[:alert] = _('Access denied for your LDAP account.')
redirect_to new_user_session_path
end
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index cc2bb99f55b..2a8dd997d04 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -3,6 +3,7 @@
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
include AuthenticatesWithTwoFactor
include Devise::Controllers::Rememberable
+ include AuthHelper
protect_from_forgery except: [:kerberos, :saml, :cas3, :failure], with: :exception, prepend: true
@@ -80,11 +81,13 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
end
if current_user
+ 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)
- identity_linker.link
+ link_identity(identity_linker)
if identity_linker.changed?
redirect_identity_linked
@@ -98,16 +101,20 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
end
end
+ def link_identity(identity_linker)
+ identity_linker.link
+ end
+
def redirect_identity_exists
redirect_to after_sign_in_path_for(current_user)
end
def redirect_identity_link_failed(error_message)
- redirect_to profile_account_path, notice: "Authentication failed: #{error_message}"
+ redirect_to profile_account_path, notice: _("Authentication failed: %{error_message}") % { error_message: error_message }
end
def redirect_identity_linked
- redirect_to profile_account_path, notice: 'Authentication method updated'
+ redirect_to profile_account_path, notice: _('Authentication method updated')
end
def handle_service_ticket(provider, ticket)
@@ -145,10 +152,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
def handle_signup_error
label = Gitlab::Auth::OAuth::Provider.label_for(oauth['provider'])
- message = ["Signing in using your #{label} account without a pre-existing GitLab account is not allowed."]
+ message = [_("Signing in using your %{label} account without a pre-existing GitLab account is not allowed.") % { label: label }]
if Gitlab::CurrentSettings.allow_signup?
- message << "Create a GitLab account first, and then connect it to your #{label} account."
+ message << _("Create a GitLab account first, and then connect it to your %{label} account.") % { label: label }
end
flash[:notice] = message.join(' ')
@@ -166,14 +173,14 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
end
def fail_auth0_login
- flash[:alert] = 'Wrong extern UID provided. Make sure Auth0 is configured correctly.'
+ flash[:alert] = _('Wrong extern UID provided. Make sure Auth0 is configured correctly.')
redirect_to new_user_session_path
end
def handle_disabled_provider
label = Gitlab::Auth::OAuth::Provider.label_for(oauth['provider'])
- flash[:alert] = "Signing in using #{label} has been disabled"
+ flash[:alert] = _("Signing in using %{label} has been disabled") % { label: label }
redirect_to new_user_session_path
end
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
index 28f113b5cbe..77de5cb45c9 100644
--- a/app/controllers/passwords_controller.rb
+++ b/app/controllers/passwords_controller.rb
@@ -22,7 +22,7 @@ class PasswordsController < Devise::PasswordsController
).first_or_initialize
unless user.reset_password_period_valid?
- flash[:alert] = 'Your password reset token has expired.'
+ flash[:alert] = _('Your password reset token has expired.')
redirect_to(new_user_password_url(user_email: user['email']))
end
end
@@ -52,7 +52,7 @@ class PasswordsController < Devise::PasswordsController
end
redirect_to after_sending_reset_password_instructions_path_for(resource_name),
- alert: "Password authentication is unavailable."
+ alert: _("Password authentication is unavailable.")
end
def throttle_reset
diff --git a/app/controllers/profiles/accounts_controller.rb b/app/controllers/profiles/accounts_controller.rb
index b0d65f284af..b03f4b7435f 100644
--- a/app/controllers/profiles/accounts_controller.rb
+++ b/app/controllers/profiles/accounts_controller.rb
@@ -14,10 +14,10 @@ class Profiles::AccountsController < Profiles::ApplicationController
return render_404 unless identity
- if unlink_allowed?(provider)
+ if unlink_provider_allowed?(provider)
identity.destroy
else
- flash[:alert] = "You are not allowed to unlink your primary login account"
+ flash[:alert] = _("You are not allowed to unlink your primary login account")
end
redirect_to profile_account_path
diff --git a/app/controllers/profiles/active_sessions_controller.rb b/app/controllers/profiles/active_sessions_controller.rb
index efe7ede5efa..c473023cacb 100644
--- a/app/controllers/profiles/active_sessions_controller.rb
+++ b/app/controllers/profiles/active_sessions_controller.rb
@@ -2,15 +2,6 @@
class Profiles::ActiveSessionsController < Profiles::ApplicationController
def index
- @sessions = ActiveSession.list(current_user)
- end
-
- def destroy
- ActiveSession.destroy(current_user, params[:id])
-
- respond_to do |format|
- format.html { redirect_to profile_active_sessions_url, status: :found }
- format.js { head :ok }
- end
+ @sessions = ActiveSession.list(current_user).reject(&:is_impersonated)
end
end
diff --git a/app/controllers/profiles/chat_names_controller.rb b/app/controllers/profiles/chat_names_controller.rb
index 2e78b9e6dc7..80b8279e91e 100644
--- a/app/controllers/profiles/chat_names_controller.rb
+++ b/app/controllers/profiles/chat_names_controller.rb
@@ -15,9 +15,9 @@ class Profiles::ChatNamesController < Profiles::ApplicationController
new_chat_name = current_user.chat_names.new(chat_name_params)
if new_chat_name.save
- flash[:notice] = "Authorized #{new_chat_name.chat_name}"
+ flash[:notice] = _("Authorized %{new_chat_name}") % { new_chat_name: new_chat_name.chat_name }
else
- flash[:alert] = "Could not authorize chat nickname. Try again!"
+ flash[:alert] = _("Could not authorize chat nickname. Try again!")
end
delete_chat_name_token
@@ -27,7 +27,7 @@ class Profiles::ChatNamesController < Profiles::ApplicationController
def deny
delete_chat_name_token
- flash[:notice] = "Denied authorization of chat nickname #{chat_name_params[:user_name]}."
+ flash[:notice] = _("Denied authorization of chat nickname %{user_name}.") % { user_name: chat_name_params[:user_name] }
redirect_to profile_chat_names_path
end
@@ -36,9 +36,9 @@ class Profiles::ChatNamesController < Profiles::ApplicationController
@chat_name = chat_names.find(params[:id])
if @chat_name.destroy
- flash[:notice] = "Deleted chat nickname: #{@chat_name.chat_name}!"
+ flash[:notice] = _("Deleted chat nickname: %{chat_name}!") % { chat_name: @chat_name.chat_name }
else
- flash[:alert] = "Could not delete chat nickname #{@chat_name.chat_name}."
+ flash[:alert] = _("Could not delete chat nickname %{chat_name}.") % { chat_name: @chat_name.chat_name }
end
redirect_to profile_chat_names_path, status: :found
diff --git a/app/controllers/profiles/groups_controller.rb b/app/controllers/profiles/groups_controller.rb
new file mode 100644
index 00000000000..c755bcb718a
--- /dev/null
+++ b/app/controllers/profiles/groups_controller.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class Profiles::GroupsController < Profiles::ApplicationController
+ include RoutableActions
+
+ def update
+ group = find_routable!(Group, params[:id])
+ notification_setting = current_user.notification_settings.find_by(source: group) # rubocop: disable CodeReuse/ActiveRecord
+
+ if notification_setting.update(update_params)
+ flash[:notice] = "Notification settings for #{group.name} saved"
+ else
+ flash[:alert] = "Failed to save new settings for #{group.name}"
+ end
+
+ redirect_back_or_default(default: profile_notifications_path)
+ end
+
+ private
+
+ def update_params
+ params.require(:notification_setting).permit(:notification_email)
+ end
+end
diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb
index b719b70c56e..617e5bb7cb3 100644
--- a/app/controllers/profiles/notifications_controller.rb
+++ b/app/controllers/profiles/notifications_controller.rb
@@ -14,9 +14,9 @@ class Profiles::NotificationsController < Profiles::ApplicationController
result = Users::UpdateService.new(current_user, user_params.merge(user: current_user)).execute
if result[:status] == :success
- flash[:notice] = "Notification settings saved"
+ flash[:notice] = _("Notification settings saved")
else
- flash[:alert] = "Failed to save new settings"
+ flash[:alert] = _("Failed to save new settings")
end
redirect_back_or_default(default: profile_notifications_path)
diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb
index a0391d677c4..d2787c2e450 100644
--- a/app/controllers/profiles/passwords_controller.rb
+++ b/app/controllers/profiles/passwords_controller.rb
@@ -14,7 +14,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController
def create
unless @user.password_automatically_set || @user.valid_password?(user_params[:current_password])
- redirect_to new_profile_password_path, alert: 'You must provide a valid current password'
+ redirect_to new_profile_password_path, alert: _('You must provide a valid current password')
return
end
@@ -29,7 +29,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController
if result[:status] == :success
Users::UpdateService.new(current_user, user: @user, password_expires_at: nil).execute
- redirect_to root_path, notice: 'Password successfully changed'
+ redirect_to root_path, notice: _('Password successfully changed')
else
render :new
end
@@ -45,24 +45,24 @@ class Profiles::PasswordsController < Profiles::ApplicationController
password_attributes[:password_automatically_set] = false
unless @user.password_automatically_set || @user.valid_password?(user_params[:current_password])
- redirect_to edit_profile_password_path, alert: 'You must provide a valid current password'
+ redirect_to edit_profile_password_path, alert: _('You must provide a valid current password')
return
end
result = Users::UpdateService.new(current_user, password_attributes.merge(user: @user)).execute
if result[:status] == :success
- flash[:notice] = "Password was successfully updated. Please login with it"
+ flash[:notice] = _('Password was successfully updated. Please login with it')
redirect_to new_user_session_path
else
- @user.reload
+ @user.reset
render 'edit'
end
end
def reset
current_user.send_reset_password_instructions
- redirect_to edit_profile_password_path, notice: 'We sent you an email with reset password instructions'
+ redirect_to edit_profile_password_path, notice: _('We sent you an email with reset password instructions')
end
private
diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb
index 4b6ec2697b7..f1c07cd9a1d 100644
--- a/app/controllers/profiles/personal_access_tokens_controller.rb
+++ b/app/controllers/profiles/personal_access_tokens_controller.rb
@@ -11,7 +11,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
if @personal_access_token.save
PersonalAccessToken.redis_store!(current_user.id, @personal_access_token.token)
- redirect_to profile_personal_access_tokens_path, notice: "Your new personal access token has been created."
+ redirect_to profile_personal_access_tokens_path, notice: _("Your new personal access token has been created.")
else
set_index_vars
render :index
@@ -22,9 +22,9 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
@personal_access_token = finder.find(params[:id])
if @personal_access_token.revoke!
- flash[:notice] = "Revoked personal access token #{@personal_access_token.name}!"
+ flash[:notice] = _("Revoked personal access token %{personal_access_token_name}!") % { personal_access_token_name: @personal_access_token.name }
else
- flash[:alert] = "Could not revoke personal access token #{@personal_access_token.name}."
+ flash[:alert] = _("Could not revoke personal access token %{personal_access_token_name}.") % { personal_access_token_name: @personal_access_token.name }
end
redirect_to profile_personal_access_tokens_path
@@ -42,7 +42,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def set_index_vars
- @scopes = Gitlab::Auth.available_scopes(current_user)
+ @scopes = Gitlab::Auth.available_scopes_for(current_user)
@inactive_personal_access_tokens = finder(state: 'inactive').execute
@active_personal_access_tokens = finder(state: 'active').execute.order(:expires_at)
diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb
index 0227af2c266..62f98d9e549 100644
--- a/app/controllers/profiles/preferences_controller.rb
+++ b/app/controllers/profiles/preferences_controller.rb
@@ -11,13 +11,13 @@ class Profiles::PreferencesController < Profiles::ApplicationController
result = Users::UpdateService.new(current_user, preferences_params.merge(user: user)).execute
if result[:status] == :success
- flash[:notice] = 'Preferences saved.'
+ flash[:notice] = _('Preferences saved.')
else
- flash[:alert] = 'Failed to save preferences.'
+ flash[:alert] = _('Failed to save preferences.')
end
rescue ArgumentError => e
# Raised when `dashboard` is given an invalid value.
- flash[:alert] = "Failed to save preferences (#{e.message})."
+ flash[:alert] = _("Failed to save preferences (%{error_message}).") % { error_message: e.message }
end
respond_to do |format|
@@ -44,7 +44,9 @@ class Profiles::PreferencesController < Profiles::ApplicationController
:project_view,
:theme_id,
:first_day_of_week,
- :preferred_language
+ :preferred_language,
+ :time_display_relative,
+ :time_format_in_24h
]
end
end
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index ba94196b2f9..95b9344c551 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -18,21 +18,16 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
two_factor_authentication_reason(
global: lambda do
flash.now[:alert] =
- 'The global settings require you to enable Two-Factor Authentication for your account.'
+ _('The global settings require you to enable Two-Factor Authentication for your account.')
end,
group: lambda do |groups|
- group_links = groups.map { |group| view_context.link_to group.full_name, group_path(group) }.to_sentence
-
- flash.now[:alert] = %{
- The group settings for #{group_links} require you to enable
- Two-Factor Authentication for your account.
- }.html_safe
+ flash.now[:alert] = groups_notification(groups)
end
)
unless two_factor_grace_period_expired?
grace_period_deadline = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
- flash.now[:alert] = flash.now[:alert] + " You need to do this before #{l(grace_period_deadline)}."
+ flash.now[:alert] = flash.now[:alert] + _(" You need to do this before %{grace_period_deadline}.") % { grace_period_deadline: l(grace_period_deadline) }
end
end
@@ -49,7 +44,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
render 'create'
else
- @error = 'Invalid pin code'
+ @error = _('Invalid pin code')
@qr_code = build_qr_code
setup_u2f_registration
render 'show'
@@ -63,7 +58,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
if @u2f_registration.persisted?
session.delete(:challenges)
- redirect_to profile_two_factor_auth_path, notice: "Your U2F device was registered!"
+ redirect_to profile_two_factor_auth_path, notice: s_("Your U2F device was registered!")
else
@qr_code = build_qr_code
setup_u2f_registration
@@ -85,7 +80,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
def skip
if two_factor_grace_period_expired?
- redirect_to new_profile_two_factor_auth_path, alert: 'Cannot skip two factor authentication setup'
+ redirect_to new_profile_two_factor_auth_path, alert: s_('Cannot skip two factor authentication setup')
else
session[:skip_two_factor] = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
redirect_to root_path
@@ -126,4 +121,12 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
def u2f_registration_params
params.require(:u2f_registration).permit(:device_response, :name)
end
+
+ def groups_notification(groups)
+ group_links = groups.map { |group| view_context.link_to group.full_name, group_path(group) }.to_sentence
+ leave_group_links = groups.map { |group| view_context.link_to (s_("leave %{group_name}") % { group_name: group.full_name }), leave_group_members_path(group), remote: false, method: :delete}.to_sentence
+
+ s_(%{The group settings for %{group_links} require you to enable Two-Factor Authentication for your account. You can %{leave_group_links}.})
+ .html_safe % { group_links: group_links.html_safe, leave_group_links: leave_group_links.html_safe }
+ end
end
diff --git a/app/controllers/profiles/u2f_registrations_controller.rb b/app/controllers/profiles/u2f_registrations_controller.rb
index e6a154fb6aa..866c4dee6e2 100644
--- a/app/controllers/profiles/u2f_registrations_controller.rb
+++ b/app/controllers/profiles/u2f_registrations_controller.rb
@@ -4,6 +4,6 @@ class Profiles::U2fRegistrationsController < Profiles::ApplicationController
def destroy
u2f_registration = current_user.u2f_registrations.find(params[:id])
u2f_registration.destroy
- redirect_to profile_two_factor_auth_path, status: 302, notice: "Successfully deleted U2F device."
+ redirect_to profile_two_factor_auth_path, status: 302, notice: _("Successfully deleted U2F device.")
end
end
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index b9c52618d4b..1d16ddb1608 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -15,7 +15,7 @@ class ProfilesController < Profiles::ApplicationController
result = Users::UpdateService.new(current_user, user_params.merge(user: @user)).execute
if result[:status] == :success
- message = "Profile was successfully updated"
+ message = s_("Profiles|Profile was successfully updated")
format.html { redirect_back_or_default(default: { action: 'show' }, options: { notice: message }) }
format.json { render json: { message: message } }
@@ -31,7 +31,7 @@ class ProfilesController < Profiles::ApplicationController
user.reset_incoming_email_token!
end
- flash[:notice] = "Incoming email token was successfully reset"
+ flash[:notice] = s_("Profiles|Incoming email token was successfully reset")
redirect_to profile_personal_access_tokens_path
end
@@ -41,7 +41,7 @@ class ProfilesController < Profiles::ApplicationController
user.reset_feed_token!
end
- flash[:notice] = 'Feed token was successfully reset'
+ flash[:notice] = s_('Profiles|Feed token was successfully reset')
redirect_to profile_personal_access_tokens_path
end
@@ -106,6 +106,7 @@ class ProfilesController < Profiles::ApplicationController
:organization,
:private_profile,
:include_private_contributions,
+ :timezone,
status: [:emoji, :message]
)
end
diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb
index e0677ce3fbc..80e4f54bbf4 100644
--- a/app/controllers/projects/application_controller.rb
+++ b/app/controllers/projects/application_controller.rb
@@ -3,7 +3,6 @@
class Projects::ApplicationController < ApplicationController
include CookiesHelper
include RoutableActions
- include ProjectUnauthorized
include ChecksCollaboration
skip_before_action :authenticate_user!
@@ -17,12 +16,12 @@ class Projects::ApplicationController < ApplicationController
def project
return @project if @project
- return nil unless params[:project_id] || params[:id]
+ return unless params[:project_id] || params[:id]
path = File.join(params[:namespace_id], params[:project_id] || params[:id])
auth_proc = ->(project) { !project.pending_delete? }
- @project = find_routable!(Project, path, extra_authorization_proc: auth_proc, not_found_or_authorized_proc: project_unauthorized_proc)
+ @project = find_routable!(Project, path, extra_authorization_proc: auth_proc)
end
def build_canonical_path(project)
@@ -88,4 +87,10 @@ class Projects::ApplicationController < ApplicationController
def check_issues_available!
return render_404 unless @project.feature_available?(:issues, current_user)
end
+
+ def allow_gitaly_ref_name_caching
+ ::Gitlab::GitalyClient.allow_ref_name_caching do
+ yield
+ end
+ end
end
diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb
index 9c130af8394..0e3f13045ce 100644
--- a/app/controllers/projects/autocomplete_sources_controller.rb
+++ b/app/controllers/projects/autocomplete_sources_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Projects::AutocompleteSourcesController < Projects::ApplicationController
+ before_action :authorize_read_milestone!, only: :milestones
+
def members
render json: ::Projects::ParticipantsService.new(@project, current_user).execute(target)
end
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 77672e7d9fc..b04ffe80db4 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -9,6 +9,8 @@ class Projects::BlobController < Projects::ApplicationController
include ActionView::Helpers::SanitizeHelper
prepend_before_action :authenticate_user!, only: [:edit]
+ around_action :allow_gitaly_ref_name_caching, only: [:show]
+
before_action :require_non_empty_project, except: [:new, :create]
before_action :authorize_download_code!
@@ -29,7 +31,7 @@ class Projects::BlobController < Projects::ApplicationController
end
def create
- create_commit(Files::CreateService, success_notice: "The file has been successfully created.",
+ create_commit(Files::CreateService, success_notice: _("The file has been successfully created."),
success_path: -> { project_blob_path(@project, File.join(@branch_name, @file_path)) },
failure_view: :new,
failure_path: project_new_blob_path(@project, @ref))
@@ -81,7 +83,7 @@ class Projects::BlobController < Projects::ApplicationController
end
def destroy
- create_commit(Files::DeleteService, success_notice: "The file has been successfully deleted.",
+ create_commit(Files::DeleteService, success_notice: _("The file has been successfully deleted."),
success_path: -> { after_delete_path },
failure_view: :show,
failure_path: project_blob_path(@project, @id))
@@ -90,65 +92,21 @@ class Projects::BlobController < Projects::ApplicationController
def diff
apply_diff_view_cookie!
- @blob.load_all_data!
- @lines = @blob.present.highlight.lines
-
- @form = UnfoldForm.new(params.to_unsafe_h)
-
- @lines = @lines[@form.since - 1..@form.to - 1].map(&:html_safe)
-
- if @form.bottom?
- @match_line = ''
- else
- lines_length = @lines.length - 1
- line = [@form.since, lines_length].join(',')
- @match_line = "@@ -#{line}+#{line} @@"
- end
+ @form = Blobs::UnfoldPresenter.new(blob, params.to_unsafe_h)
- # We can keep only 'render_diff_lines' from this conditional when
+ # keep only json rendering when
# https://gitlab.com/gitlab-org/gitlab-ce/issues/44988 is done
if rendered_for_merge_request?
- render_diff_lines
+ render json: DiffLineSerializer.new.represent(@form.diff_lines)
else
+ @lines = @form.lines
+ @match_line = @form.match_line_text
render layout: false
end
end
private
- # Converts a String array to Gitlab::Diff::Line array
- def render_diff_lines
- @lines.map! do |line|
- # These are marked as context lines but are loaded from blobs.
- # We also have context lines loaded from diffs in other places.
- diff_line = Gitlab::Diff::Line.new(line, nil, nil, nil, nil)
- diff_line.rich_text = line
- diff_line
- end
-
- add_match_line
-
- render json: DiffLineSerializer.new.represent(@lines)
- end
-
- def add_match_line
- return unless @form.unfold?
-
- if @form.bottom? && @form.to < @blob.lines.size
- old_pos = @form.to - @form.offset
- new_pos = @form.to
- elsif @form.since != 1
- old_pos = new_pos = @form.since
- end
-
- # Match line is not needed when it reaches the top limit or bottom limit of the file.
- return unless new_pos
-
- @match_line = Gitlab::Diff::Line.new(@match_line, 'match', nil, old_pos, new_pos)
-
- @form.bottom? ? @lines.push(@match_line) : @lines.unshift(@match_line)
- end
-
def blob
@blob ||= @repository.blob_at(@commit.id, @path)
@@ -216,8 +174,7 @@ class Projects::BlobController < Projects::ApplicationController
end
if params[:file].present?
- params[:content] = Base64.encode64(params[:file].read)
- params[:encoding] = 'base64'
+ params[:content] = params[:file]
end
@commit_params = {
@@ -231,6 +188,8 @@ class Projects::BlobController < Projects::ApplicationController
end
def validate_diff_params
+ return if params[:full]
+
if [:since, :to, :offset].any? { |key| params[key].blank? }
head :ok
end
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index 8189b5d182a..95897aaf980 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -1,34 +1,15 @@
# frozen_string_literal: true
class Projects::BoardsController < Projects::ApplicationController
- include BoardsResponses
+ include BoardsActions
include IssuableCollections
before_action :check_issues_available!
before_action :authorize_read_board!, only: [:index, :show]
- before_action :boards, only: :index
before_action :assign_endpoint_vars
- before_action :redirect_to_recent_board, only: :index
-
- def index
- respond_with_boards
- end
-
- def show
- @board = boards.find(params[:id])
-
- # add/update the board in the recent visited table
- Boards::Visits::CreateService.new(@board.project, current_user).execute(@board) if request.format.html?
-
- respond_with_board
- end
private
- def boards
- @boards ||= Boards::ListService.new(project, current_user).execute
- end
-
def assign_endpoint_vars
@boards_endpoint = project_boards_path(project)
@bulk_issues_path = bulk_update_project_issues_path(project)
@@ -39,22 +20,4 @@ class Projects::BoardsController < Projects::ApplicationController
def authorize_read_board!
access_denied! unless can?(current_user, :read_board, project)
end
-
- def serialize_as_json(resource)
- resource.as_json(only: [:id])
- end
-
- def includes_board?(board_id)
- boards.any? { |board| board.id == board_id }
- end
-
- def redirect_to_recent_board
- return if request.format.json?
-
- recently_visited = Boards::Visits::LatestService.new(project, current_user).execute
-
- if recently_visited && includes_board?(recently_visited.board_id)
- redirect_to(namespace_project_board_path(id: recently_visited.board_id), status: :found)
- end
- end
end
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index 32b7f3207ef..fc708400657 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -25,7 +25,7 @@ class Projects::BranchesController < Projects::ApplicationController
@refs_pipelines = @project.ci_pipelines.latest_successful_for_refs(@branches.map(&:name))
@merged_branch_names = repository.merged_branch_names(@branches.map(&:name))
- # n+1: https://gitlab.com/gitlab-org/gitaly/issues/992
+ # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/48097
Gitlab::GitalyClient.allow_n_plus_1_calls do
@max_commits = @branches.reduce(0) do |memo, branch|
diverging_commit_counts = repository.diverging_commit_counts(branch)
@@ -53,7 +53,7 @@ class Projects::BranchesController < Projects::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def create
- branch_name = sanitize(strip_tags(params[:branch_name]))
+ branch_name = strip_tags(sanitize(params[:branch_name]))
branch_name = Addressable::URI.unescape(branch_name)
redirect_to_autodeploy = project.empty_repo? && project.deployment_platform.present?
@@ -100,14 +100,14 @@ class Projects::BranchesController < Projects::ApplicationController
respond_to do |format|
format.html do
- flash_type = result[:status] == :error ? :alert : :notice
- flash[flash_type] = result[:message]
+ flash_type = result.error? ? :alert : :notice
+ flash[flash_type] = result.message
redirect_to project_branches_path(@project), status: :see_other
end
- format.js { head result[:return_code] }
- format.json { render json: { message: result[:message] }, status: result[:return_code] }
+ format.js { head result.http_status }
+ format.json { render json: { message: result.message }, status: result.http_status }
end
end
@@ -115,14 +115,14 @@ class Projects::BranchesController < Projects::ApplicationController
DeleteMergedBranchesService.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.'
+ notice: _('Merged branches are being deleted. This can take some time depending on the number of branches. Please refresh the page to see changes.')
end
private
def ref
if params[:ref]
- ref_escaped = sanitize(strip_tags(params[:ref]))
+ ref_escaped = strip_tags(sanitize(params[:ref]))
Addressable::URI.unescape(ref_escaped)
else
@project.default_branch || 'master'
@@ -143,7 +143,7 @@ class Projects::BranchesController < Projects::ApplicationController
def redirect_for_legacy_index_sort_or_search
# Normalize a legacy URL with redirect
if request.format != :json && !params[:state].presence && [:sort, :search, :page].any? { |key| params[key].presence }
- redirect_to project_branches_filtered_path(@project, state: 'all'), notice: 'Update your bookmarked URLs as filtered/sorted branches URL has been changed.'
+ redirect_to project_branches_filtered_path(@project, state: 'all'), notice: _('Update your bookmarked URLs as filtered/sorted branches URL has been changed.')
end
end
diff --git a/app/controllers/projects/clusters/applications_controller.rb b/app/controllers/projects/clusters/applications_controller.rb
index c7b6218d007..2a04b007304 100644
--- a/app/controllers/projects/clusters/applications_controller.rb
+++ b/app/controllers/projects/clusters/applications_controller.rb
@@ -1,8 +1,6 @@
# frozen_string_literal: true
class Projects::Clusters::ApplicationsController < Clusters::ApplicationsController
- include ProjectUnauthorized
-
prepend_before_action :project
private
@@ -12,6 +10,6 @@ class Projects::Clusters::ApplicationsController < Clusters::ApplicationsControl
end
def project
- @project ||= find_routable!(Project, File.join(params[:namespace_id], params[:project_id]), not_found_or_authorized_proc: project_unauthorized_proc)
+ @project ||= find_routable!(Project, File.join(params[:namespace_id], params[:project_id]))
end
end
diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb
index feda6deeaa6..98cd66cf6f9 100644
--- a/app/controllers/projects/clusters_controller.rb
+++ b/app/controllers/projects/clusters_controller.rb
@@ -1,11 +1,13 @@
# frozen_string_literal: true
class Projects::ClustersController < Clusters::ClustersController
- include ProjectUnauthorized
-
prepend_before_action :project
before_action :repository
+ before_action do
+ push_frontend_feature_flag(:prometheus_computed_alerts)
+ end
+
layout 'project'
private
@@ -15,7 +17,7 @@ class Projects::ClustersController < Clusters::ClustersController
end
def project
- @project ||= find_routable!(Project, File.join(params[:namespace_id], params[:project_id]), not_found_or_authorized_proc: project_unauthorized_proc)
+ @project ||= find_routable!(Project, File.join(params[:namespace_id], params[:project_id]))
end
def repository
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index b13c0ae3967..939a09d4fd2 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -65,7 +65,11 @@ class Projects::CommitController < Projects::ApplicationController
# rubocop: enable CodeReuse/ActiveRecord
def merge_requests
- @merge_requests = @commit.merge_requests.map do |mr|
+ @merge_requests = MergeRequestsFinder.new(
+ current_user,
+ project_id: @project.id,
+ commit_sha: @commit.sha
+ ).execute.map do |mr|
{ iid: mr.iid, path: merge_request_path(mr), title: mr.title }
end
diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb
index 2510a31c9b3..f540ccee386 100644
--- a/app/controllers/projects/commits_controller.rb
+++ b/app/controllers/projects/commits_controller.rb
@@ -7,6 +7,7 @@ class Projects::CommitsController < Projects::ApplicationController
include RendersCommits
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
+ around_action :allow_gitaly_ref_name_caching
before_action :whitelist_query_limiting, except: :commits_root
before_action :require_non_empty_project
before_action :assign_ref_vars, except: :commits_root
diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb
index 6824a07dc76..514b03e23b5 100644
--- a/app/controllers/projects/deploy_keys_controller.rb
+++ b/app/controllers/projects/deploy_keys_controller.rb
@@ -38,7 +38,7 @@ class Projects::DeployKeysController < Projects::ApplicationController
def update
if deploy_key.update(update_params)
- flash[:notice] = 'Deploy key was successfully updated.'
+ flash[:notice] = _('Deploy key was successfully updated.')
redirect_to_repository_settings(@project, anchor: 'js-deploy-keys-settings')
else
render 'edit'
diff --git a/app/controllers/projects/environments/prometheus_api_controller.rb b/app/controllers/projects/environments/prometheus_api_controller.rb
new file mode 100644
index 00000000000..f8ef23cd83e
--- /dev/null
+++ b/app/controllers/projects/environments/prometheus_api_controller.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+class Projects::Environments::PrometheusApiController < Projects::ApplicationController
+ before_action :authorize_read_prometheus!
+ before_action :environment
+
+ def proxy
+ result = Prometheus::ProxyService.new(
+ environment,
+ proxy_method,
+ proxy_path,
+ proxy_params
+ ).execute
+
+ if result.nil?
+ return render status: :accepted, json: {
+ status: _('processing'),
+ message: _('Not ready yet. Try again later.')
+ }
+ end
+
+ if result[:status] == :success
+ render status: result[:http_status], json: result[:body]
+ else
+ render(
+ status: result[:http_status] || :bad_request,
+ json: { status: result[:status], message: result[:message] }
+ )
+ end
+ end
+
+ private
+
+ def query_context
+ Gitlab::Prometheus::QueryVariables.call(environment)
+ end
+
+ def environment
+ @environment ||= project.environments.find(params[:id])
+ end
+
+ def proxy_method
+ request.method
+ end
+
+ 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_controller.rb b/app/controllers/projects/environments_controller.rb
index e9cd475a199..e002a4d349b 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -10,6 +10,12 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics]
before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action :expire_etag_cache, only: [:index]
+ before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do
+ push_frontend_feature_flag(:environment_metrics_use_prometheus_endpoint)
+ push_frontend_feature_flag(:environment_metrics_show_multiple_dashboards)
+ push_frontend_feature_flag(:grafana_dashboard_link)
+ push_frontend_feature_flag(:prometheus_computed_alerts)
+ end
def index
@environments = project.environments
@@ -114,7 +120,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
terminal = environment.terminals.try(:first)
if terminal
set_workhorse_internal_api_content_type
- render json: Gitlab::Workhorse.terminal_websocket(terminal)
+ render json: Gitlab::Workhorse.channel_websocket(terminal)
else
render html: 'Not found', status: :not_found
end
@@ -131,13 +137,13 @@ class Projects::EnvironmentsController < Projects::ApplicationController
end
def metrics
- # Currently, this acts as a hint to load the metrics details into the cache
- # if they aren't there already
- @metrics = environment.metrics || {}
-
respond_to do |format|
format.html
format.json do
+ # Currently, this acts as a hint to load the metrics details into the cache
+ # if they aren't there already
+ @metrics = environment.metrics || {}
+
render json: @metrics, status: @metrics.any? ? :ok : :no_content
end
end
@@ -146,13 +152,40 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def additional_metrics
respond_to do |format|
format.json do
- additional_metrics = environment.additional_metrics || {}
+ additional_metrics = environment.additional_metrics(*metrics_params) || {}
render json: additional_metrics, status: additional_metrics.any? ? :ok : :no_content
end
end
end
+ def metrics_dashboard
+ return render_403 unless Feature.enabled?(:environment_metrics_use_prometheus_endpoint, project)
+
+ if Feature.enabled?(:environment_metrics_show_multiple_dashboards, project)
+ result = dashboard_finder.find(project, current_user, environment, params[:dashboard])
+
+ result[:all_dashboards] = project.repository.metrics_dashboard_paths
+ else
+ result = dashboard_finder.find(project, current_user, environment)
+ end
+
+ respond_to do |format|
+ if result[:status] == :success
+ format.json do
+ render status: :ok, json: result.slice(:all_dashboards, :dashboard, :status)
+ end
+ else
+ format.json do
+ render(
+ status: result[:http_status],
+ json: result.slice(:all_dashboards, :message, :status)
+ )
+ end
+ end
+ end
+ end
+
def search
respond_to do |format|
format.json do
@@ -186,6 +219,14 @@ class Projects::EnvironmentsController < Projects::ApplicationController
@environment ||= project.environments.find(params[:id])
end
+ def metrics_params
+ params.require([:start, :end])
+ end
+
+ def dashboard_finder
+ Gitlab::Metrics::Dashboard::Finder
+ end
+
def search_environment_names
return [] unless params[:query]
diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb
index d439db97252..956093b972b 100644
--- a/app/controllers/projects/git_http_client_controller.rb
+++ b/app/controllers/projects/git_http_client_controller.rb
@@ -15,6 +15,7 @@ class Projects::GitHttpClientController < Projects::ApplicationController
alias_method :authenticated_user, :actor
# Git clients will not know what authenticity token to send along
+ skip_around_action :set_session_storage
skip_before_action :verify_authenticity_token
skip_before_action :repository
before_action :authenticate_user
@@ -78,24 +79,28 @@ class Projects::GitHttpClientController < Projects::ApplicationController
end
def parse_repo_path
- @project, @wiki, @redirected_path = Gitlab::RepoPath.parse("#{params[:namespace_id]}/#{params[:project_id]}")
+ @project, @repo_type, @redirected_path = Gitlab::RepoPath.parse("#{params[:namespace_id]}/#{params[:project_id]}")
end
def render_missing_personal_access_token
render plain: "HTTP Basic: Access denied\n" \
- "You must use a personal access token with 'api' scope for Git over HTTP.\n" \
+ "You must use a personal access token with 'read_repository' or 'write_repository' scope for Git over HTTP.\n" \
"You can generate one at #{profile_personal_access_tokens_url}",
status: :unauthorized
end
def repository
- wiki? ? project.wiki.repository : project.repository
+ repo_type.repository_for(project)
end
def wiki?
- parse_repo_path unless defined?(@wiki)
+ repo_type.wiki?
+ end
+
+ def repo_type
+ parse_repo_path unless defined?(@repo_type)
- @wiki
+ @repo_type
end
def handle_basic_authentication(login, password)
diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb
index 30e436365de..e519cc1f158 100644
--- a/app/controllers/projects/git_http_controller.rb
+++ b/app/controllers/projects/git_http_controller.rb
@@ -4,6 +4,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController
include WorkhorseRequest
before_action :access_check
+ prepend_before_action :deny_head_requests, only: [:info_refs]
rescue_from Gitlab::GitAccess::UnauthorizedError, with: :render_403
rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404
@@ -20,6 +21,8 @@ class Projects::GitHttpController < Projects::GitHttpClientController
# POST /foo/bar.git/git-upload-pack (git pull)
def git_upload_pack
+ enqueue_fetch_statistics_update
+
render_ok
end
@@ -30,6 +33,10 @@ class Projects::GitHttpController < Projects::GitHttpClientController
private
+ def deny_head_requests
+ head :forbidden if request.head?
+ end
+
def download_request?
upload_pack?
end
@@ -48,7 +55,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController
def render_ok
set_workhorse_internal_api_content_type
- render json: Gitlab::Workhorse.git_http_ok(repository, wiki?, user, action_name)
+ render json: Gitlab::Workhorse.git_http_ok(repository, repo_type, user, action_name)
end
def render_403(exception)
@@ -67,6 +74,13 @@ class Projects::GitHttpController < Projects::GitHttpClientController
render plain: exception.message, status: :service_unavailable
end
+ def enqueue_fetch_statistics_update
+ return if wiki?
+ return unless project.daily_statistics_enabled?
+
+ ProjectDailyStatisticsWorker.perform_async(project.id)
+ end
+
def access
@access ||= access_klass.new(access_actor, project,
'http', authentication_abilities: authentication_abilities,
@@ -85,7 +99,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController
end
def access_klass
- @access_klass ||= wiki? ? Gitlab::GitAccessWiki : Gitlab::GitAccess
+ @access_klass ||= repo_type.access_checker_class
end
def project_path
diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb
index c80fce513f6..67d3f49af18 100644
--- a/app/controllers/projects/graphs_controller.rb
+++ b/app/controllers/projects/graphs_controller.rb
@@ -46,12 +46,8 @@ class Projects::GraphsController < Projects::ApplicationController
def get_languages
@languages =
- if @project.repository_languages.present?
- @project.repository_languages.map do |lang|
- { value: lang.share, label: lang.name, color: lang.color, highlight: lang.color }
- end
- else
- @project.repository.languages
+ ::Projects::RepositoryLanguagesService.new(@project, current_user).execute.map do |lang|
+ { value: lang.share, label: lang.name, color: lang.color, highlight: lang.color }
end
end
diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb
index 7c713c19762..dc65f9959db 100644
--- a/app/controllers/projects/group_links_controller.rb
+++ b/app/controllers/projects/group_links_controller.rb
@@ -13,11 +13,12 @@ class Projects::GroupLinksController < Projects::ApplicationController
group = Group.find(params[:link_group_id]) if params[:link_group_id].present?
if group
- return render_404 unless can?(current_user, :read_group, group)
+ result = Projects::GroupLinks::CreateService.new(project, current_user, group_link_create_params).execute(group)
+ return render_404 if result[:http_status] == 404
- Projects::GroupLinks::CreateService.new(project, current_user, group_link_create_params).execute(group)
+ flash[:alert] = result[:message] if result[:http_status] == 409
else
- flash[:alert] = 'Please select a group.'
+ flash[:alert] = _('Please select a group.')
end
redirect_to project_project_members_path(project)
diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb
index bc84418b79f..5fa0339f44d 100644
--- a/app/controllers/projects/hooks_controller.rb
+++ b/app/controllers/projects/hooks_controller.rb
@@ -32,7 +32,7 @@ class Projects::HooksController < Projects::ApplicationController
def update
if hook.update(hook_params)
- flash[:notice] = 'Hook was successfully updated.'
+ flash[:notice] = _('Hook was successfully updated.')
redirect_to project_settings_integrations_path(@project)
else
render 'edit'
diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb
index 8b33fa85c1e..afbf9fd7720 100644
--- a/app/controllers/projects/imports_controller.rb
+++ b/app/controllers/projects/imports_controller.rb
@@ -2,6 +2,7 @@
class Projects::ImportsController < Projects::ApplicationController
include ContinueParams
+ include ImportUrlParams
# Authorize
before_action :authorize_admin_project!
@@ -14,7 +15,7 @@ class Projects::ImportsController < Projects::ApplicationController
def create
if @project.update(import_params)
- @project.import_state.reload.schedule
+ @project.import_state.reset.schedule
end
redirect_to project_import_path(@project)
@@ -42,9 +43,9 @@ class Projects::ImportsController < Projects::ApplicationController
def finished_notice
if @project.forked?
- 'The project was successfully forked.'
+ _('The project was successfully forked.')
else
- 'The project was successfully imported.'
+ _('The project was successfully imported.')
end
end
@@ -67,10 +68,12 @@ class Projects::ImportsController < Projects::ApplicationController
end
def import_params_attributes
- [:import_url]
+ []
end
def import_params
- params.require(:project).permit(import_params_attributes)
+ params.require(:project)
+ .permit(import_params_attributes)
+ .merge(import_url_params)
end
end
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index b9d02a62fc3..b4d89db20c5 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -10,11 +10,11 @@ class Projects::IssuesController < Projects::ApplicationController
include SpammableActions
include RecordUserLastActivity
- def self.issue_except_actions
+ def issue_except_actions
%i[index calendar new create bulk_update import_csv]
end
- def self.set_issuables_index_only_actions
+ def set_issuables_index_only_actions
%i[index calendar]
end
@@ -25,9 +25,9 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :whitelist_query_limiting, only: [:create, :create_merge_request, :move, :bulk_update]
before_action :check_issues_available!
- before_action :issue, except: issue_except_actions
+ before_action :issue, unless: ->(c) { c.issue_except_actions.include?(c.action_name.to_sym) }
- before_action :set_issuables_index, only: set_issuables_index_only_actions
+ before_action :set_issuables_index, if: ->(c) { c.set_issuables_index_only_actions.include?(c.action_name.to_sym) }
# Allow write(create) issue
before_action :authorize_create_issue!, only: [:new, :create]
@@ -39,6 +39,7 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :authorize_create_merge_request_from!, only: [:create_merge_request]
before_action :authorize_import_issues!, only: [:import_csv]
+ before_action :authorize_download_code!, only: [:related_branches]
before_action :set_suggested_issues_feature_flags, only: [:new]
@@ -95,9 +96,9 @@ class Projects::IssuesController < Projects::ApplicationController
if service.discussions_to_resolve.count(&:resolved?) > 0
flash[:notice] = if service.discussion_to_resolve_id
- "Resolved 1 discussion."
+ _("Resolved 1 discussion.")
else
- "Resolved all discussions."
+ _("Resolved all discussions.")
end
end
@@ -131,18 +132,6 @@ class Projects::IssuesController < Projects::ApplicationController
render_conflict_response
end
- def referenced_merge_requests
- @merge_requests, @closed_by_merge_requests = ::Issues::ReferencedMergeRequestsService.new(project, current_user).execute(issue)
-
- respond_to do |format|
- format.json do
- render json: {
- html: view_to_html_string('projects/issues/_merge_requests')
- }
- end
- end
- end
-
def related_branches
@related_branches = Issues::RelatedBranchesService.new(project, current_user).execute(issue)
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index d5ce790e2d9..2a4933e7bc2 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -122,7 +122,7 @@ class Projects::JobsController < Projects::ApplicationController
def erase
if @build.erase(erased_by: current_user)
redirect_to project_job_path(project, @build),
- notice: "Job has been successfully erased!"
+ notice: _("Job has been successfully erased!")
else
respond_422
end
@@ -157,7 +157,7 @@ class Projects::JobsController < Projects::ApplicationController
# GET .../terminal.ws : implemented in gitlab-workhorse
def terminal_websocket_authorize
set_workhorse_internal_api_content_type
- render json: Gitlab::Workhorse.terminal_websocket(@build.terminal_specification)
+ render json: Gitlab::Workhorse.channel_websocket(@build.terminal_specification)
end
private
diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb
index 640038818f2..386a1f00bd2 100644
--- a/app/controllers/projects/labels_controller.rb
+++ b/app/controllers/projects/labels_controller.rb
@@ -132,7 +132,7 @@ class Projects::LabelsController < Projects::ApplicationController
respond_to do |format|
format.html do
redirect_to(project_labels_path(@project),
- notice: 'Failed to promote label due to internal error. Please contact administrators.')
+ notice: _('Failed to promote label due to internal error. Please contact administrators.'))
end
format.js
end
diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb
index be40077d389..42c415757f9 100644
--- a/app/controllers/projects/lfs_api_controller.rb
+++ b/app/controllers/projects/lfs_api_controller.rb
@@ -26,7 +26,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController
def deprecated
render(
json: {
- message: 'Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.',
+ message: _('Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.'),
documentation_url: "#{Gitlab.config.gitlab.url}/help"
},
status: :not_implemented
@@ -62,7 +62,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController
else
object[:error] = {
code: 404,
- message: "Object does not exist on the server or you don't have permissions to access it"
+ message: _("Object does not exist on the server or you don't have permissions to access it")
}
end
end
diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb
index 6045ee4e171..f2a6268b3e9 100644
--- a/app/controllers/projects/merge_requests/application_controller.rb
+++ b/app/controllers/projects/merge_requests/application_controller.rb
@@ -7,11 +7,15 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
private
- # rubocop: disable CodeReuse/ActiveRecord
def merge_request
- @issuable = @merge_request ||= @project.merge_requests.includes(author: :status).find_by!(iid: params[:id])
+ @issuable =
+ @merge_request ||=
+ merge_request_includes(@project.merge_requests).find_by_iid!(params[:id])
+ end
+
+ def merge_request_includes(association)
+ association.includes(:metrics, :assignees, author: :status) # rubocop:disable CodeReuse/ActiveRecord
end
- # rubocop: enable CodeReuse/ActiveRecord
def merge_request_params
params.require(:merge_request).permit(merge_request_params_attributes)
@@ -20,7 +24,6 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
def merge_request_params_attributes
[
:allow_collaboration,
- :assignee_id,
:description,
:force_remove_source_branch,
:lock_version,
@@ -35,6 +38,7 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
:title,
:discussion_locked,
label_ids: [],
+ assignee_ids: [],
update_task: [:index, :checked, :line_number, :line_source]
]
end
diff --git a/app/controllers/projects/merge_requests/conflicts_controller.rb b/app/controllers/projects/merge_requests/conflicts_controller.rb
index 045a4e974fe..011ac9a42f8 100644
--- a/app/controllers/projects/merge_requests/conflicts_controller.rb
+++ b/app/controllers/projects/merge_requests/conflicts_controller.rb
@@ -16,12 +16,12 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap
render json: @conflicts_list
elsif @merge_request.can_be_merged?
render json: {
- message: 'The merge conflicts for this merge request have already been resolved. Please return to the merge request.',
+ message: _('The merge conflicts for this merge request have already been resolved. Please return to the merge request.'),
type: 'error'
}
else
render json: {
- message: 'The merge conflicts for this merge request cannot be resolved through GitLab. Please try to resolve them locally.',
+ message: _('The merge conflicts for this merge request cannot be resolved through GitLab. Please try to resolve them locally.'),
type: 'error'
}
end
@@ -43,7 +43,7 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap
return render_404 unless @conflicts_list.can_be_resolved_in_ui?
if @merge_request.can_be_merged?
- render status: :bad_request, json: { message: 'The merge conflicts for this merge request have already been resolved.' }
+ render status: :bad_request, json: { message: _('The merge conflicts for this merge request have already been resolved.') }
return
end
@@ -52,7 +52,7 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap
.new(merge_request)
.execute(current_user, params)
- flash[:notice] = 'All merge conflicts were resolved. The merge request can now be merged.'
+ flash[:notice] = _('All merge conflicts were resolved. The merge request can now be merged.')
render json: { redirect_to: project_merge_request_url(@project, @merge_request, resolved_conflicts: true) }
rescue Gitlab::Git::Conflict::Resolver::ResolutionError => e
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index 518d41bd3fb..456d2c34768 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -46,8 +46,8 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
# rubocop: disable CodeReuse/ActiveRecord
def commit
- return nil unless commit_id = params[:commit_id].presence
- return nil unless @merge_request.all_commits.exists?(sha: commit_id)
+ return unless commit_id = params[:commit_id].presence
+ return unless @merge_request.all_commits.exists?(sha: commit_id)
@commit ||= @project.commit(commit_id)
end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 46a44841c31..135117926be 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -16,9 +16,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action :authenticate_user!, only: [:assign_related_issues]
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
- before_action only: [:show] do
- push_frontend_feature_flag(:diff_tree_filtering, default_enabled: true)
- end
+ around_action :allow_gitaly_ref_name_caching, only: [:index, :show]
def index
@merge_requests = @issuables
@@ -35,7 +33,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
def show
close_merge_request_if_no_source_project
- mark_merge_request_mergeable
+ @merge_request.check_mergeability
respond_to do |format|
format.html do
@@ -100,20 +98,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
def test_reports
- result = @merge_request.compare_test_reports
-
- case result[:status]
- when :parsing
- Gitlab::PollingInterval.set_header(response, interval: 3000)
-
- render json: '', status: :no_content
- when :parsed
- render json: result[:data].to_json, status: :ok
- when :error
- render json: { status_reason: result[:status_reason] }, status: :bad_request
- else
- render json: { status_reason: 'Unknown error' }, status: :internal_server_error
- end
+ reports_response(@merge_request.compare_test_reports)
end
def edit
@@ -160,14 +145,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
render partial: 'projects/merge_requests/widget/commit_change_content', layout: false
end
- def cancel_merge_when_pipeline_succeeds
- unless @merge_request.can_cancel_merge_when_pipeline_succeeds?(current_user)
+ def cancel_auto_merge
+ unless @merge_request.can_cancel_auto_merge?(current_user)
return access_denied!
end
- ::MergeRequests::MergeWhenPipelineSucceedsService
- .new(@project, current_user)
- .cancel(@merge_request)
+ AutoMergeService.new(project, current_user).cancel(@merge_request)
render json: serialize_widget(@merge_request)
end
@@ -244,12 +227,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
def merge_params_attributes
- [:should_remove_source_branch, :commit_message, :squash_commit_message, :squash]
+ [:should_remove_source_branch, :commit_message, :squash_commit_message, :squash, :auto_merge_strategy]
end
- def merge_when_pipeline_succeeds_active?
- params[:merge_when_pipeline_succeeds].present? &&
- @merge_request.head_pipeline && @merge_request.head_pipeline.active?
+ def auto_merge_requested?
+ # Support params[:merge_when_pipeline_succeeds] during the transition period
+ params[:auto_merge_strategy].present? || params[:merge_when_pipeline_succeeds].present?
end
def close_merge_request_if_no_source_project
@@ -268,14 +251,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
@merge_request.has_no_commits? && !@merge_request.target_branch_exists?
end
- def mark_merge_request_mergeable
- @merge_request.check_if_can_be_merged
- end
-
def merge!
- # Disable the CI check if merge_when_pipeline_succeeds is enabled since we have
+ # Disable the CI check if auto_merge_strategy is specified since we have
# to wait until CI completes to know
- unless @merge_request.mergeable?(skip_ci_check: merge_when_pipeline_succeeds_active?)
+ unless @merge_request.mergeable?(skip_ci_check: auto_merge_requested?)
return :failed
end
@@ -289,24 +268,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
@merge_request.update(merge_error: nil, squash: merge_params.fetch(:squash, false))
- if params[:merge_when_pipeline_succeeds].present?
- return :failed unless @merge_request.actual_head_pipeline
-
- if @merge_request.actual_head_pipeline.active?
- ::MergeRequests::MergeWhenPipelineSucceedsService
- .new(@project, current_user, merge_params)
- .execute(@merge_request)
-
- :merge_when_pipeline_succeeds
- elsif @merge_request.actual_head_pipeline.success?
- # This can be triggered when a user clicks the auto merge button while
- # the tests finish at about the same time
- @merge_request.merge_async(current_user.id, merge_params)
-
- :success
- else
- :failed
- end
+ if auto_merge_requested?
+ AutoMergeService.new(project, current_user, merge_params)
+ .execute(merge_request,
+ params[:auto_merge_strategy] || AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS)
else
@merge_request.merge_async(current_user.id, merge_params)
@@ -355,4 +320,19 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
# Also see https://gitlab.com/gitlab-org/gitlab-ce/issues/42441
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42438')
end
+
+ def reports_response(report_comparison)
+ case report_comparison[:status]
+ when :parsing
+ ::Gitlab::PollingInterval.set_header(response, interval: 3000)
+
+ render json: '', status: :no_content
+ when :parsed
+ render json: report_comparison[:data].to_json, status: :ok
+ when :error
+ render json: { status_reason: report_comparison[:status_reason] }, status: :bad_request
+ else
+ render json: { status_reason: 'Unknown error' }, status: :internal_server_error
+ end
+ end
end
diff --git a/app/controllers/projects/mirrors_controller.rb b/app/controllers/projects/mirrors_controller.rb
index ab7ab13657a..6c6adc233b7 100644
--- a/app/controllers/projects/mirrors_controller.rb
+++ b/app/controllers/projects/mirrors_controller.rb
@@ -18,7 +18,7 @@ class Projects::MirrorsController < Projects::ApplicationController
result = ::Projects::UpdateService.new(project, current_user, mirror_params).execute
if result[:status] == :success
- flash[:notice] = 'Mirroring settings were successfully updated.'
+ flash[:notice] = _('Mirroring settings were successfully updated.')
else
flash[:alert] = project.errors.full_messages.join(', ').html_safe
end
@@ -38,7 +38,7 @@ class Projects::MirrorsController < Projects::ApplicationController
def update_now
if params[:sync_remote]
project.update_remote_mirrors
- flash[:notice] = "The remote repository is being updated..."
+ flash[:notice] = _("The remote repository is being updated...")
end
redirect_to_repository_settings(project, anchor: 'js-push-remote-settings')
@@ -81,6 +81,7 @@ class Projects::MirrorsController < Projects::ApplicationController
password
ssh_known_hosts
regenerate_ssh_private_key
+ _destroy
]
]
end
diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb
index 58b1bc54181..89f21d8dadb 100644
--- a/app/controllers/projects/pages_domains_controller.rb
+++ b/app/controllers/projects/pages_domains_controller.rb
@@ -65,11 +65,11 @@ class Projects::PagesDomainsController < Projects::ApplicationController
private
def create_params
- params.require(:pages_domain).permit(:key, :certificate, :domain)
+ params.require(:pages_domain).permit(:key, :certificate, :domain, :auto_ssl_enabled)
end
def update_params
- params.require(:pages_domain).permit(:key, :certificate)
+ params.require(:pages_domain).permit(:key, :certificate, :auto_ssl_enabled)
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb
index acf56f0eb6a..72e939a3310 100644
--- a/app/controllers/projects/pipeline_schedules_controller.rb
+++ b/app/controllers/projects/pipeline_schedules_controller.rb
@@ -50,9 +50,11 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
job_id = RunPipelineScheduleWorker.perform_async(schedule.id, current_user.id)
if job_id
- flash[:notice] = "Successfully scheduled a pipeline to run. Go to the <a href=\"#{project_pipelines_path(@project)}\">Pipelines page</a> for details.".html_safe
+ pipelines_link_start = "<a href=\"#{project_pipelines_path(@project)}\">"
+ message = _("Successfully scheduled a pipeline to run. Go to the %{pipelines_link_start}Pipelines page%{pipelines_link_end} for details.") % { pipelines_link_start: pipelines_link_start, pipelines_link_end: "</a>" }
+ flash[:notice] = message.html_safe
else
- flash[:alert] = 'Unable to schedule a pipeline to run immediately'
+ flash[:alert] = _('Unable to schedule a pipeline to run immediately')
end
redirect_to pipeline_schedules_path(@project)
@@ -85,7 +87,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
return unless limiter.throttled?([current_user, schedule], 1)
- flash[:alert] = 'You cannot play this scheduled pipeline at the moment. Please wait a minute.'
+ flash[:alert] = _('You cannot play this scheduled pipeline at the moment. Please wait a minute.')
redirect_to pipeline_schedules_path(@project)
end
@@ -96,7 +98,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
def schedule_params
params.require(:schedule)
.permit(:description, :cron, :cron_timezone, :ref, :active,
- variables_attributes: [:id, :key, :secret_value, :_destroy] )
+ variables_attributes: [:id, :variable_type, :key, :secret_value, :_destroy] )
end
def authorize_play_pipeline_schedule!
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 6a86f8ca729..db3b7c8b177 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -8,6 +8,8 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :authorize_create_pipeline!, only: [:new, :create]
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
+ around_action :allow_gitaly_ref_name_caching, only: [:index, :show]
+
wrap_parameters Ci::Pipeline
POLLING_INTERVAL = 10_000
@@ -31,10 +33,7 @@ class Projects::PipelinesController < Projects::ApplicationController
Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL)
render json: {
- pipelines: PipelineSerializer
- .new(project: @project, current_user: @current_user)
- .with_pagination(request, response)
- .represent(@pipelines, disable_coverage: true, preload: true),
+ pipelines: serialize_pipelines,
count: {
all: @pipelines_count,
running: @running_count,
@@ -150,6 +149,13 @@ class Projects::PipelinesController < Projects::ApplicationController
private
+ def serialize_pipelines
+ PipelineSerializer
+ .new(project: @project, current_user: @current_user)
+ .with_pagination(request, response)
+ .represent(@pipelines, disable_coverage: true, preload: true)
+ end
+
def render_show
respond_to do |format|
format.html do
@@ -163,7 +169,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def create_params
- params.require(:pipeline).permit(:ref, variables_attributes: %i[key secret_value])
+ params.require(:pipeline).permit(:ref, variables_attributes: %i[key variable_type secret_value])
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb
index b97fbe19bbf..b3447812ef2 100644
--- a/app/controllers/projects/refs_controller.rb
+++ b/app/controllers/projects/refs_controller.rb
@@ -4,6 +4,8 @@ class Projects::RefsController < Projects::ApplicationController
include ExtractsPath
include TreeHelper
+ around_action :allow_gitaly_ref_name_caching, only: [:logs_tree]
+
before_action :require_non_empty_project
before_action :validate_ref_id
before_action :assign_ref_vars
diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb
index 4eeaeb860ee..3b4215b766e 100644
--- a/app/controllers/projects/repositories_controller.rb
+++ b/app/controllers/projects/repositories_controller.rb
@@ -23,7 +23,7 @@ class Projects::RepositoriesController < Projects::ApplicationController
append_sha = false if @filename == shortname
end
- send_git_archive @repository, ref: @ref, format: params[:format], append_sha: append_sha
+ send_git_archive @repository, ref: @ref, path: params[:path], format: params[:format], append_sha: append_sha
rescue => ex
logger.error("#{self.class.name}: #{ex}")
git_not_found!
diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index 91f40b90aa8..ca62f54813b 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -15,7 +15,7 @@ class Projects::RunnersController < Projects::ApplicationController
def update
if Ci::UpdateRunnerService.new(@runner).update(runner_params)
- redirect_to project_runner_path(@project, @runner), notice: 'Runner was successfully updated.'
+ redirect_to project_runner_path(@project, @runner), notice: _('Runner was successfully updated.')
else
render 'edit'
end
@@ -31,17 +31,17 @@ class Projects::RunnersController < Projects::ApplicationController
def resume
if Ci::UpdateRunnerService.new(@runner).update(active: true)
- redirect_to project_runners_path(@project), notice: 'Runner was successfully updated.'
+ redirect_to project_runners_path(@project), notice: _('Runner was successfully updated.')
else
- redirect_to project_runners_path(@project), alert: 'Runner was not updated.'
+ redirect_to project_runners_path(@project), alert: _('Runner was not updated.')
end
end
def pause
if Ci::UpdateRunnerService.new(@runner).update(active: false)
- redirect_to project_runners_path(@project), notice: 'Runner was successfully updated.'
+ redirect_to project_runners_path(@project), notice: _('Runner was successfully updated.')
else
- redirect_to project_runners_path(@project), alert: 'Runner was not updated.'
+ redirect_to project_runners_path(@project), alert: _('Runner was not updated.')
end
end
diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb
index 39eca10134f..4b0d001fca6 100644
--- a/app/controllers/projects/serverless/functions_controller.rb
+++ b/app/controllers/projects/serverless/functions_controller.rb
@@ -3,29 +3,20 @@
module Projects
module Serverless
class FunctionsController < Projects::ApplicationController
- include ProjectUnauthorized
-
before_action :authorize_read_cluster!
- INDEX_PRIMING_INTERVAL = 15_000
- INDEX_POLLING_INTERVAL = 60_000
-
def index
respond_to do |format|
format.json do
functions = finder.execute
- if functions.any?
- Gitlab::PollingInterval.set_header(response, interval: INDEX_POLLING_INTERVAL)
- render json: serialize_function(functions)
- else
- Gitlab::PollingInterval.set_header(response, interval: INDEX_PRIMING_INTERVAL)
- head :no_content
- end
+ render json: {
+ knative_installed: finder.knative_installed,
+ functions: serialize_function(functions)
+ }.to_json
end
format.html do
- @installed = finder.installed?
render
end
end
@@ -33,6 +24,8 @@ module Projects
def show
@service = serialize_function(finder.service(params[:environment_id], params[:id]))
+ @prometheus = finder.has_prometheus?(params[:environment_id])
+
return not_found if @service.nil?
respond_to do |format|
@@ -44,10 +37,24 @@ module Projects
end
end
+ def metrics
+ respond_to do |format|
+ format.json do
+ metrics = finder.invocation_metrics(params[:environment_id], params[:id])
+
+ if metrics.nil?
+ head :no_content
+ else
+ render json: metrics
+ end
+ end
+ end
+ end
+
private
def finder
- Projects::Serverless::FunctionsFinder.new(project.clusters)
+ Projects::Serverless::FunctionsFinder.new(project)
end
def serialize_function(function)
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index f1c9d0d0f77..e0df51590ae 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -43,20 +43,20 @@ class Projects::ServicesController < Projects::ApplicationController
if outcome[:success]
{}
else
- { error: true, message: 'Test failed.', service_response: outcome[:result].to_s, test_failed: true }
+ { error: true, message: _('Test failed.'), service_response: outcome[:result].to_s, test_failed: true }
end
else
- { error: true, message: 'Validations failed.', service_response: @service.errors.full_messages.join(','), test_failed: false }
+ { error: true, message: _('Validations failed.'), service_response: @service.errors.full_messages.join(','), test_failed: false }
end
rescue Gitlab::HTTP::BlockedUrlError => e
- { error: true, message: 'Test failed.', service_response: e.message, test_failed: true }
+ { error: true, message: _('Test failed.'), service_response: e.message, test_failed: true }
end
def success_message
if @service.active?
- "#{@service.title} activated."
+ _("%{service_title} activated.") % { service_title: @service.title }
else
- "#{@service.title} settings saved, but not activated."
+ _("%{service_title} settings saved, but not activated.") % { service_title: @service.title }
end
end
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index f2f63e986bb..1b8d479209b 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -13,13 +13,13 @@ 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."
+ flash[:notice] = _("Pipelines settings for '%{project_name}' were successfully updated.") % { project_name: @project.name }
run_autodevops_pipeline(service)
redirect_to project_settings_ci_cd_path(@project)
else
- render 'show'
+ redirect_to project_settings_ci_cd_path(@project), alert: result[:message]
end
end
end
@@ -39,7 +39,7 @@ module Projects
def reset_registration_token
@project.reset_runners_token!
- flash[:notice] = 'New runners registration token has been generated!'
+ flash[:notice] = _('New runners registration token has been generated!')
redirect_to namespace_project_settings_ci_cd_path
end
@@ -50,7 +50,8 @@ module Projects
:runners_token, :builds_enabled, :build_allow_git_fetch,
:build_timeout_human_readable, :build_coverage_regex, :public_builds,
:auto_cancel_pending_pipelines, :ci_config_path,
- auto_devops_attributes: [:id, :domain, :enabled, :deploy_strategy]
+ auto_devops_attributes: [:id, :domain, :enabled, :deploy_strategy],
+ ci_cd_settings_attributes: [:default_git_depth]
)
end
@@ -58,7 +59,7 @@ 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[:warning] = _("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
diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb
index 521ec2acebb..b5c77e5bbf4 100644
--- a/app/controllers/projects/settings/operations_controller.rb
+++ b/app/controllers/projects/settings/operations_controller.rb
@@ -3,9 +3,12 @@
module Projects
module Settings
class OperationsController < Projects::ApplicationController
- before_action :check_license
before_action :authorize_update_environment!
+ before_action do
+ push_frontend_feature_flag(:grafana_dashboard_link)
+ end
+
helper_method :error_tracking_setting
def show
@@ -14,16 +17,37 @@ module Projects
def update
result = ::Projects::Operations::UpdateService.new(project, current_user, update_params).execute
+ render_update_response(result)
+ end
+
+ private
+
+ # overridden in EE
+ def render_update_response(result)
+ respond_to do |format|
+ format.json do
+ render_update_json_response(result)
+ end
+ end
+ end
+
+ def render_update_json_response(result)
if result[:status] == :success
flash[:notice] = _('Your changes have been saved')
- redirect_to project_settings_operations_path(@project)
+ render json: {
+ status: result[:status]
+ }
else
- render 'show'
+ render(
+ status: result[:http_status] || :bad_request,
+ json: {
+ status: result[:status],
+ message: result[:message]
+ }
+ )
end
end
- private
-
def error_tracking_setting
@error_tracking_setting ||= project.error_tracking_setting ||
project.build_error_tracking_setting
@@ -35,11 +59,16 @@ module Projects
# overridden in EE
def permitted_project_params
- { error_tracking_setting_attributes: [:enabled, :api_url, :token] }
- end
+ {
+ metrics_setting_attributes: [:external_dashboard_url],
- def check_license
- render_404 unless helpers.settings_operations_available?
+ error_tracking_setting_attributes: [
+ :enabled,
+ :api_host,
+ :token,
+ project: [:slug, :name, :organization_slug, :organization_name]
+ ]
+ }
end
end
end
diff --git a/app/controllers/projects/stages_controller.rb b/app/controllers/projects/stages_controller.rb
new file mode 100644
index 00000000000..c8db5b1277f
--- /dev/null
+++ b/app/controllers/projects/stages_controller.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class Projects::StagesController < Projects::PipelinesController
+ before_action :authorize_update_pipeline!
+
+ def play_manual
+ ::Ci::PlayManualStageService
+ .new(@project, current_user, pipeline: pipeline)
+ .execute(stage)
+
+ respond_to do |format|
+ format.json do
+ render json: StageSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(stage)
+ end
+ end
+ end
+
+ private
+
+ def stage
+ @pipeline_stage ||= pipeline.find_stage_by_name!(params[:stage_name])
+ end
+end
diff --git a/app/controllers/projects/tags/releases_controller.rb b/app/controllers/projects/tags/releases_controller.rb
index 334e1847cc8..5e4c601a693 100644
--- a/app/controllers/projects/tags/releases_controller.rb
+++ b/app/controllers/projects/tags/releases_controller.rb
@@ -12,16 +12,13 @@ class Projects::Tags::ReleasesController < Projects::ApplicationController
end
def update
- # Release belongs to Tag which is not active record object,
- # it exists only to save a description to each Tag.
- # If description is empty we should destroy the existing record.
if release_params[:description].present?
release.update(release_params)
else
release.destroy
end
- redirect_to project_tag_path(@project, @tag.name)
+ redirect_to project_tag_path(@project, tag.name)
end
private
@@ -30,11 +27,10 @@ class Projects::Tags::ReleasesController < Projects::ApplicationController
@tag ||= @repository.find_tag(params[:tag_id])
end
- # rubocop: disable CodeReuse/ActiveRecord
def release
- @release ||= @project.releases.find_or_initialize_by(tag: @tag.name)
+ @release ||= Releases::CreateService.new(project, current_user, tag: @tag.name)
+ .find_or_build_release
end
- # rubocop: enable CodeReuse/ActiveRecord
def release_params
params.require(:release).permit(:description)
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index edebfc55c17..7509cc29a76 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -6,6 +6,8 @@ class Projects::TreeController < Projects::ApplicationController
include CreatesCommit
include ActionView::Helpers::SanitizeHelper
+ around_action :allow_gitaly_ref_name_caching, only: [:show]
+
before_action :require_non_empty_project, except: [:new, :create]
before_action :assign_ref_vars
before_action :assign_dir_vars, only: [:create_dir]
@@ -37,7 +39,7 @@ class Projects::TreeController < Projects::ApplicationController
def create_dir
return render_404 unless @commit_params.values.all?
- create_commit(Files::CreateDirService, success_notice: "The directory has been successfully created.",
+ create_commit(Files::CreateDirService, success_notice: _("The directory has been successfully created."),
success_path: project_tree_path(@project, File.join(@branch_name, @dir_name)),
failure_path: project_tree_path(@project, @ref))
end
diff --git a/app/controllers/projects/triggers_controller.rb b/app/controllers/projects/triggers_controller.rb
index c7b4ebb2b24..284e119ca06 100644
--- a/app/controllers/projects/triggers_controller.rb
+++ b/app/controllers/projects/triggers_controller.rb
@@ -16,9 +16,9 @@ class Projects::TriggersController < Projects::ApplicationController
@trigger = project.triggers.create(trigger_params.merge(owner: current_user))
if @trigger.valid?
- flash[:notice] = 'Trigger was created successfully.'
+ flash[:notice] = _('Trigger was created successfully.')
else
- flash[:alert] = 'You could not create a new trigger.'
+ flash[:alert] = _('You could not create a new trigger.')
end
redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers')
@@ -26,9 +26,9 @@ class Projects::TriggersController < Projects::ApplicationController
def take_ownership
if trigger.update(owner: current_user)
- flash[:notice] = 'Trigger was re-assigned.'
+ flash[:notice] = _('Trigger was re-assigned.')
else
- flash[:alert] = 'You could not take ownership of trigger.'
+ flash[:alert] = _('You could not take ownership of trigger.')
end
redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers')
@@ -39,7 +39,7 @@ class Projects::TriggersController < Projects::ApplicationController
def update
if trigger.update(trigger_params)
- redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers'), notice: 'Trigger was successfully updated.'
+ redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers'), notice: _('Trigger was successfully updated.')
else
render action: "edit"
end
@@ -47,9 +47,9 @@ class Projects::TriggersController < Projects::ApplicationController
def destroy
if trigger.destroy
- flash[:notice] = "Trigger removed."
+ flash[:notice] = _("Trigger removed.")
else
- flash[:alert] = "Could not remove the trigger."
+ flash[:alert] = _("Could not remove the trigger.")
end
redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers'), status: :found
diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb
index bb658bfcc19..646728e8167 100644
--- a/app/controllers/projects/variables_controller.rb
+++ b/app/controllers/projects/variables_controller.rb
@@ -38,6 +38,6 @@ class Projects::VariablesController < Projects::ApplicationController
end
def variable_params_attributes
- %i[id key secret_value protected _destroy]
+ %i[id variable_type key secret_value protected masked _destroy]
end
end
diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb
index 88dd111132b..fa5bdbc7d49 100644
--- a/app/controllers/projects/wikis_controller.rb
+++ b/app/controllers/projects/wikis_controller.rb
@@ -16,7 +16,10 @@ class Projects::WikisController < Projects::ApplicationController
end
def pages
- @wiki_pages = Kaminari.paginate_array(@project_wiki.pages).page(params[:page])
+ @wiki_pages = Kaminari.paginate_array(
+ @project_wiki.list_pages(sort: params[:sort], direction: params[:direction])
+ ).page(params[:page])
+
@wiki_entries = WikiPage.group_by_directory(@wiki_pages)
end
@@ -49,7 +52,7 @@ class Projects::WikisController < Projects::ApplicationController
if @page.valid?
redirect_to(
project_wiki_path(@project, @page),
- notice: 'Wiki was successfully updated.'
+ notice: _('Wiki was successfully updated.')
)
else
render 'edit'
@@ -65,7 +68,7 @@ class Projects::WikisController < Projects::ApplicationController
if @page.persisted?
redirect_to(
project_wiki_path(@project, @page),
- notice: 'Wiki was successfully updated.'
+ notice: _('Wiki was successfully updated.')
)
else
render action: "edit"
@@ -85,7 +88,7 @@ class Projects::WikisController < Projects::ApplicationController
else
redirect_to(
project_wiki_path(@project, :home),
- notice: "Page not found"
+ notice: _("Page not found")
)
end
end
@@ -95,7 +98,7 @@ class Projects::WikisController < Projects::ApplicationController
redirect_to project_wiki_path(@project, :home),
status: 302,
- notice: "Page was successfully deleted"
+ notice: _("Page was successfully deleted")
rescue Gitlab::Git::Wiki::OperationError => e
@error = e
render 'edit'
@@ -115,10 +118,10 @@ class Projects::WikisController < Projects::ApplicationController
@sidebar_page = @project_wiki.find_sidebar(params[:version_id])
unless @sidebar_page # Fallback to default sidebar
- @sidebar_wiki_entries = WikiPage.group_by_directory(@project_wiki.pages(limit: 15))
+ @sidebar_wiki_entries = WikiPage.group_by_directory(@project_wiki.list_pages(limit: 15))
end
rescue ProjectWiki::CouldNotCreateWikiError
- flash[:notice] = "Could not create Wiki Repository at this time. Please try again later."
+ flash[:notice] = _("Could not create Wiki Repository at this time. Please try again later.")
redirect_to project_path(@project)
false
end
@@ -155,7 +158,7 @@ class Projects::WikisController < Projects::ApplicationController
end
def set_encoding_error
- flash.now[:notice] = "The content of this page is not encoded in UTF-8. Edits can only be made via the Git repository."
+ flash.now[:notice] = _("The content of this page is not encoded in UTF-8. Edits can only be made via the Git repository.")
end
def file_blob
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 33c6608d321..12db493978b 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -7,9 +7,12 @@ class ProjectsController < Projects::ApplicationController
include PreviewMarkdown
include SendFileUpload
include RecordUserLastActivity
+ include ImportUrlParams
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
+ around_action :allow_gitaly_ref_name_caching, only: [:index, :show]
+
before_action :whitelist_query_limiting, only: [:create]
before_action :authenticate_user!, except: [:index, :show, :activity, :refs, :resolve]
before_action :redirect_git_extension, only: [:show]
@@ -34,10 +37,10 @@ class ProjectsController < Projects::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def new
- namespace = Namespace.find_by(id: params[:namespace_id]) if params[:namespace_id]
- return access_denied! if namespace && !can?(current_user, :create_projects, namespace)
+ @namespace = Namespace.find_by(id: params[:namespace_id]) if params[:namespace_id]
+ return access_denied! if @namespace && !can?(current_user, :create_projects, @namespace)
- @project = Project.new(namespace_id: namespace&.id)
+ @project = Project.new(namespace_id: @namespace&.id)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -47,7 +50,7 @@ class ProjectsController < Projects::ApplicationController
end
def create
- @project = ::Projects::CreateService.new(current_user, project_params).execute
+ @project = ::Projects::CreateService.new(current_user, project_params(attributes: project_params_create_attributes)).execute
if @project.saved?
cookies[:issue_board_welcome_hidden] = { path: project_path(@project), value: nil, expires: Time.at(0) }
@@ -235,7 +238,7 @@ class ProjectsController < Projects::ApplicationController
def toggle_star
current_user.toggle_star(@project)
- @project.reload
+ @project.reset
render json: {
star_count: @project.star_count
@@ -328,9 +331,10 @@ class ProjectsController < Projects::ApplicationController
end
# rubocop: enable CodeReuse/ActiveRecord
- def project_params
+ def project_params(attributes: [])
params.require(:project)
- .permit(project_params_attributes)
+ .permit(project_params_attributes + attributes)
+ .merge(import_url_params)
end
def project_params_attributes
@@ -343,17 +347,17 @@ class ProjectsController < Projects::ApplicationController
:container_registry_enabled,
:default_branch,
:description,
+ :external_authorization_classification_label,
:import_url,
:issues_tracker,
:issues_tracker_id,
:last_activity_at,
:lfs_enabled,
:name,
- :namespace_id,
:only_allow_merge_if_all_discussions_are_resolved,
:only_allow_merge_if_pipeline_succeeds,
- :printing_merge_request_link_enabled,
:path,
+ :printing_merge_request_link_enabled,
:public_builds,
:request_access_enabled,
:runners_token,
@@ -375,6 +379,10 @@ class ProjectsController < Projects::ApplicationController
]
end
+ def project_params_create_attributes
+ [:namespace_id]
+ end
+
def custom_import_params
{}
end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 8b8d87524a8..07b38371ab9 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -4,6 +4,7 @@ class RegistrationsController < Devise::RegistrationsController
include Recaptcha::Verify
include AcceptsPendingInvitations
+ prepend_before_action :check_captcha, only: :create
before_action :whitelist_query_limiting, only: [:destroy]
before_action :ensure_terms_accepted,
if: -> { Gitlab::CurrentSettings.current_application_settings.enforce_terms? },
@@ -21,15 +22,10 @@ class RegistrationsController < Devise::RegistrationsController
params[resource_name] = params.delete(:"new_#{resource_name}")
end
- if !Gitlab::Recaptcha.load_configurations! || verify_recaptcha
- accept_pending_invitations
- super do |new_user|
- persist_accepted_terms_if_required(new_user)
- end
- else
- flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.'
- flash.delete :recaptcha_error
- render action: 'new'
+ accept_pending_invitations
+
+ super do |new_user|
+ persist_accepted_terms_if_required(new_user)
end
rescue Gitlab::Access::AccessDeniedError
redirect_to(new_user_session_path)
@@ -89,6 +85,17 @@ class RegistrationsController < Devise::RegistrationsController
private
+ def check_captcha
+ return unless Feature.enabled?(:registrations_recaptcha, default_enabled: true)
+ return unless Gitlab::Recaptcha.load_configurations!
+
+ return if verify_recaptcha
+
+ flash[:alert] = _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.')
+ flash.delete :recaptcha_error
+ render action: 'new'
+ end
+
def sign_up_params
params.require(:user).permit(:username, :email, :email_confirmation, :name, :password)
end
diff --git a/app/controllers/root_controller.rb b/app/controllers/root_controller.rb
index 7b6657e1196..f1b39125a48 100644
--- a/app/controllers/root_controller.rb
+++ b/app/controllers/root_controller.rb
@@ -15,7 +15,7 @@ class RootController < Dashboard::ProjectsController
before_action :redirect_logged_user, if: -> { current_user.present? }
def index
- # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37434
+ # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/40260
Gitlab::GitalyClient.allow_n_plus_1_calls do
super
end
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 1b22907c10f..cb25548c83f 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -14,8 +14,6 @@ class SearchController < ApplicationController
layout 'search'
def show
- search_service = SearchService.new(current_user, params)
-
@project = search_service.project
@group = search_service.group
@@ -27,8 +25,10 @@ class SearchController < ApplicationController
@show_snippets = search_service.show_snippets?
@search_results = search_service.search_results
@search_objects = search_service.search_objects
+ @display_options = search_service.display_options
render_commits if @scope == 'commits'
+ eager_load_user_status if @scope == 'users'
check_single_commit_result
end
@@ -54,6 +54,12 @@ class SearchController < ApplicationController
@search_objects = prepare_commits_for_rendering(@search_objects)
end
+ def eager_load_user_status
+ return if Feature.disabled?(:users_search, default_enabled: true)
+
+ @search_objects = @search_objects.eager_load(:status) # rubocop:disable CodeReuse/ActiveRecord
+ end
+
def check_single_commit_result
if @search_results.single_commit_result?
only_commit = @search_results.objects('commits').first
diff --git a/app/controllers/sent_notifications_controller.rb b/app/controllers/sent_notifications_controller.rb
index 2b76921ebd8..77757c4a3ef 100644
--- a/app/controllers/sent_notifications_controller.rb
+++ b/app/controllers/sent_notifications_controller.rb
@@ -16,7 +16,7 @@ class SentNotificationsController < ApplicationController
noteable = @sent_notification.noteable
noteable.unsubscribe(@sent_notification.recipient, @sent_notification.project)
- flash[:notice] = "You have been unsubscribed from this thread."
+ flash[:notice] = _("You have been unsubscribed from this thread.")
if current_user
redirect_to noteable_path(noteable)
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 4bd7d71e264..a841859621e 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -8,6 +8,8 @@ class SessionsController < Devise::SessionsController
include Recaptcha::Verify
skip_before_action :check_two_factor_requirement, only: [:destroy]
+ # replaced with :require_no_authentication_without_flash
+ skip_before_action :require_no_authentication, only: [:new, :create]
prepend_before_action :check_initial_setup, only: [:new]
prepend_before_action :authenticate_with_two_factor,
@@ -15,6 +17,9 @@ class SessionsController < Devise::SessionsController
prepend_before_action :check_captcha, only: [:create]
prepend_before_action :store_redirect_uri, only: [:new]
prepend_before_action :ldap_servers, only: [:new, :create]
+ prepend_before_action :require_no_authentication_without_flash, only: [:new, :create]
+ prepend_before_action :ensure_password_authentication_enabled!, if: :password_based_login?, only: [:create]
+
before_action :auto_sign_in_with_provider, only: [:new]
before_action :load_recaptcha
@@ -54,6 +59,14 @@ class SessionsController < Devise::SessionsController
private
+ def require_no_authentication_without_flash
+ require_no_authentication
+
+ if flash[:alert] == I18n.t('devise.failure.already_authenticated')
+ flash[:alert] = nil
+ end
+ end
+
def captcha_enabled?
request.headers[CAPTCHA_HEADER] && Gitlab::Recaptcha.enabled?
end
@@ -70,7 +83,7 @@ class SessionsController < Devise::SessionsController
increment_failed_login_captcha_counter
self.resource = resource_class.new
- flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.'
+ flash[:alert] = _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.')
flash.delete :recaptcha_error
respond_with_navigational(resource) { render :new }
@@ -122,10 +135,18 @@ class SessionsController < Devise::SessionsController
end
redirect_to edit_user_password_path(reset_password_token: @token),
- notice: "Please create a password for your new account."
+ notice: _("Please create a password for your new account.")
end
# rubocop: enable CodeReuse/ActiveRecord
+ def ensure_password_authentication_enabled!
+ render_403 unless Gitlab::CurrentSettings.password_authentication_enabled_for_web?
+ end
+
+ def password_based_login?
+ user_params[:login].present? || user_params[:password].present?
+ end
+
def user_params
params.require(:user).permit(:login, :password, :remember_me, :otp_attempt, :device_response)
end
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index 519e7439205..5d28635232b 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -28,13 +28,13 @@ class UploadsController < ApplicationController
end
def find_model
- return nil unless params[:id]
+ return unless params[:id]
upload_model_class.find(params[:id])
end
def authorize_access!
- return nil unless model
+ return unless model
authorized =
case model
@@ -45,7 +45,7 @@ class UploadsController < ApplicationController
when Appearance
true
else
- permission = "read_#{model.class.to_s.underscore}".to_sym
+ permission = "read_#{model.class.underscore}".to_sym
can?(current_user, permission, model)
end
@@ -54,10 +54,11 @@ class UploadsController < ApplicationController
end
def authorize_create_access!
- return nil unless model
+ return unless model
- # for now we support only personal snippets comments
- authorized = can?(current_user, :comment_personal_snippet, model)
+ # for now we support only personal snippets comments. Only personal_snippet
+ # is allowed as a model to #create through routing.
+ authorized = can?(current_user, :create_note, model)
render_unauthorized unless authorized
end
diff --git a/app/finders/admin/runners_finder.rb b/app/finders/admin/runners_finder.rb
index fbb1cfc5c66..b2799565f57 100644
--- a/app/finders/admin/runners_finder.rb
+++ b/app/finders/admin/runners_finder.rb
@@ -11,10 +11,11 @@ class Admin::RunnersFinder < UnionFinder
search!
filter_by_status!
filter_by_runner_type!
+ filter_by_tag_list!
sort!
paginate!
- @runners
+ @runners.with_tags
end
def sort_key
@@ -44,6 +45,14 @@ class Admin::RunnersFinder < UnionFinder
filter_by!(:type_type, Ci::Runner::AVAILABLE_TYPES)
end
+ def filter_by_tag_list!
+ tag_list = @params[:tag_name].presence
+
+ if tag_list
+ @runners = @runners.tagged_with(tag_list)
+ end
+ end
+
def sort!
@runners = @runners.order_by(sort_key)
end
diff --git a/app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb b/app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb
new file mode 100644
index 00000000000..f38c187799c
--- /dev/null
+++ b/app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Autocomplete
+ module ActsAsTaggableOn
+ class TagsFinder
+ LIMIT = 20
+
+ def initialize(params:)
+ @params = params
+ end
+
+ def execute
+ tags = all_tags
+ tags = filter_by_name(tags)
+ limit(tags)
+ end
+
+ private
+
+ def all_tags
+ ::ActsAsTaggableOn::Tag.all
+ end
+
+ def filter_by_name(tags)
+ return tags unless search
+ return tags.none if search.empty?
+
+ if search.length >= Gitlab::SQL::Pattern::MIN_CHARS_FOR_PARTIAL_MATCHING
+ tags.named_like(search)
+ else
+ tags.named(search)
+ end
+ end
+
+ def limit(tags)
+ tags.limit(LIMIT) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ def search
+ @params[:search]
+ end
+ end
+ end
+end
diff --git a/app/finders/autocomplete/users_finder.rb b/app/finders/autocomplete/users_finder.rb
index 45955783be9..ce7d0b8699c 100644
--- a/app/finders/autocomplete/users_finder.rb
+++ b/app/finders/autocomplete/users_finder.rb
@@ -2,6 +2,8 @@
module Autocomplete
class UsersFinder
+ include Gitlab::Utils::StrongMemoize
+
# The number of users to display in the results is hardcoded to 20, and
# pagination is not supported. This ensures that performance remains
# consistent and removes the need for implementing keyset pagination to
@@ -31,7 +33,7 @@ module Autocomplete
# Include current user if available to filter by "Me"
items.unshift(current_user) if prepend_current_user?
- if prepend_author? && (author = User.find_by_id(author_id))
+ if prepend_author? && author&.active?
items.unshift(author)
end
end
@@ -41,6 +43,12 @@ module Autocomplete
private
+ def author
+ strong_memoize(:author) do
+ User.find_by_id(author_id)
+ end
+ end
+
# Returns the users based on the input parameters, as an Array.
#
# This method is separate so it is easier to extend in EE.
diff --git a/app/finders/clusters/knative_services_finder.rb b/app/finders/clusters/knative_services_finder.rb
new file mode 100644
index 00000000000..7d3b53ef663
--- /dev/null
+++ b/app/finders/clusters/knative_services_finder.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+module Clusters
+ class KnativeServicesFinder
+ include ReactiveCaching
+ include Gitlab::Utils::StrongMemoize
+
+ KNATIVE_STATES = {
+ 'checking' => 'checking',
+ 'installed' => 'installed',
+ 'not_found' => 'not_found'
+ }.freeze
+
+ self.reactive_cache_key = ->(finder) { finder.model_name }
+ self.reactive_cache_worker_finder = ->(_id, *cache_args) { from_cache(*cache_args) }
+
+ attr_reader :cluster, :project
+
+ def initialize(cluster, project)
+ @cluster = cluster
+ @project = project
+ end
+
+ def with_reactive_cache_memoized(*cache_args, &block)
+ strong_memoize(:reactive_cache) do
+ with_reactive_cache(*cache_args, &block)
+ end
+ end
+
+ def clear_cache!
+ clear_reactive_cache!(*cache_args)
+ end
+
+ def self.from_cache(cluster_id, project_id)
+ cluster = Clusters::Cluster.find(cluster_id)
+ project = ::Project.find(project_id)
+
+ new(cluster, project)
+ end
+
+ def calculate_reactive_cache(*)
+ # read_services calls knative_client.discover implicitily. If we stop
+ # detecting services but still want to detect knative, we'll need to
+ # explicitily call: knative_client.discover
+ #
+ # We didn't create it separately to avoid 2 cluster requests.
+ ksvc = read_services
+ pods = knative_client.discovered ? read_pods : []
+ { services: ksvc, pods: pods, knative_detected: knative_client.discovered }
+ end
+
+ def services
+ return [] unless search_namespace
+
+ cached_data = with_reactive_cache_memoized(*cache_args) { |data| data }
+ cached_data.to_h.fetch(:services, [])
+ end
+
+ def cache_args
+ [cluster.id, project.id]
+ end
+
+ def service_pod_details(service)
+ cached_data = with_reactive_cache_memoized(*cache_args) { |data| data }
+ cached_data.to_h.fetch(:pods, []).select do |pod|
+ filter_pods(pod, service)
+ end
+ end
+
+ def knative_detected
+ cached_data = with_reactive_cache_memoized(*cache_args) { |data| data }
+
+ knative_state = cached_data.to_h[:knative_detected]
+
+ return KNATIVE_STATES['checking'] if knative_state.nil?
+ return KNATIVE_STATES['installed'] if knative_state
+
+ KNATIVE_STATES['uninstalled']
+ end
+
+ def model_name
+ self.class.name.underscore.tr('/', '_')
+ end
+
+ private
+
+ def search_namespace
+ @search_namespace ||= cluster.kubernetes_namespace_for(project)
+ end
+
+ def knative_client
+ cluster.kubeclient.knative_client
+ end
+
+ def filter_pods(pod, service)
+ pod["metadata"]["labels"]["serving.knative.dev/service"] == service
+ end
+
+ def read_services
+ knative_client.get_services(namespace: search_namespace).as_json
+ rescue Kubeclient::ResourceNotFoundError
+ []
+ end
+
+ def read_pods
+ cluster.kubeclient.core_client.get_pods(namespace: search_namespace).as_json
+ end
+
+ def id
+ nil
+ end
+ end
+end
diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb
index 0080123407d..7d419103b1c 100644
--- a/app/finders/groups_finder.rb
+++ b/app/finders/groups_finder.rb
@@ -11,6 +11,7 @@
# parent: Group
# all_available: boolean (defaults to true)
# min_access_level: integer
+# exclude_group_ids: array of integers
#
# Users with full private access can see all groups. The `owned` and `parent`
# params can be used to restrict the groups that are returned.
@@ -29,6 +30,7 @@ class GroupsFinder < UnionFinder
items = all_groups.map do |item|
item = by_parent(item)
item = by_custom_attributes(item)
+ item = exclude_group_ids(item)
item
end
@@ -72,6 +74,12 @@ class GroupsFinder < UnionFinder
end
# rubocop: enable CodeReuse/ActiveRecord
+ def exclude_group_ids(groups)
+ return groups unless params[:exclude_group_ids]
+
+ groups.id_not_in(params[:exclude_group_ids])
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def by_parent(groups)
return groups unless params[:parent]
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 5870f158690..50e9418677c 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -29,6 +29,7 @@
# updated_after: datetime
# updated_before: datetime
# attempt_group_search_optimizations: boolean
+# attempt_project_search_optimizations: boolean
#
class IssuableFinder
prepend FinderWithCrossProjectAccess
@@ -78,19 +79,20 @@ class IssuableFinder
items = init_collection
items = filter_items(items)
- # This has to be last as we may use a CTE as an optimization fence
- # by passing the attempt_group_search_optimizations param and
- # enabling the use_cte_for_group_issues_search feature flag
+ # This has to be last as we use a CTE as an optimization fence
+ # for counts by passing the force_cte param and enabling the
+ # attempt_group_search_optimizations feature flag
# https://www.postgresql.org/docs/current/static/queries-with.html
items = by_search(items)
- sort(items)
+ items = sort(items)
+
+ items
end
def filter_items(items)
items = by_project(items)
items = by_group(items)
- items = by_subquery(items)
items = by_scope(items)
items = by_created_at(items)
items = by_updated_at(items)
@@ -117,8 +119,9 @@ class IssuableFinder
#
# rubocop: disable CodeReuse/ActiveRecord
def count_by_state
- count_params = params.merge(state: nil, sort: nil)
+ count_params = params.merge(state: nil, sort: nil, force_cte: true)
finder = self.class.new(current_user, count_params)
+
counts = Hash.new(0)
# Searching by label includes a GROUP BY in the query, but ours will be last
@@ -128,6 +131,11 @@ class IssuableFinder
#
# This does not apply when we are using a CTE for the search, as the labels
# GROUP BY is inside the subquery in that case, so we set labels_count to 1.
+ #
+ # Groups and projects have separate feature flags to suggest the use
+ # of a CTE. The CTE will not be used if the sort doesn't support it,
+ # but will always be used for the counts here as we ignore sorting
+ # anyway.
labels_count = label_names.any? ? label_names.count : 1
labels_count = 1 if use_cte_for_search?
@@ -177,7 +185,6 @@ class IssuableFinder
@project = project
end
- # rubocop: disable CodeReuse/ActiveRecord
def projects
return @projects if defined?(@projects)
@@ -185,17 +192,25 @@ class IssuableFinder
projects =
if current_user && params[:authorized_only].presence && !current_user_related?
- current_user.authorized_projects
+ current_user.authorized_projects(min_access_level)
elsif group
- finder_options = { include_subgroups: params[:include_subgroups], only_owned: true }
- GroupProjectsFinder.new(group: group, current_user: current_user, options: finder_options).execute # rubocop: disable CodeReuse/Finder
+ find_group_projects
else
- ProjectsFinder.new(current_user: current_user).execute # rubocop: disable CodeReuse/Finder
+ Project.public_or_visible_to_user(current_user, min_access_level)
end
- @projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil)
+ @projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ def find_group_projects
+ return Project.none unless group
+
+ if params[:include_subgroups]
+ Project.where(namespace_id: group.self_and_descendants) # rubocop: disable CodeReuse/ActiveRecord
+ else
+ group.projects
+ end.public_or_visible_to_user(current_user, min_access_level)
end
- # rubocop: enable CodeReuse/ActiveRecord
def search
params[:search].presence
@@ -303,29 +318,35 @@ class IssuableFinder
end
# rubocop: enable CodeReuse/ActiveRecord
- def use_subquery_for_search?
- strong_memoize(:use_subquery_for_search) do
- attempt_group_search_optimizations? &&
- Feature.enabled?(:use_subquery_for_group_issues_search, default_enabled: true)
- end
- end
-
def use_cte_for_search?
strong_memoize(:use_cte_for_search) do
- attempt_group_search_optimizations? &&
- !use_subquery_for_search? &&
- Feature.enabled?(:use_cte_for_group_issues_search, default_enabled: true)
+ next false unless search
+ next false unless Gitlab::Database.postgresql?
+ # Only simple unsorted & simple sorts can use CTE
+ next false if params[:sort].present? && !params[:sort].in?(klass.simple_sorts.keys)
+
+ attempt_group_search_optimizations? || attempt_project_search_optimizations?
end
end
private
+ def force_cte?
+ !!params[:force_cte]
+ end
+
def init_collection
klass.all
end
def attempt_group_search_optimizations?
- search && Gitlab::Database.postgresql? && params[:attempt_group_search_optimizations]
+ params[:attempt_group_search_optimizations] &&
+ Feature.enabled?(:attempt_group_search_optimizations, default_enabled: true)
+ end
+
+ def attempt_project_search_optimizations?
+ params[:attempt_project_search_optimizations] &&
+ Feature.enabled?(:attempt_project_search_optimizations, default_enabled: true)
end
def count_key(value)
@@ -398,15 +419,6 @@ class IssuableFinder
end
# rubocop: enable CodeReuse/ActiveRecord
- # Wrap projects and groups in a subquery if the conditions are met.
- def by_subquery(items)
- if use_subquery_for_search?
- klass.where(id: items.select(:id)) # rubocop: disable CodeReuse/ActiveRecord
- else
- items
- end
- end
-
# rubocop: disable CodeReuse/ActiveRecord
def by_search(items)
return items unless search
@@ -436,22 +448,6 @@ class IssuableFinder
end
# rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
- def by_assignee(items)
- if filter_by_no_assignee?
- items.where(assignee_id: nil)
- elsif filter_by_any_assignee?
- items.where('assignee_id IS NOT NULL')
- elsif assignee
- items.where(assignee_id: assignee.id)
- elsif assignee_id? || assignee_username? # assignee not found
- items.none
- else
- items
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def filter_by_no_assignee?
# Assignee_id takes precedence over assignee_username
[NONE, FILTER_NONE].include?(params[:assignee_id].to_s.downcase) || params[:assignee_username].to_s == NONE
@@ -475,6 +471,20 @@ class IssuableFinder
end
# rubocop: enable CodeReuse/ActiveRecord
+ def by_assignee(items)
+ if filter_by_no_assignee?
+ items.unassigned
+ elsif filter_by_any_assignee?
+ items.assigned
+ elsif assignee
+ items.assigned_to(assignee)
+ elsif assignee_id? || assignee_username? # assignee not found
+ items.none
+ else
+ items
+ end
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def by_milestone(items)
if milestones?
@@ -486,7 +496,7 @@ class IssuableFinder
upcoming_ids = Milestone.upcoming_ids(projects, related_groups)
items = items.left_joins_milestones.where(milestone_id: upcoming_ids)
elsif filter_by_started_milestone?
- items = items.left_joins_milestones.where('milestones.start_date <= NOW()')
+ items = items.left_joins_milestones.merge(Milestone.started)
else
items = items.with_milestone(params[:milestone_title])
end
@@ -568,4 +578,8 @@ class IssuableFinder
scope = params[:scope]
scope == 'created_by_me' || scope == 'authored' || scope == 'assigned_to_me'
end
+
+ def min_access_level
+ ProjectFeature.required_minimum_access_level(klass)
+ end
end
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index cb44575d6f1..58a01d598ba 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -48,9 +48,9 @@ class IssuesFinder < IssuableFinder
OR (issues.confidential = TRUE
AND (issues.author_id = :user_id
OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id)
- OR issues.project_id IN(:project_ids)))',
+ OR EXISTS (:authorizations)))',
user_id: current_user.id,
- project_ids: current_user.authorized_projects(CONFIDENTIAL_ACCESS_LEVEL).select(:id))
+ authorizations: current_user.authorizations_for_projects(min_access_level: CONFIDENTIAL_ACCESS_LEVEL, related_project_column: "issues.project_id"))
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -144,18 +144,4 @@ class IssuesFinder < IssuableFinder
current_user.blank?
end
-
- def by_assignee(items)
- if filter_by_no_assignee?
- items.unassigned
- elsif filter_by_any_assignee?
- items.assigned
- elsif assignee
- items.assigned_to(assignee)
- elsif assignee_id? || assignee_username? # assignee not found
- items.none
- else
- items
- end
- end
end
diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb
index f90a7868102..917de249104 100644
--- a/app/finders/members_finder.rb
+++ b/app/finders/members_finder.rb
@@ -9,25 +9,18 @@ class MembersFinder
@group = project.group
end
- # rubocop: disable CodeReuse/ActiveRecord
- def execute(include_descendants: false)
+ def execute(include_descendants: false, include_invited_groups_members: false)
project_members = project.project_members
project_members = project_members.non_invite unless can?(current_user, :admin_project, project)
- if group
- group_members = GroupMembersFinder.new(group).execute(include_descendants: include_descendants) # rubocop: disable CodeReuse/Finder
- group_members = group_members.non_invite
+ union_members = group_union_members(include_descendants, include_invited_groups_members)
- union = Gitlab::SQL::Union.new([project_members, group_members], remove_duplicates: false) # rubocop: disable Gitlab/Union
-
- sql = distinct_on(union)
-
- Member.includes(:user).from("(#{sql}) AS #{Member.table_name}")
+ if union_members.any?
+ distinct_union_of_members(union_members << project_members)
else
project_members
end
end
- # rubocop: enable CodeReuse/ActiveRecord
def can?(*args)
Ability.allowed?(*args)
@@ -35,6 +28,34 @@ class MembersFinder
private
+ def group_union_members(include_descendants, include_invited_groups_members)
+ [].tap do |members|
+ members << direct_group_members(include_descendants) if group
+ members << project_invited_groups_members if 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
+ end
+
+ def project_invited_groups_members
+ invited_groups_ids_including_ancestors = Gitlab::ObjectHierarchy
+ .new(project.invited_groups)
+ .base_and_ancestors
+ .public_or_visible_to_user(current_user)
+ .select(:id)
+
+ GroupMember.with_source_id(invited_groups_ids_including_ancestors)
+ end
+
+ def distinct_union_of_members(union_members)
+ union = Gitlab::SQL::Union.new(union_members, remove_duplicates: false) # rubocop: disable Gitlab/Union
+ sql = distinct_on(union)
+
+ Member.includes(:user).from([Arel.sql("(#{sql}) AS #{Member.table_name}")]) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
def distinct_on(union)
# We're interested in a list of members without duplicates by user_id.
# We prefer project members over group members, project members should go first.
diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb
index b645011a3c5..29947bc94d5 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -29,7 +29,7 @@
#
class MergeRequestsFinder < IssuableFinder
def self.scalar_params
- @scalar_params ||= super + [:wip]
+ @scalar_params ||= super + [:wip, :target_branch]
end
def klass
@@ -37,13 +37,21 @@ class MergeRequestsFinder < IssuableFinder
end
def filter_items(_items)
- items = by_source_branch(super)
+ items = by_commit(super)
+ items = by_source_branch(items)
items = by_wip(items)
- by_target_branch(items)
+ items = by_target_branch(items)
+ by_source_project_id(items)
end
private
+ def by_commit(items)
+ return items unless params[:commit_sha].presence
+
+ items.by_commit_sha(params[:commit_sha])
+ end
+
def source_branch
@source_branch ||= params[:source_branch].presence
end
@@ -67,6 +75,16 @@ class MergeRequestsFinder < IssuableFinder
items.where(target_branch: target_branch)
end
+ def source_project_id
+ @source_project_id ||= params[:source_project_id].presence
+ end
+
+ def by_source_project_id(items)
+ return items unless source_project_id
+
+ items.where(source_project_id: source_project_id)
+ end
+
def by_wip(items)
if params[:wip] == 'yes'
items.where(wip_match(items.arel_table))
diff --git a/app/finders/projects/daily_statistics_finder.rb b/app/finders/projects/daily_statistics_finder.rb
new file mode 100644
index 00000000000..912c23107bc
--- /dev/null
+++ b/app/finders/projects/daily_statistics_finder.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Projects
+ class DailyStatisticsFinder
+ attr_reader :project
+
+ def initialize(project)
+ @project = project
+ end
+
+ def fetches
+ ProjectDailyStatistic.of_project(project)
+ .of_last_30_days
+ .sorted_by_date_desc
+ end
+
+ def total_fetch_count
+ fetches.sum_fetch_count
+ end
+ end
+end
diff --git a/app/finders/projects/serverless/functions_finder.rb b/app/finders/projects/serverless/functions_finder.rb
index 2f2816a4a08..ebe50806ca1 100644
--- a/app/finders/projects/serverless/functions_finder.rb
+++ b/app/finders/projects/serverless/functions_finder.rb
@@ -3,29 +3,59 @@
module Projects
module Serverless
class FunctionsFinder
- def initialize(clusters)
- @clusters = clusters
+ attr_reader :project
+
+ def initialize(project)
+ @clusters = project.clusters
+ @project = project
end
def execute
knative_services.flatten.compact
end
- def installed?
- clusters_with_knative_installed.exists?
+ # Possible return values: Clusters::KnativeServicesFinder::KNATIVE_STATE
+ def knative_installed
+ states = @clusters.map do |cluster|
+ cluster.application_knative
+ cluster.knative_services_finder(project).knative_detected.tap do |state|
+ return state if state == ::Clusters::KnativeServicesFinder::KNATIVE_STATES['checking'] # rubocop:disable Cop/AvoidReturnFromBlocks
+ end
+ end
+
+ states.any? { |state| state == ::Clusters::KnativeServicesFinder::KNATIVE_STATES['installed'] }
end
def service(environment_scope, name)
knative_service(environment_scope, name)&.first
end
+ def invocation_metrics(environment_scope, name)
+ return unless prometheus_adapter&.can_query?
+
+ cluster = @clusters.find do |c|
+ environment_scope == c.environment_scope
+ end
+
+ func = ::Serverless::Function.new(project, name, cluster.kubernetes_namespace_for(project))
+ prometheus_adapter.query(:knative_invocation, func)
+ end
+
+ def has_prometheus?(environment_scope)
+ @clusters.any? do |cluster|
+ environment_scope == cluster.environment_scope && cluster.application_prometheus_available?
+ end
+ end
+
private
def knative_service(environment_scope, name)
- clusters_with_knative_installed.preload_knative.map do |cluster|
+ @clusters.map do |cluster|
next if environment_scope != cluster.environment_scope
- services = cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace)
+ services = cluster
+ .knative_services_finder(project)
+ .services
.select { |svc| svc["metadata"]["name"] == name }
add_metadata(cluster, services).first unless services.nil?
@@ -33,8 +63,11 @@ module Projects
end
def knative_services
- clusters_with_knative_installed.preload_knative.map do |cluster|
- services = cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace)
+ @clusters.map do |cluster|
+ services = cluster
+ .knative_services_finder(project)
+ .services
+
add_metadata(cluster, services) unless services.nil?
end
end
@@ -45,16 +78,19 @@ module Projects
s["cluster_id"] = cluster.id
if services.length == 1
- s["podcount"] = cluster.application_knative.service_pod_details(
- cluster.platform_kubernetes&.actual_namespace,
- s["metadata"]["name"]).length
+ s["podcount"] = cluster
+ .knative_services_finder(project)
+ .service_pod_details(s["metadata"]["name"])
+ .length
end
end
end
- def clusters_with_knative_installed
- @clusters.with_knative_installed
+ # rubocop: disable CodeReuse/ServiceClass
+ def prometheus_adapter
+ @prometheus_adapter ||= ::Prometheus::AdapterService.new(project).prometheus_adapter
end
+ # rubocop: enable CodeReuse/ServiceClass
end
end
end
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index 93d3c991846..23b731b1aed 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -62,7 +62,7 @@ class ProjectsFinder < UnionFinder
collection = by_personal(collection)
collection = by_starred(collection)
collection = by_trending(collection)
- collection = by_visibilty_level(collection)
+ collection = by_visibility_level(collection)
collection = by_tags(collection)
collection = by_search(collection)
collection = by_archived(collection)
@@ -71,12 +71,11 @@ class ProjectsFinder < UnionFinder
collection
end
- # rubocop: disable CodeReuse/ActiveRecord
def collection_with_user
if owned_projects?
current_user.owned_projects
elsif min_access_level?
- current_user.authorized_projects.where('project_authorizations.access_level >= ?', params[:min_access_level])
+ current_user.authorized_projects(params[:min_access_level])
else
if private_only?
current_user.authorized_projects
@@ -85,7 +84,6 @@ class ProjectsFinder < UnionFinder
end
end
end
- # rubocop: enable CodeReuse/ActiveRecord
# Builds a collection for an anonymous user.
def collection_without_user
@@ -131,7 +129,7 @@ class ProjectsFinder < UnionFinder
end
# rubocop: disable CodeReuse/ActiveRecord
- def by_visibilty_level(items)
+ def by_visibility_level(items)
params[:visibility_level].present? ? items.where(visibility_level: params[:visibility_level]) : items
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb
index d3774746cb8..bf29f15642d 100644
--- a/app/finders/snippets_finder.rb
+++ b/app/finders/snippets_finder.rb
@@ -69,6 +69,8 @@ class SnippetsFinder < UnionFinder
base.with_optional_visibility(visibility_from_scope).fresh
end
+ private
+
# Produces a query that retrieves snippets from multiple projects.
#
# The resulting query will, depending on the user's permissions, include the
diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb
index 06d26309b5b..2e5bdbd79c8 100644
--- a/app/graphql/gitlab_schema.rb
+++ b/app/graphql/gitlab_schema.rb
@@ -1,13 +1,97 @@
# frozen_string_literal: true
class GitlabSchema < GraphQL::Schema
+ # Currently an IntrospectionQuery has a complexity of 179.
+ # These values will evolve over time.
+ DEFAULT_MAX_COMPLEXITY = 200
+ AUTHENTICATED_COMPLEXITY = 250
+ ADMIN_COMPLEXITY = 300
+
+ DEFAULT_MAX_DEPTH = 10
+ AUTHENTICATED_MAX_DEPTH = 15
+
use BatchLoader::GraphQL
use Gitlab::Graphql::Authorize
use Gitlab::Graphql::Present
use Gitlab::Graphql::Connections
+ use Gitlab::Graphql::GenericTracing
+
+ query_analyzer Gitlab::Graphql::QueryAnalyzers::LoggerAnalyzer.new
query(Types::QueryType)
default_max_page_size 100
+
+ max_complexity DEFAULT_MAX_COMPLEXITY
+ max_depth DEFAULT_MAX_DEPTH
+
mutation(Types::MutationType)
+
+ class << self
+ def multiplex(queries, **kwargs)
+ kwargs[:max_complexity] ||= max_query_complexity(kwargs[:context])
+
+ queries.each do |query|
+ query[:max_depth] = max_query_depth(kwargs[:context])
+ end
+
+ super(queries, **kwargs)
+ end
+
+ def execute(query_str = nil, **kwargs)
+ kwargs[:max_complexity] ||= max_query_complexity(kwargs[:context])
+ kwargs[:max_depth] ||= max_query_depth(kwargs[:context])
+
+ super(query_str, **kwargs)
+ end
+
+ def id_from_object(object)
+ unless object.respond_to?(:to_global_id)
+ # This is an error in our schema and needs to be solved. So raise a
+ # more meaningfull error message
+ raise "#{object} does not implement `to_global_id`. "\
+ "Include `GlobalID::Identification` into `#{object.class}"
+ end
+
+ object.to_global_id
+ end
+
+ def object_from_id(global_id)
+ gid = GlobalID.parse(global_id)
+
+ unless gid
+ raise Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid GitLab id."
+ end
+
+ if gid.model_class < ApplicationRecord
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(gid.model_class, gid.model_id).find
+ else
+ gid.find
+ end
+ end
+
+ private
+
+ def max_query_complexity(ctx)
+ current_user = ctx&.fetch(:current_user, nil)
+
+ if current_user&.admin
+ ADMIN_COMPLEXITY
+ elsif current_user
+ AUTHENTICATED_COMPLEXITY
+ else
+ DEFAULT_MAX_COMPLEXITY
+ end
+ end
+
+ def max_query_depth(ctx)
+ current_user = ctx&.fetch(:current_user, nil)
+
+ if current_user
+ AUTHENTICATED_MAX_DEPTH
+ else
+ DEFAULT_MAX_DEPTH
+ end
+ end
+ end
end
diff --git a/app/graphql/mutations/merge_requests/base.rb b/app/graphql/mutations/merge_requests/base.rb
index 7d0cb777ad1..e85d16fc2c5 100644
--- a/app/graphql/mutations/merge_requests/base.rb
+++ b/app/graphql/mutations/merge_requests/base.rb
@@ -10,7 +10,7 @@ module Mutations
required: true,
description: "The project the merge request to mutate is in"
- argument :iid, GraphQL::ID_TYPE,
+ argument :iid, GraphQL::STRING_TYPE,
required: true,
description: "The iid of the merge request to mutate"
diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb
index 063def75d38..5b7eb57841c 100644
--- a/app/graphql/resolvers/base_resolver.rb
+++ b/app/graphql/resolvers/base_resolver.rb
@@ -9,5 +9,24 @@ module Resolvers
end
end
end
+
+ def self.resolver_complexity(args, child_complexity:)
+ complexity = 1
+ complexity += 1 if args[:sort]
+ complexity += 5 if args[:search]
+
+ complexity
+ end
+
+ def self.complexity_multiplier(args)
+ # When fetching many items, additional complexity is added to the field
+ # depending on how many items is fetched. For each item we add 1% of the
+ # original complexity - this means that loading 100 items (our default
+ # maxp_age_size limit) doubles the original complexity.
+ #
+ # Complexity is not increased when searching by specific ID(s), because
+ # complexity difference is minimal in this case.
+ [args[:iid], args[:iids]].any? ? 0 : 0.01
+ end
end
end
diff --git a/app/graphql/resolvers/concerns/resolves_pipelines.rb b/app/graphql/resolvers/concerns/resolves_pipelines.rb
index 8fd26d85994..a6f82cc8505 100644
--- a/app/graphql/resolvers/concerns/resolves_pipelines.rb
+++ b/app/graphql/resolvers/concerns/resolves_pipelines.rb
@@ -19,6 +19,16 @@ module ResolvesPipelines
description: "Filter pipelines by the sha of the commit they are run for"
end
+ class_methods do
+ def resolver_complexity(args, child_complexity:)
+ complexity = super
+ complexity += 2 if args[:sha]
+ complexity += 2 if args[:ref]
+
+ complexity
+ end
+ end
+
def resolve_pipelines(project, params = {})
PipelinesFinder.new(project, context[:current_user], params).execute
end
diff --git a/app/graphql/resolvers/full_path_resolver.rb b/app/graphql/resolvers/full_path_resolver.rb
index 0f1a64b6c58..972f318c806 100644
--- a/app/graphql/resolvers/full_path_resolver.rb
+++ b/app/graphql/resolvers/full_path_resolver.rb
@@ -7,14 +7,14 @@ module Resolvers
prepended do
argument :full_path, GraphQL::ID_TYPE,
required: true,
- description: 'The full path of the project or namespace, e.g., "gitlab-org/gitlab-ce"'
+ description: 'The full path of the project, group or namespace, e.g., "gitlab-org/gitlab-ce"'
end
def model_by_full_path(model, full_path)
BatchLoader.for(full_path).batch(key: model) do |full_paths, loader, args|
# `with_route` avoids an N+1 calculating full_path
- args[:key].where_full_path_in(full_paths).with_route.each do |project|
- loader.call(project.full_path, project)
+ args[:key].where_full_path_in(full_paths).with_route.each do |model_instance|
+ loader.call(model_instance.full_path, model_instance)
end
end
end
diff --git a/app/graphql/resolvers/group_resolver.rb b/app/graphql/resolvers/group_resolver.rb
new file mode 100644
index 00000000000..4260e18829e
--- /dev/null
+++ b/app/graphql/resolvers/group_resolver.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class GroupResolver < BaseResolver
+ prepend FullPathResolver
+
+ type Types::GroupType, null: true
+
+ def resolve(full_path:)
+ model_by_full_path(Group, full_path)
+ end
+ end
+end
diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb
index b98d8bd1fff..6988b451ec3 100644
--- a/app/graphql/resolvers/issues_resolver.rb
+++ b/app/graphql/resolvers/issues_resolver.rb
@@ -2,11 +2,11 @@
module Resolvers
class IssuesResolver < BaseResolver
- argument :iid, GraphQL::ID_TYPE,
+ argument :iid, GraphQL::STRING_TYPE,
required: false,
description: 'The IID of the issue, e.g., "1"'
- argument :iids, [GraphQL::ID_TYPE],
+ argument :iids, [GraphQL::STRING_TYPE],
required: false,
description: 'The list of IIDs of issues, e.g., [1, 2]'
argument :state, Types::IssuableStateEnum,
@@ -44,6 +44,12 @@ module Resolvers
alias_method :project, :object
def resolve(**args)
+ # The project could have been loaded in batch by `BatchLoader`.
+ # At this point we need the `id` of the project to query for issues, so
+ # make sure it's loaded and not `nil` before continuing.
+ project.sync if project.respond_to?(:sync)
+ return Issue.none if project.nil?
+
# Will need to be be made group & namespace aware with
# https://gitlab.com/gitlab-org/gitlab-ce/issues/54520
args[:project_id] = project.id
@@ -51,5 +57,12 @@ module Resolvers
IssuesFinder.new(context[:current_user], args).execute
end
+
+ def self.resolver_complexity(args, child_complexity:)
+ complexity = super
+ complexity += 2 if args[:labelName]
+
+ complexity
+ end
end
end
diff --git a/app/graphql/resolvers/merge_requests_resolver.rb b/app/graphql/resolvers/merge_requests_resolver.rb
index 90795c797ac..b84e60066e1 100644
--- a/app/graphql/resolvers/merge_requests_resolver.rb
+++ b/app/graphql/resolvers/merge_requests_resolver.rb
@@ -2,11 +2,11 @@
module Resolvers
class MergeRequestsResolver < BaseResolver
- argument :iid, GraphQL::ID_TYPE,
+ argument :iid, GraphQL::STRING_TYPE,
required: false,
description: 'The IID of the merge request, e.g., "1"'
- argument :iids, [GraphQL::ID_TYPE],
+ argument :iids, [GraphQL::STRING_TYPE],
required: false,
description: 'The list of IIDs of issues, e.g., [1, 2]'
diff --git a/app/graphql/resolvers/metadata_resolver.rb b/app/graphql/resolvers/metadata_resolver.rb
new file mode 100644
index 00000000000..3a79e6434fb
--- /dev/null
+++ b/app/graphql/resolvers/metadata_resolver.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class MetadataResolver < BaseResolver
+ type Types::MetadataType, null: false
+
+ def resolve(**args)
+ { version: Gitlab::VERSION, revision: Gitlab.revision }
+ end
+ end
+end
diff --git a/app/graphql/resolvers/namespace_projects_resolver.rb b/app/graphql/resolvers/namespace_projects_resolver.rb
new file mode 100644
index 00000000000..677ea808aeb
--- /dev/null
+++ b/app/graphql/resolvers/namespace_projects_resolver.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class NamespaceProjectsResolver < BaseResolver
+ argument :include_subgroups, GraphQL::BOOLEAN_TYPE,
+ required: false,
+ default_value: false,
+ description: 'Include also subgroup projects'
+
+ type Types::ProjectType, null: true
+
+ alias_method :namespace, :object
+
+ def resolve(include_subgroups:)
+ # The namespace could have been loaded in batch by `BatchLoader`.
+ # At this point we need the `id` or the `full_path` of the namespace
+ # to query for projects, so make sure it's loaded and not `nil` before continuing.
+ namespace.sync if namespace.respond_to?(:sync)
+ return Project.none if namespace.nil?
+
+ if include_subgroups
+ namespace.all_projects.with_route
+ else
+ namespace.projects.with_route
+ end
+ end
+
+ def self.resolver_complexity(args, child_complexity:)
+ complexity = super
+ complexity + 10
+ end
+ end
+end
diff --git a/app/graphql/resolvers/namespace_resolver.rb b/app/graphql/resolvers/namespace_resolver.rb
new file mode 100644
index 00000000000..17b3800d151
--- /dev/null
+++ b/app/graphql/resolvers/namespace_resolver.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class NamespaceResolver < BaseResolver
+ prepend FullPathResolver
+
+ type Types::NamespaceType, null: true
+
+ def resolve(full_path:)
+ model_by_full_path(Namespace, full_path)
+ end
+ end
+end
diff --git a/app/graphql/resolvers/project_resolver.rb b/app/graphql/resolvers/project_resolver.rb
index ac7c9b0ce2e..2132447da5e 100644
--- a/app/graphql/resolvers/project_resolver.rb
+++ b/app/graphql/resolvers/project_resolver.rb
@@ -9,5 +9,9 @@ module Resolvers
def resolve(full_path:)
model_by_full_path(Project, full_path)
end
+
+ def self.complexity_multiplier(args)
+ 0
+ end
end
end
diff --git a/app/graphql/resolvers/tree_resolver.rb b/app/graphql/resolvers/tree_resolver.rb
new file mode 100644
index 00000000000..5aad1c71b40
--- /dev/null
+++ b/app/graphql/resolvers/tree_resolver.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class TreeResolver < BaseResolver
+ argument :path, GraphQL::STRING_TYPE,
+ required: false,
+ default_value: '',
+ description: 'The path to get the tree for. Default value is the root of the repository'
+ argument :ref, GraphQL::STRING_TYPE,
+ required: false,
+ default_value: :head,
+ description: 'The commit ref to get the tree for. Default value is HEAD'
+ argument :recursive, GraphQL::BOOLEAN_TYPE,
+ required: false,
+ default_value: false,
+ description: 'Used to get a recursive tree. Default is false'
+
+ alias_method :repository, :object
+
+ def resolve(**args)
+ return unless repository.exists?
+
+ repository.tree(args[:ref], args[:path], recursive: args[:recursive])
+ end
+ end
+end
diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb
index 2b2ea64c00b..dd0d9105df6 100644
--- a/app/graphql/types/base_field.rb
+++ b/app/graphql/types/base_field.rb
@@ -3,5 +3,47 @@
module Types
class BaseField < GraphQL::Schema::Field
prepend Gitlab::Graphql::Authorize
+
+ DEFAULT_COMPLEXITY = 1
+
+ def initialize(*args, **kwargs, &block)
+ kwargs[:complexity] ||= field_complexity(kwargs[:resolver_class])
+
+ super(*args, **kwargs, &block)
+ end
+
+ private
+
+ def field_complexity(resolver_class)
+ if resolver_class
+ field_resolver_complexity
+ else
+ DEFAULT_COMPLEXITY
+ end
+ end
+
+ def field_resolver_complexity
+ # Complexity can be either integer or proc. If proc is used then it's
+ # called when computing a query complexity and context and query
+ # arguments are available for computing complexity. For resolvers we use
+ # proc because we set complexity depending on arguments and number of
+ # items which can be loaded.
+ proc do |ctx, args, child_complexity|
+ # Resolvers may add extra complexity depending on used arguments
+ complexity = child_complexity + self.resolver&.try(:resolver_complexity, args, child_complexity: child_complexity).to_i
+
+ field_defn = to_graphql
+
+ if field_defn.connection?
+ # Resolvers may add extra complexity depending on number of items being loaded.
+ page_size = field_defn.connection_max_page_size || ctx.schema.default_max_page_size
+ limit_value = [args[:first], args[:last], page_size].compact.min
+ multiplier = self.resolver&.try(:complexity_multiplier, args).to_f
+ complexity += complexity * limit_value * multiplier
+ end
+
+ complexity.to_i
+ end
+ end
end
end
diff --git a/app/graphql/types/base_object.rb b/app/graphql/types/base_object.rb
index 82b78abd573..e40059c46bb 100644
--- a/app/graphql/types/base_object.rb
+++ b/app/graphql/types/base_object.rb
@@ -6,5 +6,10 @@ module Types
prepend Gitlab::Graphql::ExposePermissions
field_class Types::BaseField
+
+ # All graphql fields exposing an id, should expose a global id.
+ def id
+ GitlabSchema.id_from_object(object)
+ end
end
end
diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb
new file mode 100644
index 00000000000..2987354b556
--- /dev/null
+++ b/app/graphql/types/ci/detailed_status_type.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+module Types
+ module Ci
+ class DetailedStatusType < BaseObject
+ graphql_name 'DetailedStatus'
+
+ field :group, GraphQL::STRING_TYPE, null: false
+ field :icon, GraphQL::STRING_TYPE, null: false
+ field :favicon, GraphQL::STRING_TYPE, null: false
+ field :details_path, GraphQL::STRING_TYPE, null: false
+ field :has_details, GraphQL::BOOLEAN_TYPE, null: false, method: :has_details?
+ field :label, GraphQL::STRING_TYPE, null: false
+ field :text, GraphQL::STRING_TYPE, null: false
+ field :tooltip, GraphQL::STRING_TYPE, null: false, method: :status_tooltip
+ end
+ end
+end
diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb
index 2bbffad4563..cff81e5670b 100644
--- a/app/graphql/types/ci/pipeline_type.rb
+++ b/app/graphql/types/ci/pipeline_type.rb
@@ -3,16 +3,22 @@
module Types
module Ci
class PipelineType < BaseObject
- expose_permissions Types::PermissionTypes::Ci::Pipeline
-
graphql_name 'Pipeline'
+ authorize :read_pipeline
+
+ expose_permissions Types::PermissionTypes::Ci::Pipeline
+
field :id, GraphQL::ID_TYPE, null: false
- field :iid, GraphQL::ID_TYPE, null: false
+ field :iid, GraphQL::STRING_TYPE, null: false
field :sha, GraphQL::STRING_TYPE, null: false
field :before_sha, GraphQL::STRING_TYPE, null: true
field :status, PipelineStatusEnum, null: false
+ field :detailed_status,
+ Types::Ci::DetailedStatusType,
+ null: false,
+ resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) }
field :duration,
GraphQL::INT_TYPE,
null: true,
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
new file mode 100644
index 00000000000..530aecc2bf9
--- /dev/null
+++ b/app/graphql/types/group_type.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Types
+ class GroupType < NamespaceType
+ graphql_name 'Group'
+
+ authorize :read_group
+
+ expose_permissions Types::PermissionTypes::Group
+
+ field :web_url, GraphQL::STRING_TYPE, null: false
+
+ field :avatar_url, GraphQL::STRING_TYPE, null: true, resolve: -> (group, args, ctx) do
+ group.avatar_url(only_path: false)
+ end
+
+ if ::Group.supports_nested_objects?
+ field :parent, GroupType,
+ null: true,
+ resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.parent_id).find }
+ end
+ end
+end
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index 87f6b1f8278..dd5133189dc 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -2,10 +2,12 @@
module Types
class IssueType < BaseObject
- expose_permissions Types::PermissionTypes::Issue
-
graphql_name 'Issue'
+ authorize :read_issue
+
+ expose_permissions Types::PermissionTypes::Issue
+
present_using IssuePresenter
field :iid, GraphQL::ID_TYPE, null: false
@@ -13,20 +15,22 @@ module Types
field :description, GraphQL::STRING_TYPE, null: true
field :state, IssueStateEnum, null: false
+ field :reference, GraphQL::STRING_TYPE, null: false, method: :to_reference do
+ argument :full, GraphQL::BOOLEAN_TYPE, required: false, default_value: false
+ end
+
field :author, Types::UserType,
null: false,
- resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find } do
- authorize :read_user
- end
+ resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find }
- field :assignees, Types::UserType.connection_type, null: true
+ # Remove complexity when BatchLoader is used
+ field :assignees, Types::UserType.connection_type, null: true, complexity: 5
- field :labels, Types::LabelType.connection_type, null: true
+ # Remove complexity when BatchLoader is used
+ field :labels, Types::LabelType.connection_type, null: true, complexity: 5
field :milestone, Types::MilestoneType,
null: true,
- resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find } do
- authorize :read_milestone
- end
+ resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find }
field :due_date, Types::TimeType, null: true
field :confidential, GraphQL::BOOLEAN_TYPE, null: false
@@ -37,7 +41,9 @@ module Types
field :upvotes, GraphQL::INT_TYPE, null: false
field :downvotes, GraphQL::INT_TYPE, null: false
field :user_notes_count, GraphQL::INT_TYPE, null: false
+ field :web_path, GraphQL::STRING_TYPE, null: false, method: :issue_path
field :web_url, GraphQL::STRING_TYPE, null: false
+ field :relative_position, GraphQL::INT_TYPE, null: true
field :closed_at, Types::TimeType, null: true
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index 7827b6e3717..85ac3102442 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -2,14 +2,16 @@
module Types
class MergeRequestType < BaseObject
+ graphql_name 'MergeRequest'
+
+ authorize :read_merge_request
+
expose_permissions Types::PermissionTypes::MergeRequest
present_using MergeRequestPresenter
- graphql_name 'MergeRequest'
-
field :id, GraphQL::ID_TYPE, null: false
- field :iid, GraphQL::ID_TYPE, null: false
+ field :iid, GraphQL::STRING_TYPE, null: false
field :title, GraphQL::STRING_TYPE, null: false
field :description, GraphQL::STRING_TYPE, null: true
field :state, MergeRequestStateEnum, null: false
@@ -48,9 +50,7 @@ module Types
field :downvotes, GraphQL::INT_TYPE, null: false
field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false
- field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline do
- authorize :read_pipeline
- end
+ field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline
field :pipelines, Types::Ci::PipelineType.connection_type,
resolver: Resolvers::MergeRequestPipelinesResolver
end
diff --git a/app/graphql/types/metadata_type.rb b/app/graphql/types/metadata_type.rb
new file mode 100644
index 00000000000..2d8bad0614b
--- /dev/null
+++ b/app/graphql/types/metadata_type.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Types
+ class MetadataType < ::Types::BaseObject
+ graphql_name 'Metadata'
+
+ field :version, GraphQL::STRING_TYPE, null: false
+ field :revision, GraphQL::STRING_TYPE, null: false
+ end
+end
diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb
index af31b572c9a..2772fbec86f 100644
--- a/app/graphql/types/milestone_type.rb
+++ b/app/graphql/types/milestone_type.rb
@@ -4,6 +4,8 @@ module Types
class MilestoneType < BaseObject
graphql_name 'Milestone'
+ authorize :read_milestone
+
field :description, GraphQL::STRING_TYPE, null: true
field :title, GraphQL::STRING_TYPE, null: false
field :state, GraphQL::STRING_TYPE, null: false
diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb
new file mode 100644
index 00000000000..f6d91320e50
--- /dev/null
+++ b/app/graphql/types/namespace_type.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Types
+ class NamespaceType < BaseObject
+ graphql_name 'Namespace'
+
+ field :id, GraphQL::ID_TYPE, null: false
+
+ field :name, GraphQL::STRING_TYPE, null: false
+ field :path, GraphQL::STRING_TYPE, null: false
+ field :full_name, GraphQL::STRING_TYPE, null: false
+ field :full_path, GraphQL::ID_TYPE, null: false
+
+ field :description, GraphQL::STRING_TYPE, null: true
+ field :visibility, GraphQL::STRING_TYPE, null: true
+ field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, method: :lfs_enabled?
+ field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true
+
+ field :projects,
+ Types::ProjectType.connection_type,
+ null: false,
+ resolver: ::Resolvers::NamespaceProjectsResolver
+ end
+end
diff --git a/app/graphql/types/permission_types/group.rb b/app/graphql/types/permission_types/group.rb
new file mode 100644
index 00000000000..29833993ce6
--- /dev/null
+++ b/app/graphql/types/permission_types/group.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Types
+ module PermissionTypes
+ class Group < BasePermissionType
+ graphql_name 'GroupPermissions'
+
+ abilities :read_group
+ end
+ end
+end
diff --git a/app/graphql/types/project_statistics_type.rb b/app/graphql/types/project_statistics_type.rb
new file mode 100644
index 00000000000..62537361918
--- /dev/null
+++ b/app/graphql/types/project_statistics_type.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Types
+ class ProjectStatisticsType < BaseObject
+ graphql_name 'ProjectStatistics'
+
+ field :commit_count, GraphQL::INT_TYPE, null: false
+
+ field :storage_size, GraphQL::INT_TYPE, null: false
+ field :repository_size, GraphQL::INT_TYPE, null: false
+ field :lfs_objects_size, GraphQL::INT_TYPE, null: false
+ field :build_artifacts_size, GraphQL::INT_TYPE, null: false
+ field :packages_size, GraphQL::INT_TYPE, null: false
+ field :wiki_size, GraphQL::INT_TYPE, null: true
+ end
+end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index d25c8c8bd90..2236ffa394d 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -2,10 +2,12 @@
module Types
class ProjectType < BaseObject
- expose_permissions Types::PermissionTypes::Project
-
graphql_name 'Project'
+ authorize :read_project
+
+ expose_permissions Types::PermissionTypes::Project
+
field :id, GraphQL::ID_TYPE, null: false
field :full_path, GraphQL::ID_TYPE, null: false
@@ -16,7 +18,6 @@ module Types
field :description, GraphQL::STRING_TYPE, null: true
- field :default_branch, GraphQL::STRING_TYPE, null: true
field :tag_list, GraphQL::STRING_TYPE, null: true
field :ssh_url_to_repo, GraphQL::STRING_TYPE, null: true
@@ -59,26 +60,30 @@ module Types
end
field :import_status, GraphQL::STRING_TYPE, null: true
- field :ci_config_path, GraphQL::STRING_TYPE, null: true
field :only_allow_merge_if_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true
field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true
field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::BOOLEAN_TYPE, null: true
field :printing_merge_request_link_enabled, GraphQL::BOOLEAN_TYPE, null: true
+ field :namespace, Types::NamespaceType, null: false
+ field :group, Types::GroupType, null: true
+
+ field :statistics, Types::ProjectStatisticsType,
+ null: false,
+ resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchProjectStatisticsLoader.new(obj.id).find }
+
+ field :repository, Types::RepositoryType, null: false
+
field :merge_requests,
Types::MergeRequestType.connection_type,
null: true,
- resolver: Resolvers::MergeRequestsResolver do
- authorize :read_merge_request
- end
+ resolver: Resolvers::MergeRequestsResolver
field :merge_request,
Types::MergeRequestType,
null: true,
- resolver: Resolvers::MergeRequestsResolver.single do
- authorize :read_merge_request
- end
+ resolver: Resolvers::MergeRequestsResolver.single
field :issues,
Types::IssueType.connection_type,
@@ -92,7 +97,7 @@ module Types
field :pipelines,
Types::Ci::PipelineType.connection_type,
- null: false,
+ null: true,
resolver: Resolvers::ProjectPipelinesResolver
end
end
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 7c41716b82a..536bdb077ad 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -1,14 +1,30 @@
# frozen_string_literal: true
module Types
- class QueryType < BaseObject
+ class QueryType < ::Types::BaseObject
graphql_name 'Query'
field :project, Types::ProjectType,
null: true,
resolver: Resolvers::ProjectResolver,
- description: "Find a project" do
- authorize :read_project
+ description: "Find a project"
+
+ field :group, Types::GroupType,
+ null: true,
+ resolver: Resolvers::GroupResolver,
+ description: "Find a group"
+
+ field :namespace, Types::NamespaceType,
+ null: true,
+ resolver: Resolvers::NamespaceResolver,
+ description: "Find a namespace"
+
+ field :metadata, Types::MetadataType,
+ null: true,
+ resolver: Resolvers::MetadataResolver,
+ description: 'Metadata about GitLab' do |*args|
+
+ authorize :read_instance_metadata
end
field :echo, GraphQL::STRING_TYPE, null: false, function: Functions::Echo.new
diff --git a/app/graphql/types/repository_type.rb b/app/graphql/types/repository_type.rb
new file mode 100644
index 00000000000..5987467e1ea
--- /dev/null
+++ b/app/graphql/types/repository_type.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ class RepositoryType < BaseObject
+ graphql_name 'Repository'
+
+ authorize :download_code
+
+ field :root_ref, GraphQL::STRING_TYPE, null: true
+ field :empty, GraphQL::BOOLEAN_TYPE, null: false, method: :empty?
+ field :exists, GraphQL::BOOLEAN_TYPE, null: false, method: :exists?
+ field :tree, Types::Tree::TreeType, null: true, resolver: Resolvers::TreeResolver
+ end
+end
diff --git a/app/graphql/types/tree/blob_type.rb b/app/graphql/types/tree/blob_type.rb
new file mode 100644
index 00000000000..f2b7d5df2b2
--- /dev/null
+++ b/app/graphql/types/tree/blob_type.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+module Types
+ module Tree
+ class BlobType < BaseObject
+ implements Types::Tree::EntryType
+
+ present_using BlobPresenter
+
+ graphql_name 'Blob'
+
+ field :web_url, GraphQL::STRING_TYPE, null: true
+ end
+ end
+end
diff --git a/app/graphql/types/tree/entry_type.rb b/app/graphql/types/tree/entry_type.rb
new file mode 100644
index 00000000000..d8e8642ddb8
--- /dev/null
+++ b/app/graphql/types/tree/entry_type.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+module Types
+ module Tree
+ module EntryType
+ include Types::BaseInterface
+
+ field :id, GraphQL::ID_TYPE, null: false
+ field :name, GraphQL::STRING_TYPE, null: false
+ field :type, Tree::TypeEnum, null: false
+ field :path, GraphQL::STRING_TYPE, null: false
+ field :flat_path, GraphQL::STRING_TYPE, null: false
+ end
+ end
+end
diff --git a/app/graphql/types/tree/submodule_type.rb b/app/graphql/types/tree/submodule_type.rb
new file mode 100644
index 00000000000..cea76dbfd2a
--- /dev/null
+++ b/app/graphql/types/tree/submodule_type.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+module Types
+ module Tree
+ class SubmoduleType < BaseObject
+ implements Types::Tree::EntryType
+
+ graphql_name 'Submodule'
+ end
+ end
+end
diff --git a/app/graphql/types/tree/tree_entry_type.rb b/app/graphql/types/tree/tree_entry_type.rb
new file mode 100644
index 00000000000..23ec2ef0ec2
--- /dev/null
+++ b/app/graphql/types/tree/tree_entry_type.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+module Types
+ module Tree
+ class TreeEntryType < BaseObject
+ implements Types::Tree::EntryType
+
+ present_using TreeEntryPresenter
+
+ graphql_name 'TreeEntry'
+ description 'Represents a directory'
+
+ field :web_url, GraphQL::STRING_TYPE, null: true
+ end
+ end
+end
diff --git a/app/graphql/types/tree/tree_type.rb b/app/graphql/types/tree/tree_type.rb
new file mode 100644
index 00000000000..1ee93ed9542
--- /dev/null
+++ b/app/graphql/types/tree/tree_type.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+module Types
+ module Tree
+ class TreeType < BaseObject
+ graphql_name 'Tree'
+
+ field :trees, Types::Tree::TreeEntryType.connection_type, null: false, resolve: -> (obj, args, ctx) do
+ Gitlab::Graphql::Representation::TreeEntry.decorate(obj.trees, obj.repository)
+ end
+
+ field :submodules, Types::Tree::SubmoduleType.connection_type, null: false
+
+ field :blobs, Types::Tree::BlobType.connection_type, null: false, resolve: -> (obj, args, ctx) do
+ Gitlab::Graphql::Representation::TreeEntry.decorate(obj.blobs, obj.repository)
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/tree/type_enum.rb b/app/graphql/types/tree/type_enum.rb
new file mode 100644
index 00000000000..6560d91e9e5
--- /dev/null
+++ b/app/graphql/types/tree/type_enum.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ module Tree
+ class TypeEnum < BaseEnum
+ graphql_name 'EntryType'
+ description 'Type of a tree entry'
+
+ value 'tree', value: :tree
+ value 'blob', value: :blob
+ value 'commit', value: :commit
+ end
+ end
+end
diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb
index a13e65207df..6b53554314b 100644
--- a/app/graphql/types/user_type.rb
+++ b/app/graphql/types/user_type.rb
@@ -4,6 +4,8 @@ module Types
class UserType < BaseObject
graphql_name 'User'
+ authorize :read_user
+
present_using UserPresenter
field :name, GraphQL::STRING_TYPE, null: false
diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb
index 023e44258b7..c0db9910143 100644
--- a/app/helpers/appearances_helper.rb
+++ b/app/helpers/appearances_helper.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
module AppearancesHelper
+ include MarkupHelper
+
def brand_title
current_appearance&.title.presence || default_brand_title
end
@@ -47,7 +49,7 @@ module AppearancesHelper
class_names = []
class_names << 'with-performance-bar' if performance_bar_enabled?
- render_message(:header_message, class_names)
+ render_message(:header_message, class_names: class_names)
end
def footer_message
@@ -58,10 +60,10 @@ module AppearancesHelper
private
- def render_message(field_sym, class_names = [])
+ def render_message(field_sym, class_names: [], style: message_style)
class_names << field_sym.to_s.dasherize
- content_tag :div, class: class_names, style: message_style do
+ content_tag :div, class: class_names, style: style do
markdown_field(current_appearance, field_sym)
end
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index e635f608237..4469118f065 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -119,6 +119,39 @@ module ApplicationSettingsHelper
options_for_select(options, selected)
end
+ def external_authorization_description
+ _("If enabled, access to projects will be validated on an external service"\
+ " using their classification label.")
+ end
+
+ def external_authorization_timeout_help_text
+ _("Time in seconds GitLab will wait for a response from the external "\
+ "service. When the service does not respond in time, access will be "\
+ "denied.")
+ end
+
+ def external_authorization_url_help_text
+ _("When leaving the URL blank, classification labels can still be "\
+ "specified without disabling cross project features or performing "\
+ "external authorization checks.")
+ end
+
+ def external_authorization_client_certificate_help_text
+ _("The X509 Certificate to use when mutual TLS is required to communicate "\
+ "with the external authorization service. If left blank, the server "\
+ "certificate is still validated when accessing over HTTPS.")
+ end
+
+ def external_authorization_client_key_help_text
+ _("The private key to use when a client certificate is provided. This value "\
+ "is encrypted at rest.")
+ end
+
+ def external_authorization_client_pass_help_text
+ _("The passphrase required to decrypt the private key. This is optional "\
+ "and the value is encrypted at rest.")
+ end
+
def visible_attributes
[
:admin_notification_email,
@@ -127,6 +160,7 @@ module ApplicationSettingsHelper
:akismet_api_key,
:akismet_enabled,
:allow_local_requests_from_hooks_and_services,
+ :dns_rebinding_protection_enabled,
:archive_builds_in_human_readable,
:authorized_keys_enabled,
:auto_devops_enabled,
@@ -137,6 +171,7 @@ module ApplicationSettingsHelper
:default_artifacts_expire_in,
:default_branch_protection,
:default_group_visibility,
+ :default_project_creation,
:default_project_visibility,
:default_projects_limit,
:default_snippet_visibility,
@@ -237,7 +272,23 @@ module ApplicationSettingsHelper
]
end
+ def external_authorization_service_attributes
+ [
+ :external_auth_client_cert,
+ :external_auth_client_key,
+ :external_auth_client_key_pass,
+ :external_authorization_service_default_label,
+ :external_authorization_service_enabled,
+ :external_authorization_service_timeout,
+ :external_authorization_service_url
+ ]
+ end
+
def expanded_by_default?
Rails.env.test?
end
+
+ def instance_clusters_enabled?
+ can?(current_user, :read_cluster, Clusters::Instance.new)
+ end
end
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index 2b1d6f49878..076976175a9 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -2,7 +2,7 @@
module AuthHelper
PROVIDERS_WITH_ICONS = %w(twitter github gitlab bitbucket google_oauth2 facebook azure_oauth2 authentiq).freeze
- LDAP_PROVIDER = /\Aldap/
+ LDAP_PROVIDER = /\Aldap/.freeze
def ldap_enabled?
Gitlab::Auth::LDAP::Config.enabled?
@@ -100,8 +100,12 @@ module AuthHelper
end
# rubocop: enable CodeReuse/ActiveRecord
- def unlink_allowed?(provider)
- %w(saml cas3).exclude?(provider.to_s)
+ def unlink_provider_allowed?(provider)
+ IdentityProviderPolicy.new(current_user, provider).can?(:unlink)
+ end
+
+ def link_provider_allowed?(provider)
+ IdentityProviderPolicy.new(current_user, provider).can?(:link)
end
extend self
diff --git a/app/helpers/auto_devops_helper.rb b/app/helpers/auto_devops_helper.rb
index 67e7e475920..0f0d5350df6 100644
--- a/app/helpers/auto_devops_helper.rb
+++ b/app/helpers/auto_devops_helper.rb
@@ -9,4 +9,17 @@ module AutoDevopsHelper
!project.repository.gitlab_ci_yml &&
!project.ci_service
end
+
+ def badge_for_auto_devops_scope(auto_devops_receiver)
+ return unless auto_devops_receiver.auto_devops_enabled?
+
+ case auto_devops_receiver.first_auto_devops_config[:scope]
+ when :project
+ nil
+ when :group
+ s_('CICD|group enabled')
+ when :instance
+ s_('CICD|instance enabled')
+ end
+ end
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 23d6684a8e6..0d6a6496993 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -19,10 +19,14 @@ module BlobHelper
def ide_edit_path(project = @project, ref = @ref, path = @path, options = {})
segments = [ide_path, 'project', project.full_path, 'edit', ref]
- segments.concat(['-', path]) if path.present?
+ segments.concat(['-', encode_ide_path(path)]) if path.present?
File.join(segments)
end
+ def encode_ide_path(path)
+ url_encode(path).gsub('%2F', '/')
+ end
+
def edit_blob_button(project = @project, ref = @ref, path = @path, options = {})
return unless blob = readable_blob(options, path, project, ref)
@@ -31,12 +35,13 @@ module BlobHelper
edit_button_tag(blob,
common_classes,
_('Edit'),
- edit_blob_path(project, ref, path, options),
+ Feature.enabled?(:web_ide_default) ? ide_edit_path(project, ref, path, options) : edit_blob_path(project, ref, path, options),
project,
ref)
end
def ide_edit_button(project = @project, ref = @ref, path = @path, options = {})
+ return if Feature.enabled?(:web_ide_default)
return unless blob = readable_blob(options, path, project, ref)
edit_button_tag(blob,
@@ -72,7 +77,7 @@ module BlobHelper
project,
ref,
path,
- label: "Replace",
+ label: _("Replace"),
action: "replace",
btn_class: "default",
modal_type: "upload"
@@ -84,7 +89,7 @@ module BlobHelper
project,
ref,
path,
- label: "Delete",
+ label: _("Delete"),
action: "delete",
btn_class: "remove",
modal_type: "remove"
@@ -96,14 +101,14 @@ module BlobHelper
end
def leave_edit_message
- "Leave edit mode?\nAll unsaved changes will be lost."
+ _("Leave edit mode? All unsaved changes will be lost.")
end
def editing_preview_title(filename)
if Gitlab::MarkupHelper.previewable?(filename)
- 'Preview'
+ _('Preview')
else
- 'Preview changes'
+ _('Preview changes')
end
end
@@ -183,7 +188,7 @@ module BlobHelper
end
def copy_file_path_button(file_path)
- clipboard_button(text: file_path, gfm: "`#{file_path}`", class: 'btn-clipboard btn-transparent prepend-left-5', title: 'Copy file path to clipboard')
+ clipboard_button(text: file_path, gfm: "`#{file_path}`", class: 'btn-clipboard btn-transparent', title: 'Copy file path to clipboard')
end
def copy_blob_source_button(blob)
@@ -196,14 +201,14 @@ module BlobHelper
return if blob.empty?
return if blob.binary? || blob.stored_externally?
- title = 'Open raw'
+ 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' }
end
def download_blob_button(blob)
return if blob.empty?
- title = 'Download'
+ 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' }
end
diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb
index be1e7016a1e..1640f4fc93f 100644
--- a/app/helpers/boards_helper.rb
+++ b/app/helpers/boards_helper.rb
@@ -69,7 +69,7 @@ module BoardsHelper
end
def board_sidebar_user_data
- dropdown_options = issue_assignees_dropdown_options
+ dropdown_options = assignees_dropdown_options('issue')
{
toggle: 'dropdown',
diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb
index 289cb44f1e8..495c29d3e24 100644
--- a/app/helpers/broadcast_messages_helper.rb
+++ b/app/helpers/broadcast_messages_helper.rb
@@ -4,7 +4,7 @@ module BroadcastMessagesHelper
def broadcast_message(message)
return unless message.present?
- content_tag :div, class: 'broadcast-message', style: broadcast_message_style(message) do
+ content_tag :div, dir: 'auto', class: 'broadcast-message', style: broadcast_message_style(message) do
icon('bullhorn') << ' ' << render_broadcast_message(message)
end
end
diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb
index 3c8caec3fe5..a5fe6bb8f07 100644
--- a/app/helpers/builds_helper.rb
+++ b/app/helpers/builds_helper.rb
@@ -4,12 +4,12 @@ module BuildsHelper
def build_summary(build, skip: false)
if build.has_trace?
if skip
- link_to "View job trace", pipeline_job_url(build.pipeline, build)
+ link_to _("View job trace"), pipeline_job_url(build.pipeline, build)
else
build.trace.html(last_lines: 10).html_safe
end
else
- "No job trace"
+ _("No job trace")
end
end
@@ -31,7 +31,7 @@ module BuildsHelper
def build_failed_issue_options
{
- title: "Job Failed ##{@build.id}",
+ title: _("Job Failed #%{build_id}") % { build_id: @build.id },
description: project_job_url(@project, @build)
}
end
diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb
index 494c754e7d5..03adbfa204f 100644
--- a/app/helpers/button_helper.rb
+++ b/app/helpers/button_helper.rb
@@ -21,7 +21,7 @@ module ButtonHelper
# See http://clipboardjs.com/#usage
def clipboard_button(data = {})
css_class = data[:class] || 'btn-clipboard btn-transparent'
- title = data[:title] || 'Copy to clipboard'
+ title = data[:title] || _('Copy to clipboard')
button_text = data[:button_text] || ''
hide_tooltip = data[:hide_tooltip] || false
hide_button_icon = data[:hide_button_icon] || false
diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb
index 923a06a0512..f2b5b82b013 100644
--- a/app/helpers/ci_status_helper.rb
+++ b/app/helpers/ci_status_helper.rb
@@ -16,7 +16,7 @@ module CiStatusHelper
label = case status
when 'success'
'passed'
- when 'success_with_warnings'
+ when 'success-with-warnings'
'passed with warnings'
when 'manual'
'waiting for manual action'
@@ -37,7 +37,7 @@ module CiStatusHelper
case status
when 'success'
s_('CiStatusText|passed')
- when 'success_with_warnings'
+ when 'success-with-warnings'
s_('CiStatusText|passed')
when 'manual'
s_('CiStatusText|blocked')
@@ -71,7 +71,7 @@ module CiStatusHelper
case status
when 'success'
'status_success'
- when 'success_with_warnings'
+ when 'success-with-warnings'
'status_warning'
when 'failed'
'status_failed'
@@ -100,17 +100,6 @@ module CiStatusHelper
"pipeline-status/#{pipeline_status.sha}-#{pipeline_status.status}"
end
- def render_project_pipeline_status(pipeline_status, tooltip_placement: 'left')
- project = pipeline_status.project
- path = pipelines_project_commit_path(project, pipeline_status.sha, ref: pipeline_status.ref)
-
- render_status_with_link(
- 'commit',
- pipeline_status.status,
- path,
- tooltip_placement: tooltip_placement)
- end
-
def render_commit_status(commit, ref: nil, tooltip_placement: 'left')
project = commit.project
path = pipelines_project_commit_path(project, commit, ref: ref)
@@ -123,14 +112,8 @@ module CiStatusHelper
icon_size: 24)
end
- def render_pipeline_status(pipeline, tooltip_placement: 'left')
- project = pipeline.project
- path = project_pipeline_path(project, pipeline)
- render_status_with_link('pipeline', pipeline.status, path, tooltip_placement: tooltip_placement)
- end
-
def render_status_with_link(type, status, path = nil, tooltip_placement: 'left', cssclass: '', container: 'body', icon_size: 16)
- klass = "ci-status-link ci-status-icon-#{status.dasherize} #{cssclass}"
+ klass = "ci-status-link ci-status-icon-#{status.dasherize} d-inline-flex #{cssclass}"
title = "#{type.titleize}: #{ci_label_for_status(status)}"
data = { toggle: 'tooltip', placement: tooltip_placement, container: container }
diff --git a/app/helpers/ci_variables_helper.rb b/app/helpers/ci_variables_helper.rb
index e3728804c2a..fc51f00d052 100644
--- a/app/helpers/ci_variables_helper.rb
+++ b/app/helpers/ci_variables_helper.rb
@@ -12,4 +12,23 @@ module CiVariablesHelper
ci_variable_protected_by_default?
end
end
+
+ def ci_variable_masked?(variable, only_key_value)
+ if variable && !only_key_value
+ variable.masked
+ else
+ false
+ end
+ end
+
+ def ci_variable_type_options
+ [
+ %w(Variable env_var),
+ %w(File file)
+ ]
+ end
+
+ def ci_variable_maskable_regex
+ Maskable::REGEX.inspect.sub('\\A', '^').sub('\\z', '$').sub(/^\//, '').sub(/\/[a-z]*$/, '').gsub('\/', '/')
+ end
end
diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb
index 916dcb1a308..769f75f57c4 100644
--- a/app/helpers/clusters_helper.rb
+++ b/app/helpers/clusters_helper.rb
@@ -14,4 +14,10 @@ module ClustersHelper
render 'clusters/clusters/gcp_signup_offer_banner'
end
end
+
+ def has_rbac_enabled?(cluster)
+ return cluster.platform_kubernetes_rbac? if cluster.platform_kubernetes
+
+ !cluster.provider.legacy_abac?
+ end
end
diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb
index d90ef8903a7..42732eb93dd 100644
--- a/app/helpers/dashboard_helper.rb
+++ b/app/helpers/dashboard_helper.rb
@@ -21,6 +21,10 @@ module DashboardHelper
links.any? { |link| dashboard_nav_link?(link) }
end
+ def has_start_trial?
+ false
+ end
+
private
def get_dashboard_nav_links
diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index dedc58f482b..36122d3a22a 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -57,12 +57,6 @@ module EmailsHelper
pluralize(valid_length, unit)
end
- def reset_token_expire_message
- link_tag = link_to('request a new one', new_user_password_url(user_email: @user.email))
- "This link is valid for #{password_reset_token_valid_time}. " \
- "After it expires, you can #{link_tag}."
- end
-
def header_logo
if current_appearance&.header_logo?
image_tag(
@@ -91,6 +85,29 @@ module EmailsHelper
].join(';')
end
+ def closure_reason_text(closed_via, format: nil)
+ case closed_via
+ when MergeRequest
+ merge_request = MergeRequest.find(closed_via[:id]).present
+
+ case format
+ when :html
+ merge_request_link = link_to(merge_request.to_reference, merge_request.web_url)
+ _("via merge request %{link}").html_safe % { link: merge_request_link }
+ else
+ # If it's not HTML nor text then assume it's text to be safe
+ _("via merge request %{link}") % { link: "#{merge_request.to_reference} (#{merge_request.web_url})" }
+ end
+ when String
+ # Technically speaking this should be Commit but per
+ # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/15610#note_163812339
+ # we can't deserialize Commit without custom serializer for ActiveJob
+ _("via %{closed_via}") % { closed_via: closed_via }
+ else
+ ""
+ end
+ end
+
# "You are receiving this email because #{reason}"
def notification_reason_text(reason)
string = case reason
@@ -131,4 +148,42 @@ module EmailsHelper
project.id.to_s + "." + project_path_as_domain + "." + Gitlab.config.gitlab.host
end
+
+ def html_header_message
+ return unless show_header?
+
+ render_message(:header_message, style: '')
+ end
+
+ def html_footer_message
+ return unless show_footer?
+
+ render_message(:footer_message, style: '')
+ end
+
+ def text_header_message
+ return unless show_header?
+
+ strip_tags(render_message(:header_message, style: ''))
+ end
+
+ def text_footer_message
+ return unless show_footer?
+
+ strip_tags(render_message(:footer_message, style: ''))
+ end
+
+ private
+
+ def show_footer?
+ email_header_and_footer_enabled? && current_appearance&.show_footer?
+ end
+
+ def show_header?
+ email_header_and_footer_enabled? && current_appearance&.show_header?
+ end
+
+ def email_header_and_footer_enabled?
+ current_appearance&.email_header_and_footer_enabled?
+ end
end
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index 365b94f5a3e..8002eb08ada 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -30,7 +30,8 @@ module EnvironmentsHelper
"environments-endpoint": project_environments_path(project, format: :json),
"project-path" => project_path(project),
"tags-path" => project_tags_path(project),
- "has-metrics" => "#{environment.has_metrics?}"
+ "has-metrics" => "#{environment.has_metrics?}",
+ "external-dashboard-url" => project.metrics_setting_external_dashboard_url
}
end
end
diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb
index 1371e9993b4..e990e425cb6 100644
--- a/app/helpers/events_helper.rb
+++ b/app/helpers/events_helper.rb
@@ -68,7 +68,7 @@ module EventsHelper
end
def event_preposition(event)
- if event.push? || event.commented? || event.target
+ if event.push_action? || event.commented_action? || event.target
"at"
elsif event.milestone?
"in"
@@ -80,11 +80,11 @@ module EventsHelper
words << event.author_name
words << event_action_name(event)
- if event.push?
+ if event.push_action?
words << event.ref_type
words << event.ref_name
words << "at"
- elsif event.commented?
+ elsif event.commented_action?
words << event.note_target_reference
words << "at"
elsif event.milestone?
@@ -121,9 +121,9 @@ module EventsHelper
if event.note_target
event_note_target_url(event)
end
- elsif event.push?
+ elsif event.push_action?
push_event_feed_url(event)
- elsif event.created_project?
+ elsif event.created_project_action?
project_url(event.project)
end
end
@@ -147,7 +147,7 @@ module EventsHelper
def event_feed_summary(event)
if event.issue?
render "events/event_issue", issue: event.issue
- elsif event.push?
+ elsif event.push_action?
render "events/event_push", event: event
elsif event.merge_request?
render "events/event_merge_request", merge_request: event.merge_request
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index 5705ee54cee..f7c7f37cc38 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -4,8 +4,7 @@ module FormHelper
def form_errors(model, type: 'form')
return unless model.errors.any?
- pluralized = 'error'.pluralize(model.errors.count)
- headline = "The #{type} contains the following #{pluralized}:"
+ headline = n_('The %{type} contains the following error:', 'The %{type} contains the following errors:', model.errors.count) % { type: type }
content_tag(:div, class: 'alert alert-danger', id: 'error_explanation') do
content_tag(:h4, headline) <<
@@ -18,19 +17,19 @@ module FormHelper
end
end
- def issue_assignees_dropdown_options
- {
+ def assignees_dropdown_options(issuable_type)
+ dropdown_data = {
toggle_class: 'js-user-search js-assignee-search js-multiselect js-save-user-data',
title: 'Select assignee',
filter: true,
dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee',
- placeholder: 'Search users',
+ placeholder: _('Search users'),
data: {
first_user: current_user&.username,
null_user: true,
current_user: true,
- project_id: @project&.id,
- field_name: 'issue[assignee_ids][]',
+ project_id: (@target_project || @project)&.id,
+ field_name: "#{issuable_type}[assignee_ids][]",
default_label: 'Unassigned',
'max-select': 1,
'dropdown-header': 'Assignee',
@@ -40,5 +39,36 @@ module FormHelper
current_user_info: UserSerializer.new.represent(current_user)
}
}
+
+ type = issuable_type.to_s
+
+ if type == 'issue' && issue_supports_multiple_assignees? ||
+ type == 'merge_request' && merge_request_supports_multiple_assignees?
+ dropdown_data = multiple_assignees_dropdown_options(dropdown_data)
+ end
+
+ dropdown_data
+ end
+
+ # Overwritten
+ def issue_supports_multiple_assignees?
+ false
+ end
+
+ # Overwritten
+ def merge_request_supports_multiple_assignees?
+ false
+ end
+
+ private
+
+ def multiple_assignees_dropdown_options(options)
+ new_options = options.dup
+
+ new_options[:title] = 'Select assignee(s)'
+ new_options[:data][:'dropdown-header'] = 'Assignee(s)'
+ new_options[:data].delete(:'max-select')
+
+ new_options
end
end
diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb
new file mode 100644
index 00000000000..a5d2f76820f
--- /dev/null
+++ b/app/helpers/groups/group_members_helper.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Groups::GroupMembersHelper
+ def group_member_select_options
+ { multiple: true, class: 'input-clamp', scope: :all, email_user: true }
+ end
+end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 4a9ed123161..a3f53ca8dd6 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -4,6 +4,7 @@ module GroupsHelper
def group_overview_nav_link_paths
%w[
groups#show
+ groups#details
groups#activity
groups#subgroups
analytics#show
@@ -98,7 +99,7 @@ module GroupsHelper
end
def remove_group_message(group)
- _("You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?") %
+ _("You are going to remove %{group_name}, this will also remove all of its subgroups and projects. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?") %
{ group_name: group.name }
end
@@ -117,11 +118,12 @@ module GroupsHelper
end
def parent_group_options(current_group)
- groups = current_user.owned_groups.sort_by(&:human_name).map do |group|
+ exclude_groups = current_group.self_and_descendants.pluck_primary_key
+ exclude_groups << current_group.parent_id if current_group.parent_id
+ groups = GroupsFinder.new(current_user, min_access_level: Gitlab::Access::OWNER, exclude_group_ids: exclude_groups).execute.sort_by(&:human_name).map do |group|
{ id: group.id, text: group.human_name }
end
- groups.delete_if { |group| group[:id] == current_group.id }
groups.to_json
end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index af28e6fcb93..9a12db258d5 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -15,11 +15,14 @@ module IssuablesHelper
sidebar_gutter_collapsed? ? _('Expand sidebar') : _('Collapse sidebar')
end
- def sidebar_assignee_tooltip_label(issuable)
- if issuable.assignee
- issuable.assignee.name
+ def assignees_label(issuable, include_value: true)
+ label = 'Assignee'.pluralize(issuable.assignees.count)
+
+ if include_value
+ sanitized_list = sanitize_name(issuable.assignee_list)
+ "#{label}: #{sanitized_list}"
else
- issuable.allows_multiple_assignees? ? _('Assignee(s)') : _('Assignee')
+ label
end
end
@@ -191,7 +194,7 @@ module IssuablesHelper
output << content_tag(:strong) do
author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline")
- author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-block d-sm-none")
+ author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-inline d-sm-none")
if status = user_status(issuable.author)
author_output << "#{status}".html_safe
@@ -277,6 +280,8 @@ module IssuablesHelper
initialTaskStatus: issuable.task_status
}
+ data[:hasClosingMergeRequest] = issuable.merge_requests_count != 0 if issuable.is_a?(Issue)
+
if parent.is_a?(Group)
data[:groupPath] = parent.path
else
diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb
index bd53add80ca..db4f29cd996 100644
--- a/app/helpers/labels_helper.rb
+++ b/app/helpers/labels_helper.rb
@@ -5,7 +5,7 @@ module LabelsHelper
include ActionView::Helpers::TagHelper
def show_label_issuables_link?(label, issuables_type, current_user: nil, project: nil)
- return true if label.is_a?(GroupLabel)
+ return true unless label.project_label?
return true unless project
project.feature_available?(issuables_type, current_user)
@@ -13,9 +13,7 @@ module LabelsHelper
# Link to a Label
#
- # label - Label object to link to
- # subject - Project/Group object which will be used as the context for the
- # label's link. If omitted, defaults to the label's own group/project.
+ # label - LabelPresenter object to link to
# type - The type of item the link will point to (:issue or
# :merge_request). If omitted, defaults to :issue.
# block - An optional block that will be passed to `link_to`, forming the
@@ -40,81 +38,77 @@ module LabelsHelper
# link_to_label(label) { "My Custom Label Text" }
#
# Returns a String
- def link_to_label(label, subject: nil, type: :issue, tooltip: true, css_class: nil, &block)
- link = label_filter_path(subject || label.subject, label, type: type)
+ def link_to_label(label, type: :issue, tooltip: true, css_class: nil, &block)
+ link = label.filter_path(type: type)
if block_given?
link_to link, class: css_class, &block
else
- link_to render_colored_label(label, tooltip: tooltip), link, class: css_class
+ render_label(label, tooltip: tooltip, link: link, css: css_class)
end
end
- def label_filter_path(subject, label, type: :issue)
- case subject
- when Group
- send("#{type.to_s.pluralize}_group_path", # rubocop:disable GitlabSecurity/PublicSend
- subject,
- label_name: [label.name])
- when Project
- send("namespace_project_#{type.to_s.pluralize}_path", # rubocop:disable GitlabSecurity/PublicSend
- subject.namespace,
- subject,
- label_name: [label.name])
- end
- end
-
- def edit_label_path(label)
- case label
- when GroupLabel then edit_group_label_path(label.group, label)
- when ProjectLabel then edit_project_label_path(label.project, label)
- end
- end
+ def render_label(label, tooltip: true, link: nil, css: 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
- def destroy_label_path(label)
- case label
- when GroupLabel then group_label_path(label.group, label)
- when ProjectLabel then project_label_path(label.project, label)
- end
+ html
end
- def render_colored_label(label, label_suffix = '', tooltip: true)
+ def render_colored_label(label, label_suffix: '', tooltip: true, title: nil)
text_color = text_color_for_bg(label.color)
+ title ||= tooltip ? label_tooltip_title(label) : label.name
# Intentionally not using content_tag here so that this method can be called
# by LabelReferenceFilter
span = %(<span class="badge color-label #{"has-tooltip" if tooltip}" ) +
- %(style="background-color: #{label.color}; color: #{text_color}" ) +
- %(title="#{escape_once(label.description)}" data-container="body">) +
+ %(data-html="true" style="background-color: #{label.color}; color: #{text_color}" ) +
+ %(title="#{escape_once(title)}" data-container="body">) +
%(#{escape_once(label.name)}#{label_suffix}</span>)
span.html_safe
end
+ def label_tooltip_title(label)
+ label.description
+ end
+
def suggested_colors
- [
- '#0033CC',
- '#428BCA',
- '#44AD8E',
- '#A8D695',
- '#5CB85C',
- '#69D100',
- '#004E00',
- '#34495E',
- '#7F8C8D',
- '#A295D6',
- '#5843AD',
- '#8E44AD',
- '#FFECDB',
- '#AD4363',
- '#D10069',
- '#CC0033',
- '#FF0000',
- '#D9534F',
- '#D1D100',
- '#F0AD4E',
- '#AD8D43'
- ]
+ {
+ '#0033CC' => s_('SuggestedColors|UA blue'),
+ '#428BCA' => s_('SuggestedColors|Moderate blue'),
+ '#44AD8E' => s_('SuggestedColors|Lime green'),
+ '#A8D695' => s_('SuggestedColors|Feijoa'),
+ '#5CB85C' => s_('SuggestedColors|Slightly desaturated green'),
+ '#69D100' => s_('SuggestedColors|Bright green'),
+ '#004E00' => s_('SuggestedColors|Very dark lime green'),
+ '#34495E' => s_('SuggestedColors|Very dark desaturated blue'),
+ '#7F8C8D' => s_('SuggestedColors|Dark grayish cyan'),
+ '#A295D6' => s_('SuggestedColors|Slightly desaturated blue'),
+ '#5843AD' => s_('SuggestedColors|Dark moderate blue'),
+ '#8E44AD' => s_('SuggestedColors|Dark moderate violet'),
+ '#FFECDB' => s_('SuggestedColors|Very pale orange'),
+ '#AD4363' => s_('SuggestedColors|Dark moderate pink'),
+ '#D10069' => s_('SuggestedColors|Strong pink'),
+ '#CC0033' => s_('SuggestedColors|Strong red'),
+ '#FF0000' => s_('SuggestedColors|Pure red'),
+ '#D9534F' => s_('SuggestedColors|Soft red'),
+ '#D1D100' => s_('SuggestedColors|Strong yellow'),
+ '#F0AD4E' => s_('SuggestedColors|Soft orange'),
+ '#AD8D43' => s_('SuggestedColors|Dark moderate orange')
+ }
+ end
+
+ def render_suggested_colors
+ colors_html = suggested_colors.map do |color_hex_value, color_name|
+ link_to('', '#', class: "has-tooltip", style: "background-color: #{color_hex_value}", data: { color: color_hex_value }, title: color_name)
+ end
+
+ content_tag(:div, class: 'suggest-colors') do
+ colors_html.join.html_safe
+ end
end
def text_color_for_bg(bg_color)
@@ -154,10 +148,6 @@ module LabelsHelper
end
end
- def can_subscribe_to_label_in_different_levels?(label)
- defined?(@project) && label.is_a?(GroupLabel)
- end
-
def label_subscription_status(label, project)
return 'group-level' if label.subscribed?(current_user)
return 'project-level' if label.subscribed?(current_user, project)
@@ -179,13 +169,6 @@ module LabelsHelper
label.subscribed?(current_user, project) ? 'Unsubscribe' : 'Subscribe'
end
- def label_deletion_confirm_text(label)
- case label
- when GroupLabel then 'Remove this label? This will affect all projects within the group. Are you sure?'
- when ProjectLabel then 'Remove this label? Are you sure?'
- end
- end
-
def create_label_title(subject)
case subject
when Group
@@ -220,17 +203,52 @@ module LabelsHelper
end
def label_status_tooltip(label, status)
- type = label.is_a?(ProjectLabel) ? 'project' : 'group'
+ type = label.project_label? ? 'project' : 'group'
level = status.unsubscribed? ? type : status.sub('-level', '')
action = status.unsubscribed? ? 'Subscribe' : 'Unsubscribe'
"#{action} at #{level} level"
end
- def labels_sorted_by_title(labels)
- labels.sort_by(&:title)
+ def presented_labels_sorted_by_title(labels, subject)
+ labels.sort_by(&:title).map { |label| label.present(issuable_subject: subject) }
+ end
+
+ def label_dropdown_data(project, opts = {})
+ {
+ toggle: "dropdown",
+ field_name: opts[:field_name] || "label_name[]",
+ show_no: "true",
+ show_any: "true",
+ project_id: project&.try(:id),
+ namespace_path: project&.try(:namespace)&.try(:full_path),
+ project_path: project&.try(:path)
+ }.merge(opts)
+ end
+
+ def sidebar_label_dropdown_data(issuable_type, issuable_sidebar)
+ label_dropdown_data(nil, {
+ default_label: "Labels",
+ field_name: "#{issuable_type}[label_names][]",
+ ability_name: issuable_type,
+ namespace_path: issuable_sidebar[:namespace_path],
+ project_path: issuable_sidebar[:project_path],
+ issue_update: issuable_sidebar[:issuable_json_path],
+ labels: issuable_sidebar[:project_labels_path],
+ display: 'static'
+ })
+ end
+
+ def label_from_hash(hash)
+ klass = hash[:group_id] ? GroupLabel : ProjectLabel
+
+ klass.new(hash.slice(:color, :description, :title, :group_id, :project_id))
+ end
+
+ def issuable_types
+ ['issues', 'merge requests']
end
# Required for Banzai::Filter::LabelReferenceFilter
- module_function :render_colored_label, :text_color_for_bg, :escape_once
+ module_function :render_colored_label, :text_color_for_bg, :escape_once, :label_tooltip_title
end
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index 66f4b7b3f30..dce4168ad7b 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -4,7 +4,7 @@ require 'nokogiri'
module MarkupHelper
include ActionView::Helpers::TagHelper
- include ActionView::Context
+ include ::Gitlab::ActionViewOutput::Context
def plain?(filename)
Gitlab::MarkupHelper.plain?(filename)
@@ -74,7 +74,7 @@ module MarkupHelper
# the tag contents are truncated without removing the closing tag.
def first_line_in_markdown(object, attribute, max_chars = nil, options = {})
md = markdown_field(object, attribute, options)
- return nil unless md.present?
+ return unless md.present?
tags = %w(a gl-emoji b pre code p span)
tags << 'img' if options[:allow_images]
@@ -83,7 +83,8 @@ module MarkupHelper
text = sanitize(
text,
tags: tags,
- attributes: Rails::Html::WhiteListSanitizer.allowed_attributes + ['style', 'data-src', 'data-name', 'data-unicode-version']
+ attributes: Rails::Html::WhiteListSanitizer.allowed_attributes +
+ %w(style data-src data-name data-unicode-version data-iid data-project-path data-mr-title)
)
# since <img> tags are stripped, this can leave empty <a> tags hanging around
@@ -241,9 +242,7 @@ module MarkupHelper
node.remove if node.name == 'a' && node.content.blank?
end
- # Use `Loofah` directly instead of `sanitize`
- # as we still use the `rails-deprecated_sanitizer` gem
- Loofah.fragment(text).scrub!(scrubber).to_s
+ sanitize text, scrubber: scrubber
end
def markdown_toolbar_button(options = {})
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index 23d7aa427bb..2de4e92e33e 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -29,7 +29,7 @@ module MergeRequestsHelper
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 nil unless build_url
+ return unless build_url
parsed_url = URI.parse(build_url)
@@ -92,7 +92,7 @@ module MergeRequestsHelper
end
def version_index(merge_request_diff)
- return nil if @merge_request_diffs.empty?
+ return if @merge_request_diffs.empty?
@merge_request_diffs.size - @merge_request_diffs.index(merge_request_diff)
end
@@ -103,7 +103,7 @@ module MergeRequestsHelper
def merge_params(merge_request)
{
- merge_when_pipeline_succeeds: true,
+ auto_merge_strategy: AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS,
should_remove_source_branch: true,
sha: merge_request.diff_head_sha,
squash: merge_request.squash
@@ -149,7 +149,7 @@ module MergeRequestsHelper
def merge_request_source_project_for_project(project = @project)
unless can?(current_user, :create_merge_request_in, project)
- return nil
+ return
end
if can?(current_user, :create_merge_request_from, project)
diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb
index 50aec83b867..c1a04640688 100644
--- a/app/helpers/milestones_helper.rb
+++ b/app/helpers/milestones_helper.rb
@@ -45,7 +45,7 @@ module MilestonesHelper
when :closed
issues.closed
else
- raise ArgumentError, "invalid milestone state `#{state}`"
+ raise ArgumentError, _("invalid milestone state `%{state}`") % { state: state }
end
issues.size
@@ -145,8 +145,13 @@ module MilestonesHelper
content = []
- content << n_("1 open issue", "%d open issues", issues["opened"]) % issues["opened"] if issues["opened"]
- content << n_("1 closed issue", "%d closed issues", issues["closed"]) % issues["closed"] if issues["closed"]
+ if issues["opened"]
+ content << n_("1 open issue", "%{issues} open issues", issues["opened"]) % { issues: issues["opened"] }
+ end
+
+ if issues["closed"]
+ content << n_("1 closed issue", "%{issues} closed issues", issues["closed"]) % { issues: issues["closed"] }
+ end
content.join('<br />').html_safe
end
@@ -158,9 +163,9 @@ module MilestonesHelper
content = []
- content << n_("1 open merge request", "%d open merge requests", merge_requests.opened.count) % merge_requests.opened.count if merge_requests.opened.any?
- content << n_("1 closed merge request", "%d closed merge requests", merge_requests.closed.count) % merge_requests.closed.count if merge_requests.closed.any?
- content << n_("1 merged merge request", "%d merged merge requests", merge_requests.merged.count) % merge_requests.merged.count if merge_requests.merged.any?
+ content << n_("1 open merge request", "%{merge_requests} open merge requests", merge_requests.opened.count) % { merge_requests: merge_requests.opened.count } if merge_requests.opened.any?
+ content << n_("1 closed merge request", "%{merge_requests} closed merge requests", merge_requests.closed.count) % { merge_requests: merge_requests.closed.count } if merge_requests.closed.any?
+ content << n_("1 merged merge request", "%{merge_requests} merged merge requests", merge_requests.merged.count) % { merge_requests: merge_requests.merged.count } if merge_requests.merged.any?
content.join('<br />').html_safe
end
@@ -178,15 +183,15 @@ module MilestonesHelper
"#{milestone.start_date.to_s(:medium)}–#{milestone.due_date.to_s(:medium)}"
elsif milestone.due_date
if milestone.due_date.past?
- "expired on #{milestone.due_date.to_s(:medium)}"
+ _("expired on %{milestone_due_date}") % { milestone_due_date: milestone.due_date.strftime('%b %-d, %Y') }
else
- "expires on #{milestone.due_date.to_s(:medium)}"
+ _("expires on %{milestone_due_date}") % { milestone_due_date: milestone.due_date.strftime('%b %-d, %Y') }
end
elsif milestone.start_date
if milestone.start_date.past?
- "started on #{milestone.start_date.to_s(:medium)}"
+ _("started on %{milestone_start_date}") % { milestone_start_date: milestone.start_date.strftime('%b %-d, %Y') }
else
- "starts on #{milestone.start_date.to_s(:medium)}"
+ _("starts on %{milestone_start_date}") % { milestone_start_date: milestone.start_date.strftime('%b %-d, %Y') }
end
end
end
diff --git a/app/helpers/mirror_helper.rb b/app/helpers/mirror_helper.rb
index 65c7cd82832..921c79ab771 100644
--- a/app/helpers/mirror_helper.rb
+++ b/app/helpers/mirror_helper.rb
@@ -7,4 +7,8 @@ module MirrorHelper
project_mirror_endpoint: project_mirror_path(@project, :json)
}
end
+
+ def mirror_lfs_sync_message
+ _('The Git LFS objects will <strong>not</strong> be synced.').html_safe
+ end
end
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index ea3bcfc791a..572d68cb4a3 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -49,6 +49,13 @@ module NamespacesHelper
end
end
+ def namespaces_options_with_developer_maintainer_access(options = {})
+ selected = options.delete(:selected) || :current_user
+ options[:groups] = current_user.manageable_groups_with_routes(include_groups_with_developer_maintainer_access: true)
+
+ namespaces_options(selected, options)
+ end
+
private
# Many importers create a temporary Group, so use the real
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index 05da5ebdb22..a57ba5f3a4f 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -58,6 +58,14 @@ module NavHelper
current_path?('milestones#show')
end
+ def admin_monitoring_nav_links
+ %w(system_info background_jobs logs health_check requests_profiles)
+ end
+
+ def group_issues_sub_menu_items
+ %w(groups#issues labels#index milestones#index boards#index boards#show)
+ end
+
private
def get_header_links
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index aaf38cbfe70..2e31a5e2ed4 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -122,21 +122,15 @@ module NotesHelper
end
def new_form_url
- return nil unless @snippet.is_a?(PersonalSnippet)
+ return unless @snippet.is_a?(PersonalSnippet)
snippet_notes_path(@snippet)
end
def can_create_note?
- issuable = @issue || @merge_request
+ noteable = @issue || @merge_request || @snippet || @project
- if @snippet.is_a?(PersonalSnippet)
- can?(current_user, :comment_personal_snippet, @snippet)
- elsif issuable
- can?(current_user, :create_note, issuable)
- else
- can?(current_user, :create_note, @project)
- end
+ can?(current_user, :create_note, noteable)
end
def initial_notes_data(autocomplete)
diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb
index 5318ab4ddef..11b9cf22142 100644
--- a/app/helpers/notifications_helper.rb
+++ b/app/helpers/notifications_helper.rb
@@ -93,4 +93,15 @@ module NotificationsHelper
s_(event.to_s.humanize)
end
end
+
+ def notification_setting_icon(notification_setting)
+ sprite_icon(
+ notification_setting.disabled? ? "notifications-off" : "notifications",
+ css_class: "icon notifications-icon js-notifications-icon"
+ )
+ end
+
+ def show_unsubscribe_title?(noteable)
+ can?(current_user, "read_#{noteable.to_ability_name}".to_sym, noteable)
+ end
end
diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb
index 5038dcf9746..ec1d8577f36 100644
--- a/app/helpers/page_layout_helper.rb
+++ b/app/helpers/page_layout_helper.rb
@@ -1,3 +1,4 @@
+# coding: utf-8
# frozen_string_literal: true
module PageLayoutHelper
@@ -36,7 +37,7 @@ module PageLayoutHelper
if description.present?
@page_description = description.squish
elsif @page_description.present?
- sanitize(@page_description, tags: []).truncate_words(30)
+ sanitize(@page_description.truncate_words(30), tags: [])
end
end
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index eed529f93db..766508b6609 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -46,7 +46,8 @@ module PreferencesHelper
def first_day_of_week_choices
[
[_('Sunday'), 0],
- [_('Monday'), 1]
+ [_('Monday'), 1],
+ [_('Saturday'), 6]
]
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index c400302cda3..8dee842a22d 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -169,7 +169,7 @@ module ProjectsHelper
translation.html_safe
end
- def project_list_cache_key(project)
+ def project_list_cache_key(project, pipeline_status: true)
key = [
project.route.cache_key,
project.cache_key,
@@ -179,10 +179,11 @@ module ProjectsHelper
Gitlab::CurrentSettings.cache_key,
"cross-project:#{can?(current_user, :read_cross_project)}",
max_project_member_access_cache_key(project),
+ pipeline_status,
'v2.6'
]
- key << pipeline_status_cache_key(project.pipeline_status) if project.pipeline_status.has_status?
+ key << pipeline_status_cache_key(project.pipeline_status) if pipeline_status && project.pipeline_status.has_status?
key
end
@@ -238,8 +239,11 @@ module ProjectsHelper
end
# rubocop: enable CodeReuse/ActiveRecord
+ # TODO: Remove this method when removing the feature flag
+ # https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/11209#note_162234863
+ # make sure to remove from the EE specific controller as well: ee/app/controllers/ee/dashboard/projects_controller.rb
def show_projects?(projects, params)
- !!(params[:personal] || params[:name] || any_projects?(projects))
+ Feature.enabled?(:project_list_filter_bar) || !!(params[:personal] || params[:name] || any_projects?(projects))
end
def push_to_create_project_command(user = current_user)
@@ -284,13 +288,74 @@ module ProjectsHelper
can?(current_user, :read_environment, @project)
end
+ def error_tracking_setting_project_json
+ setting = @project.error_tracking_setting
+
+ return if setting.blank? || setting.project_slug.blank? ||
+ setting.organization_slug.blank?
+
+ {
+ name: setting.project_name,
+ organization_name: setting.organization_name,
+ organization_slug: setting.organization_slug,
+ slug: setting.project_slug
+ }.to_json
+ end
+
+ def directory?
+ @path.present?
+ end
+
+ def external_classification_label_help_message
+ default_label = ::Gitlab::CurrentSettings.current_application_settings
+ .external_authorization_service_default_label
+
+ s_(
+ "ExternalAuthorizationService|When no classification label is set the "\
+ "default label `%{default_label}` will be used."
+ ) % { default_label: default_label }
+ end
+
+ def can_import_members?
+ Ability.allowed?(current_user, :admin_project_member, @project)
+ end
+
+ def project_can_be_shared?
+ !membership_locked? || @project.allowed_to_share_with_group?
+ end
+
+ def membership_locked?
+ false
+ end
+
+ def share_project_description(project)
+ share_with_group = project.allowed_to_share_with_group?
+ share_with_members = !membership_locked?
+
+ description =
+ if share_with_group && share_with_members
+ _("You can invite a new member to <strong>%{project_name}</strong> or invite another group.")
+ elsif share_with_group
+ _("You can invite another group to <strong>%{project_name}</strong>.")
+ elsif share_with_members
+ _("You can invite a new member to <strong>%{project_name}</strong>.")
+ end
+
+ description.html_safe % { project_name: project.name }
+ end
+
+ def metrics_external_dashboard_url
+ @project.metrics_setting_external_dashboard_url
+ end
+
private
def get_project_nav_tabs(project, current_user)
nav_tabs = [:home]
- if !project.empty_repo? && can?(current_user, :download_code, project)
- nav_tabs << [:files, :commits, :network, :graphs, :forks, :releases]
+ unless project.empty_repo?
+ nav_tabs << [:files, :commits, :network, :graphs, :forks] if can?(current_user, :download_code, project)
+ nav_tabs << :releases if can?(current_user, :read_release, project)
end
if project.repo_exists? && can?(current_user, :read_merge_request, project)
@@ -350,7 +415,8 @@ module ProjectsHelper
blobs: :download_code,
commits: :download_code,
merge_requests: :read_merge_request,
- notes: [:read_merge_request, :download_code, :read_issue, :read_project_snippet]
+ notes: [:read_merge_request, :download_code, :read_issue, :read_project_snippet],
+ members: :read_project_member
)
end
@@ -594,4 +660,8 @@ module ProjectsHelper
project.builds_enabled? &&
!project.repository.gitlab_ci_yml
end
+
+ def vue_file_list_enabled?
+ Gitlab::Graphql.enabled? && Feature.enabled?(:vue_file_list, @project)
+ end
end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 0ee76a51f7d..4594f5a31b9 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -30,13 +30,18 @@ module SearchHelper
to = collection.offset_value + collection.to_a.size
count = collection.total_count
- "Showing #{from} - #{to} of #{count} #{scope.humanize(capitalize: false)} for \"#{term}\""
+ s_("SearchResults|Showing %{from} - %{to} of %{count} %{scope} for \"%{term}\"") % { from: from, to: to, count: count, scope: scope.humanize(capitalize: false), term: term }
end
- def find_project_for_result_blob(result)
+ def find_project_for_result_blob(projects, result)
@project
end
+ # Used in EE
+ def blob_projects(results)
+ nil
+ end
+
def parse_search_result(result)
result
end
@@ -45,36 +50,40 @@ module SearchHelper
filename
end
+ def search_service
+ @search_service ||= ::SearchService.new(current_user, params)
+ end
+
private
# Autocomplete results for various settings pages
def default_autocomplete
[
- { category: "Settings", label: "User settings", url: profile_path },
- { category: "Settings", label: "SSH Keys", url: profile_keys_path },
- { category: "Settings", label: "Dashboard", url: root_path }
+ { category: "Settings", label: _("User settings"), url: profile_path },
+ { category: "Settings", label: _("SSH Keys"), url: profile_keys_path },
+ { category: "Settings", label: _("Dashboard"), url: root_path }
]
end
# Autocomplete results for settings pages, for admins
def default_autocomplete_admin
[
- { category: "Settings", label: "Admin Section", url: admin_root_path }
+ { category: "Settings", label: _("Admin Section"), url: admin_root_path }
]
end
# Autocomplete results for internal help pages
def help_autocomplete
[
- { category: "Help", label: "API Help", url: help_page_path("api/README") },
- { category: "Help", label: "Markdown Help", url: help_page_path("user/markdown") },
- { category: "Help", label: "Permissions Help", url: help_page_path("user/permissions") },
- { category: "Help", label: "Public Access Help", url: help_page_path("public_access/public_access") },
- { category: "Help", label: "Rake Tasks Help", url: help_page_path("raketasks/README") },
- { category: "Help", label: "SSH Keys Help", url: help_page_path("ssh/README") },
- { category: "Help", label: "System Hooks Help", url: help_page_path("system_hooks/system_hooks") },
- { category: "Help", label: "Webhooks Help", url: help_page_path("user/project/integrations/webhooks") },
- { category: "Help", label: "Workflow Help", url: help_page_path("workflow/README") }
+ { category: "Help", label: _("API Help"), url: help_page_path("api/README") },
+ { category: "Help", label: _("Markdown Help"), url: help_page_path("user/markdown") },
+ { category: "Help", label: _("Permissions Help"), url: help_page_path("user/permissions") },
+ { category: "Help", label: _("Public Access Help"), url: help_page_path("public_access/public_access") },
+ { category: "Help", label: _("Rake Tasks Help"), url: help_page_path("raketasks/README") },
+ { category: "Help", label: _("SSH Keys Help"), url: help_page_path("ssh/README") },
+ { category: "Help", label: _("System Hooks Help"), url: help_page_path("system_hooks/system_hooks") },
+ { category: "Help", label: _("Webhooks Help"), url: help_page_path("user/project/integrations/webhooks") },
+ { category: "Help", label: _("Workflow Help"), url: help_page_path("workflow/README") }
]
end
@@ -84,16 +93,16 @@ module SearchHelper
ref = @ref || @project.repository.root_ref
[
- { category: "In this project", label: "Files", url: project_tree_path(@project, ref) },
- { category: "In this project", label: "Commits", url: project_commits_path(@project, ref) },
- { category: "In this project", label: "Network", url: project_network_path(@project, ref) },
- { category: "In this project", label: "Graph", url: project_graph_path(@project, ref) },
- { category: "In this project", label: "Issues", url: project_issues_path(@project) },
- { category: "In this project", label: "Merge Requests", url: project_merge_requests_path(@project) },
- { category: "In this project", label: "Milestones", url: project_milestones_path(@project) },
- { category: "In this project", label: "Snippets", url: project_snippets_path(@project) },
- { category: "In this project", label: "Members", url: project_project_members_path(@project) },
- { category: "In this project", label: "Wiki", url: project_wikis_path(@project) }
+ { category: "In this project", label: _("Files"), url: project_tree_path(@project, ref) },
+ { category: "In this project", label: _("Commits"), url: project_commits_path(@project, ref) },
+ { category: "In this project", label: _("Network"), url: project_network_path(@project, ref) },
+ { category: "In this project", label: _("Graph"), url: project_graph_path(@project, ref) },
+ { category: "In this project", label: _("Issues"), url: project_issues_path(@project) },
+ { category: "In this project", label: _("Merge Requests"), url: project_merge_requests_path(@project) },
+ { category: "In this project", label: _("Milestones"), url: project_milestones_path(@project) },
+ { category: "In this project", label: _("Snippets"), url: project_snippets_path(@project) },
+ { category: "In this project", label: _("Members"), url: project_project_members_path(@project) },
+ { category: "In this project", label: _("Wiki"), url: project_wikis_path(@project) }
]
else
[]
@@ -119,7 +128,7 @@ module SearchHelper
# rubocop: disable CodeReuse/ActiveRecord
def projects_autocomplete(term, limit = 5)
current_user.authorized_projects.order_id_desc.search_by_title(term)
- .sorted_by_stars.non_archived.limit(limit).map do |p|
+ .sorted_by_stars_desc.non_archived.limit(limit).map do |p|
{
category: "Projects",
id: p.id,
@@ -153,7 +162,7 @@ module SearchHelper
opts =
{
id: "filtered-search-#{type}",
- placeholder: 'Search or filter results...',
+ placeholder: _('Search or filter results...'),
data: {
'username-params' => UserSerializer.new.represent(@users)
},
@@ -201,4 +210,14 @@ module SearchHelper
def limited_count(count, limit = 1000)
count > limit ? "#{limit}+" : count
end
+
+ def search_tabs?(tab)
+ return false if Feature.disabled?(:users_search, default_enabled: true)
+
+ if @project
+ project_search_tabs?(:members)
+ else
+ can?(current_user, :read_users_list)
+ end
+ end
end
diff --git a/app/helpers/sidekiq_helper.rb b/app/helpers/sidekiq_helper.rb
index 32bf3526571..6326d98461e 100644
--- a/app/helpers/sidekiq_helper.rb
+++ b/app/helpers/sidekiq_helper.rb
@@ -8,7 +8,7 @@ module SidekiqHelper
(?<state>[DIEKNRSTVWXZNLpsl\+<>/\d]+)\s+
(?<start>.+?)\s+
(?<command>(?:ruby\d+:\s+)?sidekiq.*\].*)
- \z}x
+ \z}x.freeze
def parse_sidekiq_ps(line)
match = line.strip.match(SIDEKIQ_PS_REGEXP)
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 07ec129dea3..26692934456 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -3,40 +3,48 @@
module SortingHelper
def sort_options_hash
{
- sort_value_created_date => sort_title_created_date,
- sort_value_downvotes => sort_title_downvotes,
- sort_value_due_date => sort_title_due_date,
- sort_value_due_date_later => sort_title_due_date_later,
- sort_value_due_date_soon => sort_title_due_date_soon,
- sort_value_label_priority => sort_title_label_priority,
- sort_value_largest_group => sort_title_largest_group,
- sort_value_largest_repo => sort_title_largest_repo,
- sort_value_milestone => sort_title_milestone,
- sort_value_milestone_later => sort_title_milestone_later,
- sort_value_milestone_soon => sort_title_milestone_soon,
- sort_value_name => sort_title_name,
- sort_value_name_desc => sort_title_name_desc,
- sort_value_oldest_created => sort_title_oldest_created,
- sort_value_oldest_signin => sort_title_oldest_signin,
- sort_value_oldest_updated => sort_title_oldest_updated,
- sort_value_recently_created => sort_title_recently_created,
- sort_value_recently_signin => sort_title_recently_signin,
- sort_value_recently_updated => sort_title_recently_updated,
- sort_value_popularity => sort_title_popularity,
- sort_value_priority => sort_title_priority,
- sort_value_upvotes => sort_title_upvotes,
- sort_value_contacted_date => sort_title_contacted_date
+ sort_value_created_date => sort_title_created_date,
+ sort_value_downvotes => sort_title_downvotes,
+ sort_value_due_date => sort_title_due_date,
+ sort_value_due_date_later => sort_title_due_date_later,
+ sort_value_due_date_soon => sort_title_due_date_soon,
+ sort_value_label_priority => sort_title_label_priority,
+ sort_value_largest_group => sort_title_largest_group,
+ sort_value_largest_repo => sort_title_largest_repo,
+ sort_value_milestone => sort_title_milestone,
+ sort_value_milestone_later => sort_title_milestone_later,
+ sort_value_milestone_soon => sort_title_milestone_soon,
+ sort_value_name => sort_title_name,
+ sort_value_name_desc => sort_title_name_desc,
+ sort_value_oldest_created => sort_title_oldest_created,
+ sort_value_oldest_signin => sort_title_oldest_signin,
+ sort_value_oldest_updated => sort_title_oldest_updated,
+ sort_value_recently_created => sort_title_recently_created,
+ sort_value_recently_signin => sort_title_recently_signin,
+ sort_value_recently_updated => sort_title_recently_updated,
+ sort_value_popularity => sort_title_popularity,
+ sort_value_priority => sort_title_priority,
+ sort_value_upvotes => sort_title_upvotes,
+ sort_value_contacted_date => sort_title_contacted_date,
+ sort_value_relative_position => sort_title_relative_position
}
end
def projects_sort_options_hash
+ Feature.enabled?(:project_list_filter_bar) && !current_controller?('admin/projects') ? projects_sort_common_options_hash : old_projects_sort_options_hash
+ end
+
+ # TODO: Simplify these sorting options
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/60798
+ # https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/11209#note_162234858
+ def old_projects_sort_options_hash
options = {
sort_value_latest_activity => sort_title_latest_activity,
sort_value_name => sort_title_name,
sort_value_oldest_activity => sort_title_oldest_activity,
sort_value_oldest_created => sort_title_oldest_created,
sort_value_recently_created => sort_title_recently_created,
- sort_value_most_stars => sort_title_most_stars
+ sort_value_stars_desc => sort_title_most_stars
}
if current_controller?('admin/projects')
@@ -46,6 +54,41 @@ module SortingHelper
options
end
+ def projects_sort_common_options_hash
+ {
+ sort_value_latest_activity => sort_title_latest_activity,
+ sort_value_recently_created => sort_title_created_date,
+ sort_value_name => sort_title_name,
+ sort_value_stars_desc => sort_title_stars
+ }
+ end
+
+ def projects_sort_option_titles
+ {
+ sort_value_latest_activity => sort_title_latest_activity,
+ sort_value_recently_created => sort_title_created_date,
+ sort_value_name => sort_title_name,
+ sort_value_stars_desc => sort_title_stars,
+ sort_value_oldest_activity => sort_title_latest_activity,
+ sort_value_oldest_created => sort_title_created_date,
+ sort_value_name_desc => sort_title_name,
+ sort_value_stars_asc => sort_title_stars
+ }
+ end
+
+ def projects_reverse_sort_options_hash
+ {
+ sort_value_latest_activity => sort_value_oldest_activity,
+ sort_value_recently_created => sort_value_oldest_created,
+ sort_value_name => sort_value_name_desc,
+ sort_value_stars_desc => sort_value_stars_asc,
+ sort_value_oldest_activity => sort_value_latest_activity,
+ sort_value_oldest_created => sort_value_recently_created,
+ sort_value_name_desc => sort_value_name,
+ sort_value_stars_asc => sort_value_stars_desc
+ }
+ end
+
def groups_sort_options_hash
{
sort_value_name => sort_title_name,
@@ -59,7 +102,7 @@ module SortingHelper
def subgroups_sort_options_hash
groups_sort_options_hash.merge(
- sort_value_most_stars => sort_title_most_stars
+ sort_value_stars_desc => sort_title_most_stars
)
end
@@ -142,7 +185,9 @@ module SortingHelper
{
sort_value_oldest_created => sort_value_created_date,
sort_value_oldest_updated => sort_value_recently_updated,
- sort_value_milestone_later => sort_value_milestone
+ sort_value_milestone_later => sort_value_milestone,
+ sort_value_due_date_later => sort_value_due_date,
+ sort_value_least_popular => sort_value_popularity
}
end
@@ -151,7 +196,11 @@ module SortingHelper
sort_value_created_date => sort_value_oldest_created,
sort_value_recently_created => sort_value_oldest_created,
sort_value_recently_updated => sort_value_oldest_updated,
- sort_value_milestone => sort_value_milestone_later
+ sort_value_milestone => sort_value_milestone_later,
+ sort_value_due_date => sort_value_due_date_later,
+ sort_value_due_date_soon => sort_value_due_date_later,
+ sort_value_popularity => sort_value_least_popular,
+ sort_value_most_popular => sort_value_least_popular
}.merge(issuable_sort_option_overrides)
end
@@ -170,6 +219,8 @@ module SortingHelper
end
end
+ # TODO: dedupicate issuable and project sort direction
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/60798
def issuable_sort_direction_button(sort_value)
link_class = 'btn btn-default has-tooltip reverse-sort-btn qa-reverse-sort'
reverse_sort = issuable_reverse_sort_order_hash[sort_value]
@@ -181,7 +232,23 @@ module SortingHelper
link_class += ' disabled'
end
- link_to(reverse_url, type: 'button', class: link_class, title: 'Sort direction') do
+ link_to(reverse_url, type: 'button', class: link_class, title: s_('SortOptions|Sort direction')) do
+ sprite_icon("sort-#{issuable_sort_icon_suffix(sort_value)}", size: 16)
+ end
+ end
+
+ def project_sort_direction_button(sort_value)
+ link_class = 'btn btn-default has-tooltip reverse-sort-btn qa-reverse-sort'
+ reverse_sort = projects_reverse_sort_options_hash[sort_value]
+
+ if reverse_sort
+ reverse_url = filter_projects_path(sort: reverse_sort)
+ else
+ reverse_url = '#'
+ link_class += ' disabled'
+ end
+
+ link_to(reverse_url, type: 'button', class: link_class, title: s_('SortOptions|Sort direction')) do
sprite_icon("sort-#{issuable_sort_icon_suffix(sort_value)}", size: 16)
end
end
@@ -319,6 +386,10 @@ module SortingHelper
s_('SortOptions|Most stars')
end
+ def sort_title_stars
+ s_('SortOptions|Stars')
+ end
+
def sort_title_oldest_last_activity
s_('SortOptions|Oldest last activity')
end
@@ -327,6 +398,10 @@ module SortingHelper
s_('SortOptions|Recent last activity')
end
+ def sort_title_relative_position
+ s_('SortOptions|Manual')
+ end
+
# Values.
def sort_value_access_level_asc
'access_level_asc'
@@ -420,6 +495,14 @@ module SortingHelper
'popularity'
end
+ def sort_value_most_popular
+ 'popularity_desc'
+ end
+
+ def sort_value_least_popular
+ 'popularity_asc'
+ end
+
def sort_value_priority
'priority'
end
@@ -452,10 +535,14 @@ module SortingHelper
'contacted_asc'
end
- def sort_value_most_stars
+ def sort_value_stars_desc
'stars_desc'
end
+ def sort_value_stars_asc
+ 'stars_asc'
+ end
+
def sort_value_oldest_last_activity
'last_activity_on_asc'
end
@@ -463,4 +550,8 @@ module SortingHelper
def sort_value_recently_last_activity
'last_activity_on_desc'
end
+
+ def sort_value_relative_position
+ 'relative_position'
+ end
end
diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb
index be8761db562..ecf37bae6b3 100644
--- a/app/helpers/storage_helper.rb
+++ b/app/helpers/storage_helper.rb
@@ -2,8 +2,21 @@
module StorageHelper
def storage_counter(size_in_bytes)
+ return s_('StorageSize|Unknown') unless size_in_bytes
+
precision = size_in_bytes < 1.megabyte ? 0 : 1
number_to_human_size(size_in_bytes, delimiter: ',', precision: precision, significant: false)
end
+
+ def storage_counters_details(statistics)
+ counters = {
+ counter_repositories: storage_counter(statistics.repository_size),
+ counter_wikis: storage_counter(statistics.wiki_size),
+ counter_build_artifacts: storage_counter(statistics.build_artifacts_size),
+ counter_lfs_objects: storage_counter(statistics.lfs_objects_size)
+ }
+
+ _("%{counter_repositories} repositories, %{counter_wikis} wikis, %{counter_build_artifacts} build artifacts, %{counter_lfs_objects} LFS") % counters
+ end
end
diff --git a/app/helpers/tracking_helper.rb b/app/helpers/tracking_helper.rb
new file mode 100644
index 00000000000..51ea79d1ddd
--- /dev/null
+++ b/app/helpers/tracking_helper.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module TrackingHelper
+ def tracking_attrs(label, event, property)
+ {} # CE has no tracking features
+ end
+end
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index e2879bfdcf1..4690b6ffbe1 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -86,17 +86,17 @@ module TreeHelper
end
def edit_in_new_fork_notice_now
- "You're not allowed to make changes to this project directly." +
- " A fork of this project is being created that you can make changes in, so you can submit a merge request."
+ _("You're not allowed to make changes to this project directly. "\
+ "A fork of this project is being created that you can make changes in, so you can submit a merge request.")
end
def edit_in_new_fork_notice
- "You're not allowed to make changes to this project directly." +
- " A fork of this project has been created that you can make changes in, so you can submit a merge request."
+ _("You're not allowed to make changes to this project directly. "\
+ "A fork of this project has been created that you can make changes in, so you can submit a merge request.")
end
def edit_in_new_fork_notice_action(action)
- edit_in_new_fork_notice + " Try to #{action} this file again."
+ edit_in_new_fork_notice + _(" Try to %{action} this file again.") % { action: action }
end
def commit_in_fork_help
@@ -136,18 +136,9 @@ module TreeHelper
end
# returns the relative path of the first subdir that doesn't have only one directory descendant
- # rubocop: disable CodeReuse/ActiveRecord
def flatten_tree(root_path, tree)
- return tree.flat_path.sub(%r{\A#{Regexp.escape(root_path)}/}, '') if tree.flat_path.present?
-
- subtree = Gitlab::Git::Tree.where(@repository, @commit.id, tree.path)
- if subtree.count == 1 && subtree.first.dir?
- return tree_join(tree.name, flatten_tree(root_path, subtree.first))
- else
- return tree.name
- end
+ tree.flat_path.sub(%r{\A#{Regexp.escape(root_path)}/}, '')
end
- # rubocop: enable CodeReuse/ActiveRecord
def selected_branch
@branch_name || tree_edit_branch
diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb
index 1ad7bb81784..5d658d35107 100644
--- a/app/helpers/user_callouts_helper.rb
+++ b/app/helpers/user_callouts_helper.rb
@@ -17,6 +17,9 @@ module UserCalloutsHelper
render 'shared/flash_user_callout', flash_type: flash_type, message: message, feature_name: feature_name
end
+ def render_dashboard_gold_trial(user)
+ end
+
private
def user_dismissed?(feature_name)
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index 712f0f808dd..b318b27992a 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -42,11 +42,11 @@ module VisibilityLevelHelper
def group_visibility_level_description(level)
case level
when Gitlab::VisibilityLevel::PRIVATE
- "The group and its projects can only be viewed by members."
+ _("The group and its projects can only be viewed by members.")
when Gitlab::VisibilityLevel::INTERNAL
- "The group and any internal projects can be viewed by any logged in user."
+ _("The group and any internal projects can be viewed by any logged in user.")
when Gitlab::VisibilityLevel::PUBLIC
- "The group and any public projects can be viewed without any authentication."
+ _("The group and any public projects can be viewed without any authentication.")
end
end
@@ -54,20 +54,20 @@ module VisibilityLevelHelper
case level
when Gitlab::VisibilityLevel::PRIVATE
if snippet.is_a? ProjectSnippet
- "The snippet is visible only to project members."
+ _("The snippet is visible only to project members.")
else
- "The snippet is visible only to me."
+ _("The snippet is visible only to me.")
end
when Gitlab::VisibilityLevel::INTERNAL
- "The snippet is visible to any logged in user."
+ _("The snippet is visible to any logged in user.")
when Gitlab::VisibilityLevel::PUBLIC
- "The snippet can be accessed without any authentication."
+ _("The snippet can be accessed without any authentication.")
end
end
def restricted_visibility_level_description(level)
level_name = Gitlab::VisibilityLevel.level_name(level)
- "#{level_name.capitalize} visibility has been restricted by the administrator."
+ _("%{level_name} visibility has been restricted by the administrator.") % { level_name: level_name.capitalize }
end
def disallowed_visibility_level_description(level, form_model)
@@ -165,8 +165,46 @@ module VisibilityLevelHelper
!form_model.visibility_level_allowed?(level)
end
+ # Visibility level can be restricted in two ways:
+ #
+ # 1. The group permissions (e.g. a subgroup is private, which requires
+ # all projects to be private)
+ # 2. The global allowed visibility settings, set by the admin
+ def selected_visibility_level(form_model, requested_level)
+ requested_level =
+ if requested_level.present?
+ requested_level.to_i
+ else
+ default_project_visibility
+ end
+
+ [requested_level, max_allowed_visibility_level(form_model)].min
+ end
+
private
+ def max_allowed_visibility_level(form_model)
+ # First obtain the maximum visibility for the project or group
+ current_level = max_allowed_visibility_level_by_model(form_model)
+
+ # Now limit this by the global setting
+ Gitlab::VisibilityLevel.closest_allowed_level(current_level)
+ end
+
+ def max_allowed_visibility_level_by_model(form_model)
+ current_level = Gitlab::VisibilityLevel::PRIVATE
+
+ Gitlab::VisibilityLevel.values.sort.each do |value|
+ if disallowed_visibility_level?(form_model, value)
+ break
+ else
+ current_level = value
+ end
+ end
+
+ current_level
+ end
+
def visibility_level_errors_for_group(group, level_name)
group_name = link_to group.name, group_path(group)
change_visiblity = link_to 'change the visibility', edit_group_path(group)
diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb
index 647f34e57ed..edd48f82729 100644
--- a/app/helpers/wiki_helper.rb
+++ b/app/helpers/wiki_helper.rb
@@ -47,4 +47,24 @@ module WikiHelper
def wiki_attachment_upload_url
expose_url(api_v4_projects_wikis_attachments_path(id: @project.id))
end
+
+ def wiki_sort_controls(project, sort, direction)
+ sort ||= ProjectWiki::TITLE_ORDER
+ link_class = 'btn btn-default has-tooltip reverse-sort-btn qa-reverse-sort'
+ reversed_direction = direction == 'desc' ? 'asc' : 'desc'
+ icon_class = direction == 'desc' ? 'highest' : 'lowest'
+
+ link_to(project_wikis_pages_path(project, sort: sort, direction: reversed_direction),
+ type: 'button', class: link_class, title: _('Sort direction')) do
+ sprite_icon("sort-#{icon_class}", size: 16)
+ end
+ end
+
+ def wiki_sort_title(key)
+ if key == ProjectWiki::CREATED_AT_ORDER
+ s_("Wiki|Created date")
+ else
+ s_("Wiki|Title")
+ end
+ end
end
diff --git a/app/mailers/abuse_report_mailer.rb b/app/mailers/abuse_report_mailer.rb
index e032f568913..e0aa66e6de3 100644
--- a/app/mailers/abuse_report_mailer.rb
+++ b/app/mailers/abuse_report_mailer.rb
@@ -1,6 +1,10 @@
# frozen_string_literal: true
class AbuseReportMailer < BaseMailer
+ layout 'empty_mailer'
+
+ helper EmailsHelper
+
def notify(abuse_report_id)
return unless deliverable?
diff --git a/app/mailers/devise_mailer.rb b/app/mailers/devise_mailer.rb
index 7aa75ee30e6..cbaf53fced1 100644
--- a/app/mailers/devise_mailer.rb
+++ b/app/mailers/devise_mailer.rb
@@ -7,6 +7,7 @@ class DeviseMailer < Devise::Mailer
layout 'mailer/devise'
helper EmailsHelper
+ helper ApplicationHelper
protected
diff --git a/app/mailers/email_rejection_mailer.rb b/app/mailers/email_rejection_mailer.rb
index 45fc5a6c383..d743533b1bc 100644
--- a/app/mailers/email_rejection_mailer.rb
+++ b/app/mailers/email_rejection_mailer.rb
@@ -1,6 +1,10 @@
# frozen_string_literal: true
class EmailRejectionMailer < BaseMailer
+ layout 'empty_mailer'
+
+ helper EmailsHelper
+
def rejection(reason, original_raw, can_retry = false)
@reason = reason
@original_message = Mail::Message.new(original_raw)
diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb
index 654ae211310..f3a3203f7ad 100644
--- a/app/mailers/emails/issues.rb
+++ b/app/mailers/emails/issues.rb
@@ -30,8 +30,8 @@ module Emails
end
# rubocop: enable CodeReuse/ActiveRecord
- def closed_issue_email(recipient_id, issue_id, updated_by_user_id, reason = nil)
- setup_issue_mail(issue_id, recipient_id)
+ def closed_issue_email(recipient_id, issue_id, updated_by_user_id, reason: nil, closed_via: nil)
+ setup_issue_mail(issue_id, recipient_id, closed_via: closed_via)
@updated_by = User.find(updated_by_user_id)
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
@@ -74,6 +74,7 @@ module Emails
@new_issue = new_issue
@new_project = new_issue.project
+ @can_access_project = recipient.can?(:read_project, @new_project)
mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id, reason))
end
@@ -82,7 +83,7 @@ module Emails
@project = Project.find(project_id)
@results = results
- mail(to: @user.notification_email, subject: subject('Imported issues')) do |format|
+ mail(to: recipient(@user.id, @project.group), subject: subject('Imported issues')) do |format|
format.html { render layout: 'mailer' }
format.text { render layout: 'mailer' }
end
@@ -90,10 +91,11 @@ module Emails
private
- def setup_issue_mail(issue_id, recipient_id)
+ def setup_issue_mail(issue_id, recipient_id, closed_via: nil)
@issue = Issue.find(issue_id)
@project = @issue.project
@target_url = project_issue_url(@project, @issue)
+ @closed_via = closed_via
@sent_notification = SentNotification.record(@issue, recipient_id, reply_key)
end
@@ -101,7 +103,7 @@ module Emails
def issue_thread_options(sender_id, recipient_id, reason)
{
from: sender(sender_id),
- to: recipient(recipient_id),
+ to: recipient(recipient_id, @project.group),
subject: subject("#{@issue.title} (##{@issue.iid})"),
'X-GitLab-NotificationReason' => reason
}
diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb
index 91dfdf58982..2bfa59774d7 100644
--- a/app/mailers/emails/members.rb
+++ b/app/mailers/emails/members.rb
@@ -58,9 +58,8 @@ module Emails
@member_source_type = member_source_type
@member_source = member_source_class.find(source_id)
@invite_email = invite_email
- inviter = User.find(created_by_id)
- mail(to: inviter.notification_email,
+ mail(to: recipient(created_by_id, member_source_type == 'Project' ? @member_source.group : @member_source),
subject: subject('Invitation declined'))
end
diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb
index 9ba8f92fcbf..864f9e2975a 100644
--- a/app/mailers/emails/merge_requests.rb
+++ b/app/mailers/emails/merge_requests.rb
@@ -24,10 +24,12 @@ module Emails
end
# rubocop: disable CodeReuse/ActiveRecord
- def reassigned_merge_request_email(recipient_id, merge_request_id, previous_assignee_id, updated_by_user_id, reason = nil)
+ def reassigned_merge_request_email(recipient_id, merge_request_id, previous_assignee_ids, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
- @previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id
+ @previous_assignees = []
+ @previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any?
+
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -56,14 +58,14 @@ module Emails
}))
end
- def closed_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil)
+ def closed_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason: nil, closed_via: nil)
setup_merge_request_mail(merge_request_id, recipient_id)
@updated_by = User.find(updated_by_user_id)
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end
- def merged_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil)
+ def merged_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason: nil, closed_via: nil)
setup_merge_request_mail(merge_request_id, recipient_id)
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
@@ -108,7 +110,7 @@ module Emails
def merge_request_thread_options(sender_id, recipient_id, reason = nil)
{
from: sender(sender_id),
- to: recipient(recipient_id),
+ to: recipient(recipient_id, @project.group),
subject: subject("#{@merge_request.title} (#{@merge_request.to_reference})"),
'X-GitLab-NotificationReason' => reason
}
diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb
index 1b3c1f9a8a9..70d296fe3b8 100644
--- a/app/mailers/emails/notes.rb
+++ b/app/mailers/emails/notes.rb
@@ -51,7 +51,7 @@ module Emails
def note_thread_options(recipient_id)
{
from: sender(@note.author_id),
- to: recipient(recipient_id),
+ to: recipient(recipient_id, @group),
subject: subject("#{@note.noteable.title} (#{@note.noteable.reference_link_text})")
}
end
diff --git a/app/mailers/emails/pages_domains.rb b/app/mailers/emails/pages_domains.rb
index ce449237ef6..2d390666f65 100644
--- a/app/mailers/emails/pages_domains.rb
+++ b/app/mailers/emails/pages_domains.rb
@@ -7,7 +7,7 @@ module Emails
@project = domain.project
mail(
- to: recipient.notification_email,
+ to: recipient(recipient.id, @project.group),
subject: subject("GitLab Pages domain '#{domain.domain}' has been enabled")
)
end
@@ -17,7 +17,7 @@ module Emails
@project = domain.project
mail(
- to: recipient.notification_email,
+ to: recipient(recipient.id, @project.group),
subject: subject("GitLab Pages domain '#{domain.domain}' has been disabled")
)
end
@@ -27,7 +27,7 @@ module Emails
@project = domain.project
mail(
- to: recipient.notification_email,
+ to: recipient(recipient.id, @project.group),
subject: subject("Verification succeeded for GitLab Pages domain '#{domain.domain}'")
)
end
@@ -37,7 +37,7 @@ module Emails
@project = domain.project
mail(
- to: recipient.notification_email,
+ to: recipient(recipient.id, @project.group),
subject: subject("ACTION REQUIRED: Verification failed for GitLab Pages domain '#{domain.domain}'")
)
end
diff --git a/app/mailers/emails/pipelines.rb b/app/mailers/emails/pipelines.rb
index 31e183640ad..fb57c0da34d 100644
--- a/app/mailers/emails/pipelines.rb
+++ b/app/mailers/emails/pipelines.rb
@@ -15,7 +15,7 @@ module Emails
def pipeline_mail(pipeline, recipients, status)
@project = pipeline.project
@pipeline = pipeline
- @merge_request = pipeline.merge_requests.first
+ @merge_request = pipeline.merge_requests_as_head_pipeline.first
add_headers
# We use bcc here because we don't want to generate this emails for a
diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb
index 2500622caa7..f81f76f67f7 100644
--- a/app/mailers/emails/projects.rb
+++ b/app/mailers/emails/projects.rb
@@ -7,20 +7,20 @@ module Emails
@project = Project.find project_id
@target_url = project_url(@project)
@old_path_with_namespace = old_path_with_namespace
- mail(to: @user.notification_email,
+ mail(to: recipient(user_id, @project.group),
subject: subject("Project was moved"))
end
def project_was_exported_email(current_user, project)
@project = project
- mail(to: current_user.notification_email,
+ mail(to: recipient(current_user.id, project.group),
subject: subject("Project was exported"))
end
def project_was_not_exported_email(current_user, project, errors)
@project = project
@errors = errors
- mail(to: current_user.notification_email,
+ mail(to: recipient(current_user.id, @project.group),
subject: subject("Project export error"))
end
@@ -28,7 +28,7 @@ module Emails
@project = project
@user = user
- mail(to: user.notification_email, subject: subject("Project cleanup has completed"))
+ mail(to: recipient(user.id, project.group), subject: subject("Project cleanup has completed"))
end
def repository_cleanup_failure_email(project, user, error)
@@ -36,7 +36,7 @@ module Emails
@user = user
@error = error
- mail(to: user.notification_email, subject: subject("Project cleanup failure"))
+ mail(to: recipient(user.id, project.group), subject: subject("Project cleanup failure"))
end
def repository_push_email(project_id, opts = {})
diff --git a/app/mailers/emails/remote_mirrors.rb b/app/mailers/emails/remote_mirrors.rb
index 2018eb7260b..2d8137843ec 100644
--- a/app/mailers/emails/remote_mirrors.rb
+++ b/app/mailers/emails/remote_mirrors.rb
@@ -6,7 +6,7 @@ module Emails
@remote_mirror = RemoteMirrorFinder.new(id: remote_mirror_id).execute
@project = @remote_mirror.project
- mail(to: recipient(recipient_id), subject: subject('Remote mirror update failed'))
+ mail(to: recipient(recipient_id, @project.group), subject: subject('Remote mirror update failed'))
end
end
end
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index efa1233b434..576caea4c10 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -4,6 +4,7 @@ class Notify < BaseMailer
include ActionDispatch::Routing::PolymorphicRoutes
include GitlabRoutingHelper
include EmailsHelper
+ include IssuablesHelper
include Emails::Issues
include Emails::MergeRequests
@@ -24,6 +25,7 @@ class Notify < BaseMailer
helper MembersHelper
helper AvatarsHelper
helper GitlabRoutingHelper
+ helper IssuablesHelper
def test_email(recipient_email, subject, body)
mail(to: recipient_email,
@@ -71,12 +73,22 @@ class Notify < BaseMailer
# Look up a User by their ID and return their email address
#
- # recipient_id - User ID
+ # recipient_id - User ID
+ # notification_group - The parent group of the notification
#
# Returns a String containing the User's email address.
- def recipient(recipient_id)
+ def recipient(recipient_id, notification_group = nil)
@current_user = User.find(recipient_id)
- @current_user.notification_email
+ group_notification_email = nil
+
+ if notification_group
+ notification_settings = notification_group.notification_settings_for(@current_user, hierarchy_order: :asc)
+ group_notification_email = notification_settings.find { |n| n.notification_email.present? }&.notification_email
+ end
+
+ # Return group-specific email address if present, otherwise return global
+ # email address
+ group_notification_email || @current_user.notification_email
end
# Formats arguments into a String suitable for use as an email subject
diff --git a/app/mailers/repository_check_mailer.rb b/app/mailers/repository_check_mailer.rb
index 145169be8a6..a24d3476d0e 100644
--- a/app/mailers/repository_check_mailer.rb
+++ b/app/mailers/repository_check_mailer.rb
@@ -2,6 +2,10 @@
class RepositoryCheckMailer < BaseMailer
# rubocop: disable CodeReuse/ActiveRecord
+ layout 'empty_mailer'
+
+ helper EmailsHelper
+
def notify(failed_count)
@message =
if failed_count == 1
diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb
index 1b78fd04ebb..a3a1748142f 100644
--- a/app/models/abuse_report.rb
+++ b/app/models/abuse_report.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class AbuseReport < ActiveRecord::Base
+class AbuseReport < ApplicationRecord
include CacheMarkdownField
cache_markdown_field :message, pipeline: :single_line
diff --git a/app/models/active_session.rb b/app/models/active_session.rb
index 0d9c6a4a1f0..f355b02c428 100644
--- a/app/models/active_session.rb
+++ b/app/models/active_session.rb
@@ -5,7 +5,8 @@ class ActiveSession
attr_accessor :created_at, :updated_at,
:session_id, :ip_address,
- :browser, :os, :device_name, :device_type
+ :browser, :os, :device_name, :device_type,
+ :is_impersonated
def current?(session)
return false if session_id.nil? || session.id.nil?
@@ -31,7 +32,8 @@ class ActiveSession
device_type: client.device_type,
created_at: user.current_sign_in_at || timestamp,
updated_at: timestamp,
- session_id: session_id
+ session_id: session_id,
+ is_impersonated: request.session[:impersonator_id].present?
)
redis.pipelined do
@@ -51,7 +53,7 @@ class ActiveSession
def self.list(user)
Gitlab::Redis::SharedState.with do |redis|
- cleaned_up_lookup_entries(redis, user.id).map do |entry|
+ cleaned_up_lookup_entries(redis, user).map do |entry|
# rubocop:disable Security/MarshalLoad
Marshal.load(entry)
# rubocop:enable Security/MarshalLoad
@@ -76,7 +78,7 @@ class ActiveSession
def self.cleanup(user)
Gitlab::Redis::SharedState.with do |redis|
- cleaned_up_lookup_entries(redis, user.id)
+ cleaned_up_lookup_entries(redis, user)
end
end
@@ -88,25 +90,52 @@ class ActiveSession
"#{Gitlab::Redis::SharedState::USER_SESSIONS_LOOKUP_NAMESPACE}:#{user_id}"
end
- def self.cleaned_up_lookup_entries(redis, user_id)
- lookup_key = lookup_key_name(user_id)
+ def self.list_sessions(user)
+ sessions_from_ids(session_ids_for_user(user))
+ end
- session_ids = redis.smembers(lookup_key)
+ def self.session_ids_for_user(user)
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.smembers(lookup_key_name(user.id))
+ end
+ end
- entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) }
- return [] if entry_keys.empty?
+ def self.sessions_from_ids(session_ids)
+ return [] if session_ids.empty?
- entries = redis.mget(entry_keys)
+ Gitlab::Redis::SharedState.with do |redis|
+ session_keys = session_ids.map { |session_id| "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}" }
- session_ids_and_entries = session_ids.zip(entries)
+ redis.mget(session_keys).compact.map do |raw_session|
+ # rubocop:disable Security/MarshalLoad
+ Marshal.load(raw_session)
+ # rubocop:enable Security/MarshalLoad
+ end
+ end
+ end
+
+ def self.raw_active_session_entries(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) }
+
+ redis.mget(entry_keys)
+ end
+ end
+
+ def self.cleaned_up_lookup_entries(redis, user)
+ session_ids = session_ids_for_user(user)
+ entries = raw_active_session_entries(session_ids, user.id)
# remove expired keys.
# only the single key entries are automatically expired by redis, the
# lookup entries in the set need to be removed manually.
+ session_ids_and_entries = session_ids.zip(entries)
session_ids_and_entries.reject { |_session_id, entry| entry }.each do |session_id, _entry|
- redis.srem(lookup_key, session_id)
+ redis.srem(lookup_key_name(user.id), session_id)
end
- session_ids_and_entries.select { |_session_id, entry| entry }.map { |_session_id, entry| entry }
+ entries.compact
end
end
diff --git a/app/models/appearance.rb b/app/models/appearance.rb
index b9ad676ca47..2815a117f7f 100644
--- a/app/models/appearance.rb
+++ b/app/models/appearance.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Appearance < ActiveRecord::Base
+class Appearance < ApplicationRecord
include CacheableAttributes
include CacheMarkdownField
include ObjectStorage::BackgroundMove
@@ -20,6 +20,7 @@ class Appearance < ActiveRecord::Base
default_value_for :message_background_color, '#E75E40'
default_value_for :message_font_color, '#FFFFFF'
+ default_value_for :email_header_and_footer_enabled, false
mount_uploader :logo, AttachmentUploader
mount_uploader :header_logo, AttachmentUploader
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index a3d662d8250..0979d03f6e6 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -3,10 +3,33 @@
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
+ alias_method :reset, :reload
+
def self.id_in(ids)
where(id: ids)
end
+ def self.id_not_in(ids)
+ where.not(id: ids)
+ end
+
+ def self.pluck_primary_key
+ where(nil).pluck(self.primary_key)
+ end
+
+ def self.safe_ensure_unique(retries: 0)
+ transaction(requires_new: true) do
+ yield
+ end
+ rescue ActiveRecord::RecordNotUnique
+ if retries > 0
+ retries -= 1
+ retry
+ end
+
+ false
+ end
+
def self.safe_find_or_create_by!(*args)
safe_find_or_create_by(*args).tap do |record|
record.validate! unless record.persisted?
@@ -14,10 +37,12 @@ class ApplicationRecord < ActiveRecord::Base
end
def self.safe_find_or_create_by(*args)
- transaction(requires_new: true) do
+ safe_ensure_unique(retries: 1) do
find_or_create_by(*args)
end
- rescue ActiveRecord::RecordNotUnique
- retry
+ end
+
+ def self.underscore
+ Gitlab::SafeRequestStore.fetch("model:#{self}:underscore") { self.to_s.underscore }
end
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index daadf9427ba..bbe2d2e8fd4 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -1,26 +1,20 @@
# frozen_string_literal: true
-class ApplicationSetting < ActiveRecord::Base
+class ApplicationSetting < ApplicationRecord
include CacheableAttributes
include CacheMarkdownField
include TokenAuthenticatable
include IgnorableColumn
include ChronicDurationAttribute
- add_authentication_token_field :runners_registration_token, encrypted: true, fallback: true
+ 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
- DOMAIN_LIST_SEPARATOR = %r{\s*[,;]\s* # comma or semicolon, optionally surrounded by whitespace
- | # or
- \s # any whitespace character
- | # or
- [\r\n] # any number of newline characters
- }x
-
- # Setting a key restriction to `-1` means that all keys of this type are
- # forbidden.
- FORBIDDEN_KEY_VALUE = KeyRestrictionValidator::FORBIDDEN
- SUPPORTED_KEY_TYPES = %i[rsa dsa ecdsa ed25519].freeze
+ # Include here so it can override methods from
+ # `add_authentication_token_field`
+ # We don't prepend for now because otherwise we'll need to
+ # fix a lot of tests using allow_any_instance_of
+ include ApplicationSettingImplementation
serialize :restricted_visibility_levels # rubocop:disable Cop/ActiveRecordSerialize
serialize :import_sources # rubocop:disable Cop/ActiveRecordSerialize
@@ -42,8 +36,6 @@ class ApplicationSetting < ActiveRecord::Base
cache_markdown_field :shared_runners_text, pipeline: :plain_markdown
cache_markdown_field :after_sign_up_text
- attr_accessor :domain_whitelist_raw, :domain_blacklist_raw
-
default_value_for :id, 1
chronic_duration_attr_writer :archive_builds_in_human_readable, :archive_builds_in_seconds
@@ -56,20 +48,20 @@ class ApplicationSetting < ActiveRecord::Base
validates :home_page_url,
allow_blank: true,
- url: true,
+ addressable_url: true,
if: :home_page_url_column_exists?
validates :help_page_support_url,
allow_blank: true,
- url: true,
+ addressable_url: true,
if: :help_page_support_url_column_exists?
validates :after_sign_out_path,
allow_blank: true,
- url: true
+ addressable_url: true
validates :admin_notification_email,
- email: true,
+ devise_email: true,
allow_blank: true
validates :two_factor_grace_period,
@@ -206,7 +198,7 @@ class ApplicationSetting < ActiveRecord::Base
validates_each :restricted_visibility_levels do |record, attr, value|
value&.each do |level|
unless Gitlab::VisibilityLevel.options.value?(level)
- record.errors.add(attr, "'#{level}' is not a valid visibility level")
+ record.errors.add(attr, _("'%{level}' is not a valid visibility level") % { level: level })
end
end
end
@@ -214,13 +206,63 @@ class ApplicationSetting < ActiveRecord::Base
validates_each :import_sources do |record, attr, value|
value&.each do |source|
unless Gitlab::ImportSources.options.value?(source)
- record.errors.add(attr, "'#{source}' is not a import source")
+ record.errors.add(attr, _("'%{source}' is not a import source") % { source: source })
end
end
end
validate :terms_exist, if: :enforce_terms?
+ validates :external_authorization_service_default_label,
+ presence: true,
+ if: :external_authorization_service_enabled
+
+ validates :external_authorization_service_url,
+ addressable_url: true, allow_blank: true,
+ if: :external_authorization_service_enabled
+
+ validates :external_authorization_service_timeout,
+ numericality: { greater_than: 0, less_than_or_equal_to: 10 },
+ if: :external_authorization_service_enabled
+
+ validates :external_auth_client_key,
+ presence: true,
+ if: -> (setting) { setting.external_auth_client_cert.present? }
+
+ validates :lets_encrypt_notification_email,
+ devise_email: true,
+ format: { without: /@example\.(com|org|net)\z/,
+ message: N_("Let's Encrypt does not accept emails on example.com") },
+ allow_blank: true
+
+ validates :lets_encrypt_notification_email,
+ presence: true,
+ if: :lets_encrypt_terms_of_service_accepted?
+
+ validates_with X509CertificateCredentialsValidator,
+ certificate: :external_auth_client_cert,
+ pkey: :external_auth_client_key,
+ pass: :external_auth_client_key_pass,
+ if: -> (setting) { setting.external_auth_client_cert.present? }
+
+ attr_encrypted :external_auth_client_key,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ algorithm: 'aes-256-gcm',
+ encode: true
+
+ attr_encrypted :external_auth_client_key_pass,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ algorithm: 'aes-256-gcm',
+ encode: true
+
+ attr_encrypted :lets_encrypt_private_key,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ algorithm: 'aes-256-gcm',
+ encode: true
+
before_validation :ensure_uuid!
before_validation :strip_sentry_values
@@ -232,265 +274,12 @@ class ApplicationSetting < ActiveRecord::Base
end
after_commit :expire_performance_bar_allowed_user_ids_cache, if: -> { previous_changes.key?('performance_bar_allowed_group_id') }
- def self.defaults
- {
- after_sign_up_text: nil,
- akismet_enabled: false,
- allow_local_requests_from_hooks_and_services: false,
- authorized_keys_enabled: true, # TODO default to false if the instance is configured to use AuthorizedKeysCommand
- container_registry_token_expire_delay: 5,
- default_artifacts_expire_in: '30 days',
- default_branch_protection: Settings.gitlab['default_branch_protection'],
- default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'],
- default_project_visibility: Settings.gitlab.default_projects_features['visibility_level'],
- default_projects_limit: Settings.gitlab['default_projects_limit'],
- default_snippet_visibility: Settings.gitlab.default_projects_features['visibility_level'],
- disabled_oauth_sign_in_sources: [],
- domain_whitelist: Settings.gitlab['domain_whitelist'],
- dsa_key_restriction: 0,
- ecdsa_key_restriction: 0,
- ed25519_key_restriction: 0,
- first_day_of_week: 0,
- gitaly_timeout_default: 55,
- gitaly_timeout_fast: 10,
- gitaly_timeout_medium: 30,
- gravatar_enabled: Settings.gravatar['enabled'],
- help_page_hide_commercial_content: false,
- help_page_text: nil,
- hide_third_party_offers: false,
- housekeeping_bitmaps_enabled: true,
- housekeeping_enabled: true,
- housekeeping_full_repack_period: 50,
- housekeeping_gc_period: 200,
- housekeeping_incremental_repack_period: 10,
- import_sources: Settings.gitlab['import_sources'],
- max_artifacts_size: Settings.artifacts['max_size'],
- max_attachment_size: Settings.gitlab['max_attachment_size'],
- mirror_available: true,
- password_authentication_enabled_for_git: true,
- password_authentication_enabled_for_web: Settings.gitlab['signin_enabled'],
- performance_bar_allowed_group_id: nil,
- rsa_key_restriction: 0,
- plantuml_enabled: false,
- plantuml_url: nil,
- polling_interval_multiplier: 1,
- project_export_enabled: true,
- recaptcha_enabled: false,
- repository_checks_enabled: true,
- repository_storages: ['default'],
- require_two_factor_authentication: false,
- restricted_visibility_levels: Settings.gitlab['restricted_visibility_levels'],
- session_expire_delay: Settings.gitlab['session_expire_delay'],
- send_user_confirmation_email: false,
- shared_runners_enabled: Settings.gitlab_ci['shared_runners_enabled'],
- shared_runners_text: nil,
- sign_in_text: nil,
- signup_enabled: Settings.gitlab['signup_enabled'],
- terminal_max_session_time: 0,
- throttle_authenticated_api_enabled: false,
- throttle_authenticated_api_period_in_seconds: 3600,
- throttle_authenticated_api_requests_per_period: 7200,
- throttle_authenticated_web_enabled: false,
- throttle_authenticated_web_period_in_seconds: 3600,
- throttle_authenticated_web_requests_per_period: 7200,
- throttle_unauthenticated_enabled: false,
- throttle_unauthenticated_period_in_seconds: 3600,
- throttle_unauthenticated_requests_per_period: 3600,
- two_factor_grace_period: 48,
- unique_ips_limit_enabled: false,
- unique_ips_limit_per_user: 10,
- unique_ips_limit_time_window: 3600,
- usage_ping_enabled: Settings.gitlab['usage_ping_enabled'],
- instance_statistics_visibility_private: false,
- user_default_external: false,
- user_default_internal_regex: nil,
- user_show_add_ssh_key_message: true,
- usage_stats_set_by_user_id: nil,
- diff_max_patch_bytes: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES,
- commit_email_hostname: default_commit_email_hostname,
- protected_ci_variables: false,
- local_markdown_version: 0
- }
- end
-
- def self.default_commit_email_hostname
- "users.noreply.#{Gitlab.config.gitlab.host}"
- end
-
def self.create_from_defaults
- build_from_defaults.tap(&:save)
- end
-
- def self.human_attribute_name(attr, _options = {})
- if attr == :default_artifacts_expire_in
- 'Default artifacts expiration'
- else
+ transaction(requires_new: true) do
super
end
- end
-
- def home_page_url_column_exists?
- ::Gitlab::Database.cached_column_exists?(:application_settings, :home_page_url)
- end
-
- def help_page_support_url_column_exists?
- ::Gitlab::Database.cached_column_exists?(:application_settings, :help_page_support_url)
- end
-
- def disabled_oauth_sign_in_sources=(sources)
- sources = (sources || []).map(&:to_s) & Devise.omniauth_providers.map(&:to_s)
- super(sources)
- end
-
- def domain_whitelist_raw
- self.domain_whitelist&.join("\n")
- end
-
- def domain_blacklist_raw
- self.domain_blacklist&.join("\n")
- end
-
- def domain_whitelist_raw=(values)
- self.domain_whitelist = []
- self.domain_whitelist = values.split(DOMAIN_LIST_SEPARATOR)
- self.domain_whitelist.reject! { |d| d.empty? }
- self.domain_whitelist
- end
-
- def domain_blacklist_raw=(values)
- self.domain_blacklist = []
- self.domain_blacklist = values.split(DOMAIN_LIST_SEPARATOR)
- self.domain_blacklist.reject! { |d| d.empty? }
- self.domain_blacklist
- end
-
- def domain_blacklist_file=(file)
- self.domain_blacklist_raw = file.read
- end
-
- def repository_storages
- Array(read_attribute(:repository_storages))
- end
-
- def commit_email_hostname
- super.presence || self.class.default_commit_email_hostname
- end
-
- def default_project_visibility=(level)
- super(Gitlab::VisibilityLevel.level_value(level))
- end
-
- def default_snippet_visibility=(level)
- super(Gitlab::VisibilityLevel.level_value(level))
- end
-
- def default_group_visibility=(level)
- super(Gitlab::VisibilityLevel.level_value(level))
- end
-
- def restricted_visibility_levels=(levels)
- super(levels&.map { |level| Gitlab::VisibilityLevel.level_value(level) })
- end
-
- def strip_sentry_values
- sentry_dsn.strip! if sentry_dsn.present?
- clientside_sentry_dsn.strip! if clientside_sentry_dsn.present?
- end
-
- def performance_bar_allowed_group
- Group.find_by_id(performance_bar_allowed_group_id)
- end
-
- # Return true if the Performance Bar is enabled for a given group
- def performance_bar_enabled
- performance_bar_allowed_group_id.present?
- end
-
- # Choose one of the available repository storage options. Currently all have
- # equal weighting.
- def pick_repository_storage
- repository_storages.sample
- end
-
- def runners_registration_token
- ensure_runners_registration_token!
- end
-
- def health_check_access_token
- ensure_health_check_access_token!
- end
-
- def usage_ping_can_be_configured?
- Settings.gitlab.usage_ping_enabled
- end
-
- def usage_ping_enabled
- usage_ping_can_be_configured? && super
- end
-
- def allowed_key_types
- SUPPORTED_KEY_TYPES.select do |type|
- key_restriction_for(type) != FORBIDDEN_KEY_VALUE
- end
- end
-
- def key_restriction_for(type)
- attr_name = "#{type}_key_restriction"
-
- has_attribute?(attr_name) ? public_send(attr_name) : FORBIDDEN_KEY_VALUE # rubocop:disable GitlabSecurity/PublicSend
- end
-
- def allow_signup?
- signup_enabled? && password_authentication_enabled_for_web?
- end
-
- def password_authentication_enabled?
- password_authentication_enabled_for_web? || password_authentication_enabled_for_git?
- end
-
- def user_default_internal_regex_enabled?
- user_default_external? && user_default_internal_regex.present?
- end
-
- def user_default_internal_regex_instance
- Regexp.new(user_default_internal_regex, Regexp::IGNORECASE)
- end
-
- delegate :terms, to: :latest_terms, allow_nil: true
- def latest_terms
- @latest_terms ||= Term.latest
- end
-
- def reset_memoized_terms
- @latest_terms = nil
- latest_terms
- end
-
- def archive_builds_older_than
- archive_builds_in_seconds.seconds.ago if archive_builds_in_seconds
- end
-
- private
-
- def ensure_uuid!
- return if uuid?
-
- self.uuid = SecureRandom.uuid
- end
-
- def check_repository_storages
- invalid = repository_storages - Gitlab.config.repositories.storages.keys
- errors.add(:repository_storages, "can't include: #{invalid.join(", ")}") unless
- invalid.empty?
- end
-
- def terms_exist
- return unless enforce_terms?
-
- errors.add(:terms, "You need to set terms to be enforced") unless terms.present?
- end
-
- def expire_performance_bar_allowed_user_ids_cache
- Gitlab::PerformanceBar.expire_allowed_user_ids_cache
+ rescue ActiveRecord::RecordNotUnique
+ # We already have an ApplicationSetting record, so just return it.
+ current_without_cache
end
end
diff --git a/app/models/application_setting/term.rb b/app/models/application_setting/term.rb
index 498701ba22b..723540c9b91 100644
--- a/app/models/application_setting/term.rb
+++ b/app/models/application_setting/term.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class ApplicationSetting
- class Term < ActiveRecord::Base
+ class Term < ApplicationRecord
include CacheMarkdownField
has_many :term_agreements
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
new file mode 100644
index 00000000000..904d650ef96
--- /dev/null
+++ b/app/models/application_setting_implementation.rb
@@ -0,0 +1,299 @@
+# frozen_string_literal: true
+
+module ApplicationSettingImplementation
+ extend ActiveSupport::Concern
+
+ DOMAIN_LIST_SEPARATOR = %r{\s*[,;]\s* # comma or semicolon, optionally surrounded by whitespace
+ | # or
+ \s # any whitespace character
+ | # or
+ [\r\n] # any number of newline characters
+ }x.freeze
+
+ # Setting a key restriction to `-1` means that all keys of this type are
+ # forbidden.
+ FORBIDDEN_KEY_VALUE = KeyRestrictionValidator::FORBIDDEN
+ SUPPORTED_KEY_TYPES = %i[rsa dsa ecdsa ed25519].freeze
+
+ class_methods do
+ def defaults
+ {
+ after_sign_up_text: nil,
+ akismet_enabled: false,
+ allow_local_requests_from_hooks_and_services: false,
+ dns_rebinding_protection_enabled: true,
+ authorized_keys_enabled: true, # TODO default to false if the instance is configured to use AuthorizedKeysCommand
+ container_registry_token_expire_delay: 5,
+ default_artifacts_expire_in: '30 days',
+ default_branch_protection: Settings.gitlab['default_branch_protection'],
+ default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'],
+ default_project_creation: Settings.gitlab['default_project_creation'],
+ default_project_visibility: Settings.gitlab.default_projects_features['visibility_level'],
+ default_projects_limit: Settings.gitlab['default_projects_limit'],
+ default_snippet_visibility: Settings.gitlab.default_projects_features['visibility_level'],
+ disabled_oauth_sign_in_sources: [],
+ domain_whitelist: Settings.gitlab['domain_whitelist'],
+ dsa_key_restriction: 0,
+ ecdsa_key_restriction: 0,
+ ed25519_key_restriction: 0,
+ first_day_of_week: 0,
+ gitaly_timeout_default: 55,
+ gitaly_timeout_fast: 10,
+ gitaly_timeout_medium: 30,
+ gravatar_enabled: Settings.gravatar['enabled'],
+ help_page_hide_commercial_content: false,
+ help_page_text: nil,
+ hide_third_party_offers: false,
+ housekeeping_bitmaps_enabled: true,
+ housekeeping_enabled: true,
+ housekeeping_full_repack_period: 50,
+ housekeeping_gc_period: 200,
+ housekeeping_incremental_repack_period: 10,
+ import_sources: Settings.gitlab['import_sources'],
+ max_artifacts_size: Settings.artifacts['max_size'],
+ max_attachment_size: Settings.gitlab['max_attachment_size'],
+ mirror_available: true,
+ password_authentication_enabled_for_git: true,
+ password_authentication_enabled_for_web: Settings.gitlab['signin_enabled'],
+ performance_bar_allowed_group_id: nil,
+ rsa_key_restriction: 0,
+ plantuml_enabled: false,
+ plantuml_url: nil,
+ polling_interval_multiplier: 1,
+ project_export_enabled: true,
+ recaptcha_enabled: false,
+ repository_checks_enabled: true,
+ repository_storages: ['default'],
+ require_two_factor_authentication: false,
+ restricted_visibility_levels: Settings.gitlab['restricted_visibility_levels'],
+ session_expire_delay: Settings.gitlab['session_expire_delay'],
+ send_user_confirmation_email: false,
+ shared_runners_enabled: Settings.gitlab_ci['shared_runners_enabled'],
+ shared_runners_text: nil,
+ sign_in_text: nil,
+ signup_enabled: Settings.gitlab['signup_enabled'],
+ terminal_max_session_time: 0,
+ throttle_authenticated_api_enabled: false,
+ throttle_authenticated_api_period_in_seconds: 3600,
+ throttle_authenticated_api_requests_per_period: 7200,
+ throttle_authenticated_web_enabled: false,
+ throttle_authenticated_web_period_in_seconds: 3600,
+ throttle_authenticated_web_requests_per_period: 7200,
+ throttle_unauthenticated_enabled: false,
+ throttle_unauthenticated_period_in_seconds: 3600,
+ throttle_unauthenticated_requests_per_period: 3600,
+ two_factor_grace_period: 48,
+ unique_ips_limit_enabled: false,
+ unique_ips_limit_per_user: 10,
+ unique_ips_limit_time_window: 3600,
+ usage_ping_enabled: Settings.gitlab['usage_ping_enabled'],
+ instance_statistics_visibility_private: false,
+ user_default_external: false,
+ user_default_internal_regex: nil,
+ user_show_add_ssh_key_message: true,
+ usage_stats_set_by_user_id: nil,
+ diff_max_patch_bytes: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES,
+ commit_email_hostname: default_commit_email_hostname,
+ protected_ci_variables: false,
+ local_markdown_version: 0
+ }
+ end
+
+ def default_commit_email_hostname
+ "users.noreply.#{Gitlab.config.gitlab.host}"
+ end
+
+ def create_from_defaults
+ build_from_defaults.tap(&:save)
+ end
+
+ def human_attribute_name(attr, _options = {})
+ if attr == :default_artifacts_expire_in
+ 'Default artifacts expiration'
+ else
+ super
+ end
+ end
+ end
+
+ def home_page_url_column_exists?
+ ::Gitlab::Database.cached_column_exists?(:application_settings, :home_page_url)
+ end
+
+ def help_page_support_url_column_exists?
+ ::Gitlab::Database.cached_column_exists?(:application_settings, :help_page_support_url)
+ end
+
+ def disabled_oauth_sign_in_sources=(sources)
+ sources = (sources || []).map(&:to_s) & Devise.omniauth_providers.map(&:to_s)
+ super(sources)
+ end
+
+ def domain_whitelist_raw
+ self.domain_whitelist&.join("\n")
+ end
+
+ def domain_blacklist_raw
+ self.domain_blacklist&.join("\n")
+ end
+
+ def domain_whitelist_raw=(values)
+ self.domain_whitelist = []
+ self.domain_whitelist = values.split(DOMAIN_LIST_SEPARATOR)
+ self.domain_whitelist.reject! { |d| d.empty? }
+ self.domain_whitelist
+ end
+
+ def domain_blacklist_raw=(values)
+ self.domain_blacklist = []
+ self.domain_blacklist = values.split(DOMAIN_LIST_SEPARATOR)
+ self.domain_blacklist.reject! { |d| d.empty? }
+ self.domain_blacklist
+ end
+
+ def domain_blacklist_file=(file)
+ self.domain_blacklist_raw = file.read
+ end
+
+ def repository_storages
+ Array(read_attribute(:repository_storages))
+ end
+
+ def commit_email_hostname
+ super.presence || self.class.default_commit_email_hostname
+ end
+
+ def default_project_visibility=(level)
+ super(Gitlab::VisibilityLevel.level_value(level))
+ end
+
+ def default_snippet_visibility=(level)
+ super(Gitlab::VisibilityLevel.level_value(level))
+ end
+
+ def default_group_visibility=(level)
+ super(Gitlab::VisibilityLevel.level_value(level))
+ end
+
+ def restricted_visibility_levels=(levels)
+ super(levels&.map { |level| Gitlab::VisibilityLevel.level_value(level) })
+ end
+
+ def strip_sentry_values
+ sentry_dsn.strip! if sentry_dsn.present?
+ clientside_sentry_dsn.strip! if clientside_sentry_dsn.present?
+ end
+
+ def sentry_enabled
+ Gitlab.config.sentry.enabled || read_attribute(:sentry_enabled)
+ end
+
+ def sentry_dsn
+ Gitlab.config.sentry.dsn || read_attribute(:sentry_dsn)
+ end
+
+ def clientside_sentry_enabled
+ Gitlab.config.sentry.enabled || read_attribute(:clientside_sentry_enabled)
+ end
+
+ def clientside_sentry_dsn
+ Gitlab.config.sentry.clientside_dsn || read_attribute(:clientside_sentry_dsn)
+ end
+
+ def performance_bar_allowed_group
+ Group.find_by_id(performance_bar_allowed_group_id)
+ end
+
+ # Return true if the Performance Bar is enabled for a given group
+ def performance_bar_enabled
+ performance_bar_allowed_group_id.present?
+ end
+
+ # Choose one of the available repository storage options. Currently all have
+ # equal weighting.
+ def pick_repository_storage
+ repository_storages.sample
+ end
+
+ def runners_registration_token
+ ensure_runners_registration_token!
+ end
+
+ def health_check_access_token
+ ensure_health_check_access_token!
+ end
+
+ def usage_ping_can_be_configured?
+ Settings.gitlab.usage_ping_enabled
+ end
+
+ def usage_ping_enabled
+ usage_ping_can_be_configured? && super
+ end
+
+ def allowed_key_types
+ SUPPORTED_KEY_TYPES.select do |type|
+ key_restriction_for(type) != FORBIDDEN_KEY_VALUE
+ end
+ end
+
+ def key_restriction_for(type)
+ attr_name = "#{type}_key_restriction"
+
+ has_attribute?(attr_name) ? public_send(attr_name) : FORBIDDEN_KEY_VALUE # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ def allow_signup?
+ signup_enabled? && password_authentication_enabled_for_web?
+ end
+
+ def password_authentication_enabled?
+ password_authentication_enabled_for_web? || password_authentication_enabled_for_git?
+ end
+
+ def user_default_internal_regex_enabled?
+ user_default_external? && user_default_internal_regex.present?
+ end
+
+ def user_default_internal_regex_instance
+ Regexp.new(user_default_internal_regex, Regexp::IGNORECASE)
+ end
+
+ delegate :terms, to: :latest_terms, allow_nil: true
+ def latest_terms
+ @latest_terms ||= ApplicationSetting::Term.latest
+ end
+
+ def reset_memoized_terms
+ @latest_terms = nil # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ latest_terms
+ end
+
+ def archive_builds_older_than
+ archive_builds_in_seconds.seconds.ago if archive_builds_in_seconds
+ end
+
+ private
+
+ def ensure_uuid!
+ return if uuid?
+
+ self.uuid = SecureRandom.uuid
+ end
+
+ def check_repository_storages
+ invalid = repository_storages - Gitlab.config.repositories.storages.keys
+ errors.add(:repository_storages, "can't include: #{invalid.join(", ")}") unless
+ invalid.empty?
+ end
+
+ def terms_exist
+ return unless enforce_terms?
+
+ errors.add(:terms, "You need to set terms to be enforced") unless terms.present?
+ end
+
+ def expire_performance_bar_allowed_user_ids_cache
+ Gitlab::PerformanceBar.expire_allowed_user_ids_cache
+ end
+end
diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb
index 8508c88d406..6ef2914ac11 100644
--- a/app/models/audit_event.rb
+++ b/app/models/audit_event.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class AuditEvent < ActiveRecord::Base
+class AuditEvent < ApplicationRecord
serialize :details, Hash # rubocop:disable Cop/ActiveRecordSerialize
belongs_to :user, foreign_key: :author_id
diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb
index ddc516ccb60..e26162f6151 100644
--- a/app/models/award_emoji.rb
+++ b/app/models/award_emoji.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class AwardEmoji < ActiveRecord::Base
+class AwardEmoji < ApplicationRecord
DOWNVOTE_NAME = "thumbsdown".freeze
UPVOTE_NAME = "thumbsup".freeze
diff --git a/app/models/badge.rb b/app/models/badge.rb
index f016654206b..50299cd6652 100644
--- a/app/models/badge.rb
+++ b/app/models/badge.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Badge < ActiveRecord::Base
+class Badge < ApplicationRecord
include FromUnion
# This structure sets the placeholders that the urls
@@ -22,7 +22,7 @@ class Badge < ActiveRecord::Base
scope :order_created_at_asc, -> { reorder(created_at: :asc) }
- validates :link_url, :image_url, url: { protocols: %w(http https) }
+ validates :link_url, :image_url, addressable_url: true
validates :type, presence: true
def rendered_link_url(project = nil)
diff --git a/app/models/blob.rb b/app/models/blob.rb
index c5766eb0327..d528bef8b19 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -110,7 +110,7 @@ class Blob < SimpleDelegator
end
def load_all_data!
- # Endpoint needed: gitlab-org/gitaly#756
+ # Endpoint needed: https://gitlab.com/gitlab-org/gitaly/issues/756
Gitlab::GitalyClient.allow_n_plus_1_calls do
super(project.repository) if project
end
diff --git a/app/models/board.rb b/app/models/board.rb
index 758a71d6903..e08db764f65 100644
--- a/app/models/board.rb
+++ b/app/models/board.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Board < ActiveRecord::Base
+class Board < ApplicationRecord
belongs_to :group
belongs_to :project
diff --git a/app/models/board_group_recent_visit.rb b/app/models/board_group_recent_visit.rb
index 92abbb67222..2f1cd830791 100644
--- a/app/models/board_group_recent_visit.rb
+++ b/app/models/board_group_recent_visit.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
# Tracks which boards in a specific group a user has visited
-class BoardGroupRecentVisit < ActiveRecord::Base
+class BoardGroupRecentVisit < ApplicationRecord
belongs_to :user
belongs_to :group
belongs_to :board
@@ -10,7 +10,7 @@ class BoardGroupRecentVisit < ActiveRecord::Base
validates :group, presence: true
validates :board, presence: true
- scope :by_user_group, -> (user, group) { where(user: user, group: group).order(:updated_at) }
+ scope :by_user_group, -> (user, group) { where(user: user, group: group) }
def self.visited!(user, board)
visit = find_or_create_by(user: user, group: board.group, board: board)
@@ -19,7 +19,10 @@ class BoardGroupRecentVisit < ActiveRecord::Base
retry
end
- def self.latest(user, group)
- by_user_group(user, group).last
+ def self.latest(user, group, count: nil)
+ visits = by_user_group(user, group).order(updated_at: :desc)
+ visits = visits.preload(:board) if count && count > 1
+
+ visits.first(count)
end
end
diff --git a/app/models/board_project_recent_visit.rb b/app/models/board_project_recent_visit.rb
index 7cffff906d8..236d88e909c 100644
--- a/app/models/board_project_recent_visit.rb
+++ b/app/models/board_project_recent_visit.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
# Tracks which boards in a specific project a user has visited
-class BoardProjectRecentVisit < ActiveRecord::Base
+class BoardProjectRecentVisit < ApplicationRecord
belongs_to :user
belongs_to :project
belongs_to :board
@@ -10,7 +10,7 @@ class BoardProjectRecentVisit < ActiveRecord::Base
validates :project, presence: true
validates :board, presence: true
- scope :by_user_project, -> (user, project) { where(user: user, project: project).order(:updated_at) }
+ scope :by_user_project, -> (user, project) { where(user: user, project: project) }
def self.visited!(user, board)
visit = find_or_create_by(user: user, project: board.project, board: board)
@@ -19,7 +19,10 @@ class BoardProjectRecentVisit < ActiveRecord::Base
retry
end
- def self.latest(user, project)
- by_user_project(user, project).last
+ def self.latest(user, project, count: nil)
+ visits = by_user_project(user, project).order(updated_at: :desc)
+ visits = visits.preload(:board) if count && count > 1
+
+ visits.first(count)
end
end
diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb
index 2d237383e60..18fe2a9624f 100644
--- a/app/models/broadcast_message.rb
+++ b/app/models/broadcast_message.rb
@@ -1,10 +1,10 @@
# frozen_string_literal: true
-class BroadcastMessage < ActiveRecord::Base
+class BroadcastMessage < ApplicationRecord
include CacheMarkdownField
include Sortable
- cache_markdown_field :message, pipeline: :broadcast_message
+ cache_markdown_field :message, pipeline: :broadcast_message, whitelisted: true
validates :message, presence: true
validates :starts_at, presence: true
diff --git a/app/models/chat_name.rb b/app/models/chat_name.rb
index 03b0af53046..0041595baba 100644
--- a/app/models/chat_name.rb
+++ b/app/models/chat_name.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ChatName < ActiveRecord::Base
+class ChatName < ApplicationRecord
LAST_USED_AT_INTERVAL = 1.hour
belongs_to :service
diff --git a/app/models/chat_team.rb b/app/models/chat_team.rb
index 4e724f9adf7..52b5a7b4a91 100644
--- a/app/models/chat_team.rb
+++ b/app/models/chat_team.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ChatTeam < ActiveRecord::Base
+class ChatTeam < ApplicationRecord
validates :team_id, presence: true
validates :namespace, uniqueness: true
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 5450d40ea95..644716ba8e7 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -3,8 +3,11 @@
module Ci
class Bridge < CommitStatus
include Ci::Processable
+ include Ci::Contextable
+ include Ci::PipelineDelegator
include Importable
include AfterCommitQueue
+ include HasRef
include Gitlab::Utils::StrongMemoize
belongs_to :project
@@ -37,11 +40,11 @@ module Ci
false
end
- def expanded_environment_name
+ def runnable?
+ false
end
- def predefined_variables
- raise NotImplementedError
+ def expanded_environment_name
end
def execute_hooks
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index c902e49ee6d..89cc082d0bc 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -2,9 +2,10 @@
module Ci
class Build < CommitStatus
- prepend ArtifactMigratable
include Ci::Processable
include Ci::Metadatable
+ include Ci::Contextable
+ include Ci::PipelineDelegator
include TokenAuthenticatable
include AfterCommitQueue
include ObjectStorage::BackgroundMove
@@ -18,6 +19,11 @@ module Ci
BuildArchivedError = Class.new(StandardError)
ignore_column :commands
+ ignore_column :artifacts_file
+ ignore_column :artifacts_metadata
+ ignore_column :artifacts_file_store
+ ignore_column :artifacts_metadata_store
+ ignore_column :artifacts_size
belongs_to :project, inverse_of: :builds
belongs_to :runner
@@ -25,7 +31,8 @@ module Ci
belongs_to :erased_by, class_name: 'User'
RUNNER_FEATURES = {
- upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? }
+ upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? },
+ refspecs: -> (build) { build.merge_request_ref? }
}.freeze
has_one :deployment, as: :deployable, class_name: 'Deployment'
@@ -46,7 +53,6 @@ module Ci
delegate :terminal_specification, to: :runner_session, allow_nil: true
delegate :gitlab_deploy_token, to: :project
delegate :trigger_short_token, to: :trigger_request, allow_nil: true
- delegate :merge_request?, to: :pipeline
##
# Since Gitlab 11.5, deployments records started being created right after
@@ -80,8 +86,7 @@ module Ci
scope :unstarted, ->() { where(runner_id: nil) }
scope :ignore_failures, ->() { where(allow_failure: false) }
scope :with_artifacts_archive, ->() do
- where('(artifacts_file IS NOT NULL AND artifacts_file <> ?) OR EXISTS (?)',
- '', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive)
+ where('EXISTS (?)', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive)
end
scope :with_existing_job_artifacts, ->(query) do
@@ -96,15 +101,15 @@ module Ci
where('NOT EXISTS (?)', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').trace)
end
- scope :with_test_reports, ->() do
- with_existing_job_artifacts(Ci::JobArtifact.test_reports)
+ scope :with_reports, ->(reports_scope) do
+ with_existing_job_artifacts(reports_scope)
.eager_load_job_artifacts
end
scope :eager_load_job_artifacts, -> { includes(:job_artifacts) }
- scope :with_artifacts_stored_locally, -> { with_artifacts_archive.where(artifacts_file_store: [nil, LegacyArtifactUploader::Store::LOCAL]) }
- scope :with_archived_trace_stored_locally, -> { with_archived_trace.where(artifacts_file_store: [nil, LegacyArtifactUploader::Store::LOCAL]) }
+ scope :with_artifacts_stored_locally, -> { with_existing_job_artifacts(Ci::JobArtifact.archive.with_files_stored_locally) }
+ scope :with_archived_trace_stored_locally, -> { with_existing_job_artifacts(Ci::JobArtifact.trace.with_files_stored_locally) }
scope :with_artifacts_not_expired, ->() { with_artifacts_archive.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) }
scope :with_expired_artifacts, ->() { with_artifacts_archive.where('artifacts_expire_at < ?', Time.now) }
scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) }
@@ -132,14 +137,12 @@ module Ci
where("EXISTS (?)", matcher)
end
- mount_uploader :legacy_artifacts_file, LegacyArtifactUploader, mount_on: :artifacts_file
- mount_uploader :legacy_artifacts_metadata, LegacyArtifactUploader, mount_on: :artifacts_metadata
+ scope :queued_before, ->(time) { where(arel_table[:queued_at].lt(time)) }
acts_as_taggable
- add_authentication_token_field :token, encrypted: true, fallback: true
+ add_authentication_token_field :token, encrypted: :optional
- before_save :update_artifacts_size, if: :artifacts_file_changed?
before_save :ensure_token
before_destroy { unscoped_project }
@@ -147,9 +150,6 @@ module Ci
run_after_commit { BuildHooksWorker.perform_async(build.id) }
end
- after_save :update_project_statistics_after_save, if: :artifacts_size_changed?
- after_destroy :update_project_statistics_after_destroy, unless: :project_destroyed?
-
class << self
# This is needed for url_for to work,
# as the controller is JobsController
@@ -171,6 +171,10 @@ module Ci
end
state_machine :status do
+ event :enqueue do
+ transition [:created, :skipped, :manual, :scheduled] => :preparing, if: :any_unmet_prerequisites?
+ end
+
event :actionize do
transition created: :manual
end
@@ -184,8 +188,12 @@ module Ci
end
event :enqueue_scheduled do
+ transition scheduled: :preparing, if: ->(build) do
+ build.scheduled_at&.past? && build.any_unmet_prerequisites?
+ end
+
transition scheduled: :pending, if: ->(build) do
- build.scheduled_at && build.scheduled_at < Time.now
+ build.scheduled_at&.past? && !build.any_unmet_prerequisites?
end
end
@@ -203,6 +211,12 @@ module Ci
end
end
+ after_transition any => [:preparing] do |build|
+ build.run_after_commit do
+ Ci::BuildPrepareWorker.perform_async(id)
+ end
+ end
+
after_transition any => [:pending] do |build|
build.run_after_commit do
BuildQueueWorker.perform_async(id)
@@ -289,6 +303,10 @@ module Ci
self.name == 'pages'
end
+ def runnable?
+ true
+ end
+
def archived?
return true if degenerated?
@@ -350,6 +368,14 @@ module Ci
!retried?
end
+ def any_unmet_prerequisites?
+ prerequisites.present?
+ end
+
+ def prerequisites
+ Gitlab::Ci::Build::Prerequisite::Factory.new(self).unmet
+ end
+
def expanded_environment_name
return unless has_environment?
@@ -398,46 +424,6 @@ module Ci
options&.dig(:environment, :on_stop)
end
- # A slugified version of the build ref, suitable for inclusion in URLs and
- # domain names. Rules:
- #
- # * Lowercased
- # * Anything not matching [a-z0-9-] is replaced with a -
- # * Maximum length is 63 bytes
- # * First/Last Character is not a hyphen
- def ref_slug
- Gitlab::Utils.slugify(ref.to_s)
- end
-
- ##
- # Variables in the environment name scope.
- #
- def scoped_variables(environment: expanded_environment_name)
- Gitlab::Ci::Variables::Collection.new.tap do |variables|
- variables.concat(predefined_variables)
- variables.concat(project.predefined_variables)
- variables.concat(pipeline.predefined_variables)
- variables.concat(runner.predefined_variables) if runner
- variables.concat(project.deployment_variables(environment: environment)) if environment
- variables.concat(yaml_variables)
- variables.concat(user_variables)
- variables.concat(secret_group_variables)
- variables.concat(secret_project_variables(environment: environment))
- variables.concat(trigger_request.user_variables) if trigger_request
- variables.concat(pipeline.variables)
- variables.concat(pipeline.pipeline_schedule.job_variables) if pipeline.pipeline_schedule
- end
- end
-
- ##
- # Variables that do not depend on the environment name.
- #
- def simple_variables
- strong_memoize(:simple_variables) do
- scoped_variables(environment: nil).to_runner_variables
- end
- end
-
##
# All variables, including persisted environment variables.
#
@@ -451,12 +437,46 @@ module Ci
end
end
- ##
- # Regular Ruby hash of scoped variables, without duplicates that are
- # possible to be present in an array of hashes returned from `variables`.
- #
- def scoped_variables_hash
- scoped_variables.to_hash
+ CI_REGISTRY_USER = 'gitlab-ci-token'.freeze
+
+ def persisted_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ break variables unless persisted?
+
+ variables
+ .concat(pipeline.persisted_variables)
+ .append(key: 'CI_JOB_ID', value: id.to_s)
+ .append(key: 'CI_JOB_URL', value: Gitlab::Routing.url_helpers.project_job_url(project, self))
+ .append(key: 'CI_JOB_TOKEN', value: token.to_s, public: false, masked: true)
+ .append(key: 'CI_BUILD_ID', value: id.to_s)
+ .append(key: 'CI_BUILD_TOKEN', value: token.to_s, public: false, masked: true)
+ .append(key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER)
+ .append(key: 'CI_REGISTRY_PASSWORD', value: token.to_s, public: false, masked: true)
+ .append(key: 'CI_REPOSITORY_URL', value: repo_url.to_s, public: false)
+ .concat(deploy_token_variables)
+ end
+ end
+
+ def persisted_environment_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ break variables unless persisted? && persisted_environment.present?
+
+ variables.concat(persisted_environment.predefined_variables)
+
+ # Here we're passing unexpanded environment_url for runner to expand,
+ # and we need to make sure that CI_ENVIRONMENT_NAME and
+ # CI_ENVIRONMENT_SLUG so on are available for the URL be expanded.
+ variables.append(key: 'CI_ENVIRONMENT_URL', value: environment_url) if environment_url
+ end
+ end
+
+ def deploy_token_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ break variables unless gitlab_deploy_token
+
+ variables.append(key: 'CI_DEPLOY_USER', value: gitlab_deploy_token.username)
+ variables.append(key: 'CI_DEPLOY_PASSWORD', value: gitlab_deploy_token.token, public: false, masked: true)
+ end
end
def features
@@ -511,6 +531,26 @@ module Ci
trace.exist?
end
+ def artifacts_file
+ job_artifacts_archive&.file
+ end
+
+ def artifacts_size
+ job_artifacts_archive&.size
+ end
+
+ def artifacts_metadata
+ job_artifacts_metadata&.file
+ end
+
+ def artifacts?
+ !artifacts_expired? && artifacts_file&.exists?
+ end
+
+ def artifacts_metadata?
+ artifacts? && artifacts_metadata&.exists?
+ end
+
def has_job_artifacts?
job_artifacts.any?
end
@@ -579,14 +619,12 @@ module Ci
# and use that for `ExpireBuildInstanceArtifactsWorker`?
def erase_erasable_artifacts!
job_artifacts.erasable.destroy_all # rubocop: disable DestroyAll
- erase_old_artifacts!
end
def erase(opts = {})
return false unless erasable?
job_artifacts.destroy_all # rubocop: disable DestroyAll
- erase_old_artifacts!
erase_trace!
update_erased!(opts[:erased_by])
end
@@ -624,37 +662,13 @@ module Ci
end
def artifacts_file_for_type(type)
- file = job_artifacts.find_by(file_type: Ci::JobArtifact.file_types[type])&.file
- # TODO: to be removed once legacy artifacts is removed
- file ||= legacy_artifacts_file if type == :archive
- file
+ job_artifacts.find_by(file_type: Ci::JobArtifact.file_types[type])&.file
end
def coverage_regex
super || project.try(:build_coverage_regex)
end
- def user_variables
- Gitlab::Ci::Variables::Collection.new.tap do |variables|
- break variables if user.blank?
-
- variables.append(key: 'GITLAB_USER_ID', value: user.id.to_s)
- variables.append(key: 'GITLAB_USER_EMAIL', value: user.email)
- variables.append(key: 'GITLAB_USER_LOGIN', value: user.username)
- variables.append(key: 'GITLAB_USER_NAME', value: user.name)
- end
- end
-
- def secret_group_variables
- return [] unless project.group
-
- project.group.ci_variables_for(git_ref, project)
- end
-
- def secret_project_variables(environment: persisted_environment)
- project.ci_variables_for(ref: git_ref, environment: environment)
- end
-
def steps
[Gitlab::Ci::Build::Step.from_commands(self),
Gitlab::Ci::Build::Step.from_after_script(self)].compact
@@ -755,9 +769,13 @@ module Ci
end
end
+ def report_artifacts
+ job_artifacts.with_reports
+ end
+
# Virtual deployment status depending on the environment status.
def deployment_status
- return nil unless starts_environment?
+ return unless starts_environment?
if success?
return successful_deployment_status
@@ -770,13 +788,6 @@ module Ci
private
- def erase_old_artifacts!
- # TODO: To be removed once we get rid of
- remove_artifacts_file!
- remove_artifacts_metadata!
- save
- end
-
def successful_deployment_status
if deployment&.last?
:last
@@ -798,10 +809,6 @@ module Ci
job_artifacts.select { |artifact| artifact.file_type.in?(report_types) }
end
- def update_artifacts_size
- self.artifacts_size = legacy_artifacts_file&.size
- end
-
def erase_trace!
trace.erase!
end
@@ -814,89 +821,6 @@ module Ci
@unscoped_project ||= Project.unscoped.find_by(id: project_id)
end
- CI_REGISTRY_USER = 'gitlab-ci-token'.freeze
-
- def persisted_variables
- Gitlab::Ci::Variables::Collection.new.tap do |variables|
- break variables unless persisted?
-
- variables
- .concat(pipeline.persisted_variables)
- .append(key: 'CI_JOB_ID', value: id.to_s)
- .append(key: 'CI_JOB_URL', value: Gitlab::Routing.url_helpers.project_job_url(project, self))
- .append(key: 'CI_JOB_TOKEN', value: token.to_s, public: false)
- .append(key: 'CI_BUILD_ID', value: id.to_s)
- .append(key: 'CI_BUILD_TOKEN', value: token.to_s, public: false)
- .append(key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER)
- .append(key: 'CI_REGISTRY_PASSWORD', value: token.to_s, public: false)
- .append(key: 'CI_REPOSITORY_URL', value: repo_url.to_s, public: false)
- .concat(deploy_token_variables)
- end
- end
-
- def predefined_variables # rubocop:disable Metrics/AbcSize
- 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_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: ref)
- variables.append(key: 'CI_COMMIT_REF_SLUG', value: 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.concat(legacy_variables)
- end
- end
-
- 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: ref)
- variables.append(key: 'CI_BUILD_REF_SLUG', value: ref_slug)
- 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?
- end
- end
-
- def persisted_environment_variables
- Gitlab::Ci::Variables::Collection.new.tap do |variables|
- break variables unless persisted? && persisted_environment.present?
-
- variables.concat(persisted_environment.predefined_variables)
-
- # Here we're passing unexpanded environment_url for runner to expand,
- # and we need to make sure that CI_ENVIRONMENT_NAME and
- # CI_ENVIRONMENT_SLUG so on are available for the URL be expanded.
- variables.append(key: 'CI_ENVIRONMENT_URL', value: environment_url) if environment_url
- end
- end
-
- def deploy_token_variables
- Gitlab::Ci::Variables::Collection.new.tap do |variables|
- break variables unless gitlab_deploy_token
-
- variables.append(key: 'CI_DEPLOY_USER', value: gitlab_deploy_token.username)
- variables.append(key: 'CI_DEPLOY_PASSWORD', value: gitlab_deploy_token.token, public: false)
- end
- end
-
def environment_url
options&.dig(:environment, :url) || persisted_environment&.external_url
end
@@ -919,21 +843,5 @@ module Ci
pipeline.config_processor.build_attributes(name)
end
-
- def update_project_statistics_after_save
- update_project_statistics(read_attribute(:artifacts_size).to_i - artifacts_size_was.to_i)
- end
-
- def update_project_statistics_after_destroy
- update_project_statistics(-artifacts_size)
- end
-
- def update_project_statistics(difference)
- ProjectStatistics.increment_statistic(project_id, :build_artifacts_size, difference)
- end
-
- def project_destroyed?
- project.pending_delete?
- end
end
end
diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb
index cd8eb774cf5..f281cbd1d6f 100644
--- a/app/models/ci/build_metadata.rb
+++ b/app/models/ci/build_metadata.rb
@@ -3,7 +3,7 @@
module Ci
# The purpose of this class is to store Build related data that can be disposed.
# Data that should be persisted forever, should be stored with Ci::Build model.
- class BuildMetadata < ActiveRecord::Base
+ class BuildMetadata < ApplicationRecord
extend Gitlab::Ci::Model
include Presentable
include ChronicDurationAttribute
diff --git a/app/models/ci/build_runner_session.rb b/app/models/ci/build_runner_session.rb
index 457d7eeab6a..997bf298025 100644
--- a/app/models/ci/build_runner_session.rb
+++ b/app/models/ci/build_runner_session.rb
@@ -3,22 +3,34 @@
module Ci
# The purpose of this class is to store Build related runner session.
# Data will be removed after transitioning from running to any state.
- class BuildRunnerSession < ActiveRecord::Base
+ class BuildRunnerSession < ApplicationRecord
extend Gitlab::Ci::Model
+ TERMINAL_SUBPROTOCOL = 'terminal.gitlab.com'.freeze
+
self.table_name = 'ci_builds_runner_session'
belongs_to :build, class_name: 'Ci::Build', inverse_of: :runner_session
validates :build, presence: true
- validates :url, url: { protocols: %w(https) }
+ validates :url, addressable_url: { schemes: %w(https) }
def terminal_specification
- return {} unless url.present?
+ wss_url = Gitlab::UrlHelpers.as_wss(self.url)
+ return {} unless wss_url.present?
+
+ wss_url = "#{wss_url}/exec"
+ channel_specification(wss_url, TERMINAL_SUBPROTOCOL)
+ end
+
+ private
+
+ def channel_specification(url, subprotocol)
+ return {} if subprotocol.blank? || url.blank?
{
- subprotocols: ['terminal.gitlab.com'].freeze,
- url: "#{url}/exec".sub("https://", "wss://"),
+ subprotocols: Array(subprotocol),
+ url: url,
headers: { Authorization: [authorization.presence] }.compact,
ca_pem: certificate.presence
}
diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb
index 33e61cd2111..0a7a0e0772b 100644
--- a/app/models/ci/build_trace_chunk.rb
+++ b/app/models/ci/build_trace_chunk.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class BuildTraceChunk < ActiveRecord::Base
+ class BuildTraceChunk < ApplicationRecord
include FastDestroyAll
include ::Gitlab::ExclusiveLeaseHelpers
extend Gitlab::Ci::Model
@@ -115,7 +115,7 @@ module Ci
current_data = get_data
unless current_data&.bytesize.to_i == CHUNK_SIZE
- raise FailedToPersistDataError, 'Data is not fullfilled in a bucket'
+ raise FailedToPersistDataError, 'Data is not fulfilled in a bucket'
end
old_store_class = self.class.get_store_class(data_store)
diff --git a/app/models/ci/build_trace_section.rb b/app/models/ci/build_trace_section.rb
index a4bee59c83b..8be42eb48d6 100644
--- a/app/models/ci/build_trace_section.rb
+++ b/app/models/ci/build_trace_section.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class BuildTraceSection < ActiveRecord::Base
+ class BuildTraceSection < ApplicationRecord
extend Gitlab::Ci::Model
belongs_to :build, class_name: 'Ci::Build'
diff --git a/app/models/ci/build_trace_section_name.rb b/app/models/ci/build_trace_section_name.rb
index cbdf3c4b673..c065cfea14e 100644
--- a/app/models/ci/build_trace_section_name.rb
+++ b/app/models/ci/build_trace_section_name.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class BuildTraceSectionName < ActiveRecord::Base
+ class BuildTraceSectionName < ApplicationRecord
extend Gitlab::Ci::Model
belongs_to :project
diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb
index 492d1d0329e..0e50265c7ba 100644
--- a/app/models/ci/group_variable.rb
+++ b/app/models/ci/group_variable.rb
@@ -1,10 +1,11 @@
# frozen_string_literal: true
module Ci
- class GroupVariable < ActiveRecord::Base
+ class GroupVariable < ApplicationRecord
extend Gitlab::Ci::Model
include HasVariable
include Presentable
+ include Maskable
belongs_to :group, class_name: "::Group"
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index 789bb293811..f80e98e5bca 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -1,9 +1,10 @@
# frozen_string_literal: true
module Ci
- class JobArtifact < ActiveRecord::Base
+ class JobArtifact < ApplicationRecord
include AfterCommitQueue
include ObjectStorage::BackgroundMove
+ include UpdateProjectStatistics
extend Gitlab::Ci::Model
NotSupportedAdapterError = Class.new(StandardError)
@@ -21,14 +22,19 @@ module Ci
container_scanning: 'gl-container-scanning-report.json',
dast: 'gl-dast-report.json',
license_management: 'gl-license-management-report.json',
- performance: 'performance.json'
+ performance: 'performance.json',
+ metrics: 'metrics.txt'
}.freeze
- TYPE_AND_FORMAT_PAIRS = {
+ INTERNAL_TYPES = {
archive: :zip,
metadata: :gzip,
- trace: :raw,
+ trace: :raw
+ }.freeze
+
+ REPORT_TYPES = {
junit: :gzip,
+ metrics: :gzip,
# All these file formats use `raw` as we need to store them uncompressed
# for Frontend to fetch the files and do analysis
@@ -42,6 +48,8 @@ module Ci
performance: :raw
}.freeze
+ TYPE_AND_FORMAT_PAIRS = INTERNAL_TYPES.merge(REPORT_TYPES).freeze
+
belongs_to :project
belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id
@@ -50,10 +58,10 @@ module Ci
validates :file_format, presence: true, unless: :trace?, on: :create
validate :valid_file_format?, unless: :trace?, on: :create
before_save :set_size, if: :file_changed?
- after_save :update_project_statistics_after_save, if: :size_changed?
- after_destroy :update_project_statistics_after_destroy, unless: :project_destroyed?
- after_save :update_file_store, if: :file_changed?
+ update_project_statistics project_statistics_name: :build_artifacts_size
+
+ after_save :update_file_store, if: :saved_change_to_file?
scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) }
@@ -63,6 +71,10 @@ module Ci
where(file_type: types)
end
+ scope :with_reports, -> do
+ with_file_types(REPORT_TYPES.keys.map(&:to_s))
+ end
+
scope :test_reports, -> do
with_file_types(TEST_REPORT_FILE_TYPES)
end
@@ -88,14 +100,15 @@ module Ci
dast: 8, ## EE-specific
codequality: 9, ## EE-specific
license_management: 10, ## EE-specific
- performance: 11 ## EE-specific
+ performance: 11, ## EE-specific
+ metrics: 12 ## EE-specific
}
enum file_format: {
raw: 1,
zip: 2,
gzip: 3
- }
+ }, _suffix: true
# `file_location` indicates where actual files are stored.
# Ideally, actual files should be stored in the same directory, and use the same
@@ -173,18 +186,6 @@ module Ci
self.size = file.size
end
- def update_project_statistics_after_save
- update_project_statistics(size.to_i - size_was.to_i)
- end
-
- def update_project_statistics_after_destroy
- update_project_statistics(-self.size.to_i)
- end
-
- def update_project_statistics(difference)
- ProjectStatistics.increment_statistic(project_id, :build_artifacts_size, difference)
- end
-
def project_destroyed?
# Use job.project to avoid extra DB query for project
job.project.pending_delete?
diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb
index 96dbc7b6895..930c8a71453 100644
--- a/app/models/ci/legacy_stage.rb
+++ b/app/models/ci/legacy_stage.rb
@@ -58,5 +58,9 @@ module Ci
statuses.latest.failed_but_allowed.any?
end
end
+
+ def manual_playable?
+ %[manual scheduled skipped].include?(status.to_s)
+ end
end
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index eb15347b4e1..3727a9861aa 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class Pipeline < ActiveRecord::Base
+ class Pipeline < ApplicationRecord
extend Gitlab::Ci::Model
include HasStatus
include Importable
@@ -12,6 +12,11 @@ module Ci
include AtomicInternalId
include EnumWithNil
include HasRef
+ include ShaAttribute
+ include FromUnion
+
+ sha_attribute :source_sha
+ sha_attribute :target_sha
belongs_to :project, inverse_of: :all_pipelines
belongs_to :user
@@ -35,7 +40,7 @@ module Ci
# Merge requests for which the current pipeline is running against
# the merge request's latest commit.
- has_many :merge_requests, foreign_key: "head_pipeline_id"
+ has_many :merge_requests_as_head_pipeline, foreign_key: "head_pipeline_id", class_name: 'MergeRequest'
has_many :pending_builds, -> { pending }, foreign_key: :commit_id, class_name: 'Ci::Build'
has_many :retryable_builds, -> { latest.failed_or_canceled.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build'
@@ -56,9 +61,9 @@ module Ci
validates :sha, presence: { unless: :importing? }
validates :ref, presence: { unless: :importing? }
- validates :merge_request, presence: { if: :merge_request? }
- validates :merge_request, absence: { unless: :merge_request? }
- validates :tag, inclusion: { in: [false], if: :merge_request? }
+ validates :merge_request, presence: { if: :merge_request_event? }
+ validates :merge_request, absence: { unless: :merge_request_event? }
+ validates :tag, inclusion: { in: [false], if: :merge_request_event? }
validates :status, presence: { unless: :importing? }
validate :valid_commit_sha, unless: :importing?
validates :source, exclusion: { in: %w(unknown), unless: :importing? }, on: :create
@@ -77,10 +82,14 @@ module Ci
state_machine :status, initial: :created do
event :enqueue do
- transition [:created, :skipped, :scheduled] => :pending
+ transition [:created, :preparing, :skipped, :scheduled] => :pending
transition [:success, :failed, :canceled] => :running
end
+ event :prepare do
+ transition any - [:preparing] => :preparing
+ end
+
event :run do
transition any - [:running] => :running
end
@@ -113,7 +122,7 @@ module Ci
# Do not add any operations to this state_machine
# Create a separate worker for each new operation
- before_transition [:created, :pending] => :running do |pipeline|
+ before_transition [:created, :preparing, :pending] => :running do |pipeline|
pipeline.started_at = Time.now
end
@@ -136,7 +145,7 @@ module Ci
end
end
- after_transition [:created, :pending] => :running do |pipeline|
+ after_transition [:created, :preparing, :pending] => :running do |pipeline|
pipeline.run_after_commit { PipelineMetricsWorker.perform_async(pipeline.id) }
end
@@ -144,7 +153,7 @@ module Ci
pipeline.run_after_commit { PipelineMetricsWorker.perform_async(pipeline.id) }
end
- after_transition [:created, :pending, :running] => :success do |pipeline|
+ after_transition [:created, :preparing, :pending, :running] => :success do |pipeline|
pipeline.run_after_commit { PipelineSuccessWorker.perform_async(pipeline.id) }
end
@@ -157,6 +166,16 @@ module Ci
end
end
+ after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline|
+ pipeline.run_after_commit do
+ pipeline.all_merge_requests.each do |merge_request|
+ next unless merge_request.auto_merge_enabled?
+
+ AutoMergeProcessWorker.perform_async(merge_request.id)
+ end
+ end
+ end
+
after_transition any => [:success, :failed] do |pipeline|
pipeline.run_after_commit do
PipelineNotificationWorker.perform_async(pipeline.id)
@@ -175,20 +194,34 @@ module Ci
scope :sort_by_merge_request_pipelines, -> do
sql = 'CASE ci_pipelines.source WHEN (?) THEN 0 ELSE 1 END, ci_pipelines.id DESC'
- query = ActiveRecord::Base.send(:sanitize_sql_array, [sql, sources[:merge_request]]) # rubocop:disable GitlabSecurity/PublicSend
+ query = ApplicationRecord.send(:sanitize_sql_array, [sql, sources[:merge_request_event]]) # rubocop:disable GitlabSecurity/PublicSend
order(query)
end
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) }
+ scope :for_sha_or_source_sha, -> (sha) { for_sha(sha).or(for_source_sha(sha)) }
+
+ 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 :for_merge_request, -> (merge_request, ref, sha) do
- ##
- # We have to filter out unrelated MR pipelines.
- # When merge request is empty, it selects general pipelines, such as push sourced pipelines.
- # When merge request is matched, it selects MR pipelines.
- where(merge_request: [nil, merge_request], ref: ref, sha: sha)
- .sort_by_merge_request_pipelines
+ 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
# Returns the pipelines in descending order (= newest first), optionally
@@ -278,8 +311,8 @@ module Ci
sources.reject { |source| source == "external" }.values
end
- def self.latest_for_merge_request(merge_request, ref, sha)
- for_merge_request(merge_request, ref, sha).first
+ def self.branch_pipeline_sources
+ @branch_pipeline_sources ||= sources.reject { |source| source == 'merge_request_event' }.values
end
def self.ci_sources_values
@@ -397,10 +430,6 @@ module Ci
@commit ||= Commit.lazy(project, sha)
end
- def branch?
- super && !merge_request?
- end
-
def stuck?
pending_builds.any?(&:stuck?)
end
@@ -446,9 +475,9 @@ module Ci
end
def latest?
- return false unless ref && commit.present?
+ return false unless git_ref && commit.present?
- project.commit(ref) == commit
+ project.commit(git_ref) == commit
end
def retried
@@ -582,6 +611,7 @@ module Ci
retry_optimistic_lock(self) do
case latest_builds_status.to_s
when 'created' then nil
+ when 'preparing' then prepare
when 'pending' then enqueue
when 'running' then run
when 'success' then succeed
@@ -622,8 +652,11 @@ module Ci
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)
- if merge_request? && merge_request
+ if merge_request_event? && merge_request
+ variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', value: source_sha.to_s)
+ variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_SHA', value: target_sha.to_s)
variables.concat(merge_request.predefined_variables)
end
end
@@ -651,10 +684,10 @@ module Ci
# All the merge requests for which the current pipeline runs/ran against
def all_merge_requests
@all_merge_requests ||=
- if merge_request?
- project.merge_requests.where(id: merge_request_id)
+ if merge_request_event?
+ MergeRequest.where(id: merge_request_id)
else
- project.merge_requests.where(source_branch: ref)
+ MergeRequest.where(source_project_id: project_id, source_branch: ref)
end
end
@@ -668,16 +701,16 @@ module Ci
# We purposely cast the builds to an Array here. Because we always use the
# rows if there are more than 0 this prevents us from having to run two
# queries: one to get the count and one to get the rows.
- @latest_builds_with_artifacts ||= builds.latest.with_artifacts_archive.to_a
+ @latest_builds_with_artifacts ||= builds.latest.with_artifacts_not_expired.to_a
end
- def has_test_reports?
- complete? && builds.latest.with_test_reports.any?
+ def has_reports?(reports_scope)
+ complete? && builds.latest.with_reports(reports_scope).exists?
end
def test_reports
Gitlab::Ci::Reports::TestReports.new.tap do |test_reports|
- builds.latest.with_test_reports.each do |build|
+ builds.latest.with_reports(Ci::JobArtifact.test_reports).each do |build|
build.collect_test_reports!(test_reports)
end
end
@@ -696,7 +729,7 @@ module Ci
# * nil: Modified path can not be evaluated
def modified_paths
strong_memoize(:modified_paths) do
- if merge_request?
+ if merge_request_event?
merge_request.modified_paths
elsif branch_updated?
push_details.modified_paths
@@ -708,6 +741,50 @@ module Ci
ref == project.default_branch
end
+ def triggered_by_merge_request?
+ merge_request_event? && merge_request_id.present?
+ end
+
+ def detached_merge_request_pipeline?
+ triggered_by_merge_request? && target_sha.nil?
+ end
+
+ def legacy_detached_merge_request_pipeline?
+ detached_merge_request_pipeline? && !merge_request_ref?
+ end
+
+ def merge_request_pipeline?
+ triggered_by_merge_request? && target_sha.present?
+ end
+
+ def merge_request_ref?
+ MergeRequest.merge_request_ref?(ref)
+ end
+
+ def matches_sha_or_source_sha?(sha)
+ self.sha == sha || self.source_sha == sha
+ end
+
+ def triggered_by?(current_user)
+ user == current_user
+ end
+
+ def source_ref
+ if triggered_by_merge_request?
+ merge_request.source_branch
+ else
+ ref
+ end
+ end
+
+ def source_ref_slug
+ Gitlab::Utils.slugify(source_ref.to_s)
+ end
+
+ def find_stage_by_name!(name)
+ stages.find_by!(name: name)
+ end
+
private
def ci_yaml_from_repo
@@ -739,16 +816,18 @@ module Ci
end
def git_ref
- if merge_request?
- ##
- # In the future, we're going to change this ref to
- # merge request's merged reference, such as "refs/merge-requests/:iid/merge".
- # In order to do that, we have to update GitLab-Runner's source pulling
- # logic.
- # See https://gitlab.com/gitlab-org/gitlab-runner/merge_requests/1092
- Gitlab::Git::BRANCH_REF_PREFIX + ref.to_s
- else
- super
+ strong_memoize(:git_ref) do
+ if merge_request_event?
+ ##
+ # In the future, we're going to change this ref to
+ # merge request's merged reference, such as "refs/merge-requests/:iid/merge".
+ # In order to do that, we have to update GitLab-Runner's source pulling
+ # logic.
+ # See https://gitlab.com/gitlab-org/gitlab-runner/merge_requests/1092
+ Gitlab::Git::BRANCH_REF_PREFIX + ref.to_s
+ else
+ super
+ end
end
end
diff --git a/app/models/ci/pipeline_chat_data.rb b/app/models/ci/pipeline_chat_data.rb
index 8d37500fec5..65466a8c6f8 100644
--- a/app/models/ci/pipeline_chat_data.rb
+++ b/app/models/ci/pipeline_chat_data.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class PipelineChatData < ActiveRecord::Base
+ class PipelineChatData < ApplicationRecord
self.table_name = 'ci_pipeline_chat_data'
belongs_to :chat_name
diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb
index 4be4fdb1ff2..571c4271475 100644
--- a/app/models/ci/pipeline_enums.rb
+++ b/app/models/ci/pipeline_enums.rb
@@ -23,7 +23,7 @@ module Ci
api: 5,
external: 6,
chat: 8,
- merge_request: 10
+ merge_request_event: 10
}
end
diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb
index 1c1f203bdb2..c40ad39be61 100644
--- a/app/models/ci/pipeline_schedule.rb
+++ b/app/models/ci/pipeline_schedule.rb
@@ -1,10 +1,11 @@
# frozen_string_literal: true
module Ci
- class PipelineSchedule < ActiveRecord::Base
+ class PipelineSchedule < ApplicationRecord
extend Gitlab::Ci::Model
include Importable
include IgnorableColumn
+ include StripAttribute
ignore_column :deleted_at
@@ -22,11 +23,17 @@ module Ci
before_save :set_next_run_at
+ strip_attributes :cron
+
scope :active, -> { where(active: true) }
scope :inactive, -> { where(active: false) }
+ scope :runnable_schedules, -> { active.where("next_run_at < ?", Time.now) }
+ scope :preloaded, -> { preload(:owner, :project) }
accepts_nested_attributes_for :variables, allow_destroy: true
+ alias_attribute :real_next_run, :next_run_at
+
def owned_by?(current_user)
owner == current_user
end
@@ -43,8 +50,14 @@ module Ci
update_attribute(:active, false)
end
+ ##
+ # The `next_run_at` column is set to the actual execution date of `PipelineScheduleWorker`.
+ # This way, a schedule like `*/1 * * * *` won't be triggered in a short interval
+ # when PipelineScheduleWorker runs irregularly by Sidekiq Memory Killer.
def set_next_run_at
- self.next_run_at = Gitlab::Ci::CronParser.new(cron, cron_timezone).next_time_from(Time.now)
+ self.next_run_at = Gitlab::Ci::CronParser.new(Settings.cron_jobs['pipeline_schedule_worker']['cron'],
+ Time.zone.name)
+ .next_time_from(ideal_next_run_at)
end
def schedule_next_run!
@@ -53,15 +66,14 @@ module Ci
update_attribute(:next_run_at, nil) # update without validation
end
- def real_next_run(
- worker_cron: Settings.cron_jobs['pipeline_schedule_worker']['cron'],
- worker_time_zone: Time.zone.name)
- Gitlab::Ci::CronParser.new(worker_cron, worker_time_zone)
- .next_time_from(next_run_at)
- end
-
def job_variables
variables&.map(&:to_runner_variable) || []
end
+
+ private
+
+ def ideal_next_run_at
+ Gitlab::Ci::CronParser.new(cron, cron_timezone).next_time_from(Time.now)
+ end
end
end
diff --git a/app/models/ci/pipeline_schedule_variable.rb b/app/models/ci/pipeline_schedule_variable.rb
index fbb9987cab2..be6e5e76c31 100644
--- a/app/models/ci/pipeline_schedule_variable.rb
+++ b/app/models/ci/pipeline_schedule_variable.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class PipelineScheduleVariable < ActiveRecord::Base
+ class PipelineScheduleVariable < ApplicationRecord
extend Gitlab::Ci::Model
include HasVariable
diff --git a/app/models/ci/pipeline_variable.rb b/app/models/ci/pipeline_variable.rb
index 08514d6af4e..51a6272e1ff 100644
--- a/app/models/ci/pipeline_variable.rb
+++ b/app/models/ci/pipeline_variable.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class PipelineVariable < ActiveRecord::Base
+ class PipelineVariable < ApplicationRecord
extend Gitlab::Ci::Model
include HasVariable
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 5aae31de6e2..07d00503861 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class Runner < ActiveRecord::Base
+ class Runner < ApplicationRecord
extend Gitlab::Ci::Model
include Gitlab::SQL::Pattern
include IgnorableColumn
@@ -10,7 +10,7 @@ module Ci
include FromUnion
include TokenAuthenticatable
- add_authentication_token_field :token, encrypted: true, migrating: true
+ add_authentication_token_field :token, encrypted: -> { Feature.enabled?(:ci_runners_tokens_optional_encryption, default_enabled: true) ? :optional : :required }
enum access_level: {
not_protected: 0,
@@ -97,6 +97,7 @@ module Ci
scope :order_contacted_at_asc, -> { order(contacted_at: :asc) }
scope :order_created_at_desc, -> { order(created_at: :desc) }
+ scope :with_tags, -> { preload(:tags) }
validate :tag_constraints
validates :access_level, presence: true
diff --git a/app/models/ci/runner_namespace.rb b/app/models/ci/runner_namespace.rb
index 22b80b98551..6903e8a21a1 100644
--- a/app/models/ci/runner_namespace.rb
+++ b/app/models/ci/runner_namespace.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class RunnerNamespace < ActiveRecord::Base
+ class RunnerNamespace < ApplicationRecord
extend Gitlab::Ci::Model
belongs_to :runner, inverse_of: :runner_namespaces, validate: true
diff --git a/app/models/ci/runner_project.rb b/app/models/ci/runner_project.rb
index 1a718d24141..f5bd50dc5a3 100644
--- a/app/models/ci/runner_project.rb
+++ b/app/models/ci/runner_project.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class RunnerProject < ActiveRecord::Base
+ class RunnerProject < ApplicationRecord
extend Gitlab::Ci::Model
belongs_to :runner, inverse_of: :runner_projects
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index 0389945191e..d90339d90dc 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class Stage < ActiveRecord::Base
+ class Stage < ApplicationRecord
extend Gitlab::Ci::Model
include Importable
include HasStatus
@@ -39,10 +39,14 @@ module Ci
state_machine :status, initial: :created do
event :enqueue do
- transition created: :pending
+ transition [:created, :preparing] => :pending
transition [:success, :failed, :canceled, :skipped] => :running
end
+ event :prepare do
+ transition any - [:preparing] => :preparing
+ end
+
event :run do
transition any - [:running] => :running
end
@@ -76,6 +80,7 @@ module Ci
retry_optimistic_lock(self) do
case statuses.latest.status
when 'created' then nil
+ when 'preparing' then prepare
when 'pending' then enqueue
when 'running' then run
when 'success' then succeed
@@ -115,5 +120,9 @@ module Ci
.new(self, current_user)
.fabricate!
end
+
+ def manual_playable?
+ blocked? || skipped?
+ end
end
end
diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb
index 637148c4ce4..8927bb9bc18 100644
--- a/app/models/ci/trigger.rb
+++ b/app/models/ci/trigger.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class Trigger < ActiveRecord::Base
+ class Trigger < ApplicationRecord
extend Gitlab::Ci::Model
include IgnorableColumn
include Presentable
diff --git a/app/models/ci/trigger_request.rb b/app/models/ci/trigger_request.rb
index 0b52c690e93..5daf3dd192d 100644
--- a/app/models/ci/trigger_request.rb
+++ b/app/models/ci/trigger_request.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class TriggerRequest < ActiveRecord::Base
+ class TriggerRequest < ApplicationRecord
extend Gitlab::Ci::Model
belongs_to :trigger
diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb
index 524d79014f8..a77bbef0fca 100644
--- a/app/models/ci/variable.rb
+++ b/app/models/ci/variable.rb
@@ -1,10 +1,11 @@
# frozen_string_literal: true
module Ci
- class Variable < ActiveRecord::Base
+ class Variable < ApplicationRecord
extend Gitlab::Ci::Model
include HasVariable
include Presentable
+ include Maskable
belongs_to :project
diff --git a/app/models/clusters/applications/cert_manager.rb b/app/models/clusters/applications/cert_manager.rb
index c758577815a..d6a7d1d2bdd 100644
--- a/app/models/clusters/applications/cert_manager.rb
+++ b/app/models/clusters/applications/cert_manager.rb
@@ -2,7 +2,7 @@
module Clusters
module Applications
- class CertManager < ActiveRecord::Base
+ class CertManager < ApplicationRecord
VERSION = 'v0.5.2'.freeze
self.table_name = 'clusters_applications_cert_managers'
@@ -24,6 +24,12 @@ module Clusters
'stable/cert-manager'
end
+ # We will implement this in future MRs.
+ # Need to reverse postinstall step
+ def allowed_to_uninstall?
+ false
+ end
+
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
name: 'certmanager',
diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb
index 423071ec024..a83d06c4b00 100644
--- a/app/models/clusters/applications/helm.rb
+++ b/app/models/clusters/applications/helm.rb
@@ -4,7 +4,7 @@ require 'openssl'
module Clusters
module Applications
- class Helm < ActiveRecord::Base
+ class Helm < ApplicationRecord
self.table_name = 'clusters_applications_helm'
attr_encrypted :ca_key,
@@ -29,6 +29,13 @@ module Clusters
self.status = 'installable' if cluster&.platform_kubernetes_active?
end
+ # We will implement this in future MRs.
+ # Basically we need to check all other applications are not installed
+ # first.
+ def allowed_to_uninstall?
+ false
+ end
+
def install_command
Gitlab::Kubernetes::Helm::InitCommand.new(
name: name,
diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
index 7c15aaa4825..a1023f44049 100644
--- a/app/models/clusters/applications/ingress.rb
+++ b/app/models/clusters/applications/ingress.rb
@@ -2,7 +2,7 @@
module Clusters
module Applications
- class Ingress < ActiveRecord::Base
+ class Ingress < ApplicationRecord
VERSION = '1.1.2'.freeze
self.table_name = 'clusters_applications_ingress'
@@ -35,6 +35,13 @@ module Clusters
'stable/nginx-ingress'
end
+ # We will implement this in future MRs.
+ # Basically we need to check all dependent applications are not installed
+ # first.
+ def allowed_to_uninstall?
+ false
+ end
+
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
name: name,
@@ -48,6 +55,7 @@ module Clusters
def schedule_status_update
return unless installed?
return if external_ip
+ return if external_hostname
ClusterWaitForIngressIpAddressWorker.perform_async(name, id)
end
diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb
index 421a923d386..4aaa1f941e5 100644
--- a/app/models/clusters/applications/jupyter.rb
+++ b/app/models/clusters/applications/jupyter.rb
@@ -1,9 +1,11 @@
# frozen_string_literal: true
+require 'securerandom'
+
module Clusters
module Applications
- class Jupyter < ActiveRecord::Base
- VERSION = 'v0.6'.freeze
+ class Jupyter < ApplicationRecord
+ VERSION = '0.9-174bbd5'.freeze
self.table_name = 'clusters_applications_jupyter'
@@ -18,8 +20,10 @@ module Clusters
def set_initial_status
return unless not_installable?
+ return unless cluster&.application_ingress_available?
- if cluster&.application_ingress_available? && cluster.application_ingress.external_ip
+ ingress = cluster.application_ingress
+ if ingress.external_ip || ingress.external_hostname
self.status = 'installable'
end
end
@@ -36,6 +40,12 @@ module Clusters
content_values.to_yaml
end
+ # Will be addressed in future MRs
+ # We need to investigate and document what will be permanently deleted.
+ def allowed_to_uninstall?
+ false
+ end
+
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
name: name,
@@ -51,6 +61,10 @@ module Clusters
"http://#{hostname}/hub/oauth_callback"
end
+ def oauth_scopes
+ 'api read_repository write_repository'
+ end
+
private
def specification
@@ -72,24 +86,41 @@ module Clusters
"secretToken" => secret_token
},
"auth" => {
+ "state" => {
+ "cryptoKey" => crypto_key
+ },
"gitlab" => {
"clientId" => oauth_application.uid,
"clientSecret" => oauth_application.secret,
- "callbackUrl" => callback_url
+ "callbackUrl" => callback_url,
+ "gitlabProjectIdWhitelist" => [project_id]
}
},
"singleuser" => {
"extraEnv" => {
- "GITLAB_CLUSTER_ID" => cluster.id
+ "GITLAB_CLUSTER_ID" => cluster.id.to_s,
+ "GITLAB_HOST" => gitlab_host
}
}
}
end
+ def crypto_key
+ @crypto_key ||= SecureRandom.hex(32)
+ end
+
+ def project_id
+ cluster&.project&.id
+ end
+
def gitlab_url
Gitlab.config.gitlab.url
end
+ def gitlab_host
+ Gitlab.config.gitlab.host
+ end
+
def content_values
YAML.load_file(chart_values_file).deep_merge!(specification)
end
diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb
index 8d79b041b64..d5a3bd62e3d 100644
--- a/app/models/clusters/applications/knative.rb
+++ b/app/models/clusters/applications/knative.rb
@@ -2,8 +2,8 @@
module Clusters
module Applications
- class Knative < ActiveRecord::Base
- VERSION = '0.2.2'.freeze
+ class Knative < ApplicationRecord
+ VERSION = '0.5.0'.freeze
REPOSITORY = 'https://storage.googleapis.com/triggermesh-charts'.freeze
METRICS_CONFIG = 'https://storage.googleapis.com/triggermesh-charts/istio-metrics.yaml'.freeze
FETCH_IP_ADDRESS_DELAY = 30.seconds
@@ -15,9 +15,6 @@ module Clusters
include ::Clusters::Concerns::ApplicationVersion
include ::Clusters::Concerns::ApplicationData
include AfterCommitQueue
- include ReactiveCaching
-
- self.reactive_cache_key = ->(knative) { [knative.class.model_name.singular, knative.id] }
def set_initial_status
return unless not_installable?
@@ -41,8 +38,6 @@ module Clusters
scope :for_cluster, -> (cluster) { where(cluster: cluster) }
- after_save :clear_reactive_cache!
-
def chart
'knative/knative'
end
@@ -51,6 +46,12 @@ module Clusters
{ "domain" => hostname }.to_yaml
end
+ # Handled in a new issue:
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/59369
+ def allowed_to_uninstall?
+ false
+ end
+
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
name: name,
@@ -66,59 +67,17 @@ module Clusters
def schedule_status_update
return unless installed?
return if external_ip
+ return if external_hostname
ClusterWaitForIngressIpAddressWorker.perform_async(name, id)
end
- def client
- cluster.kubeclient.knative_client
- end
-
- def services
- with_reactive_cache do |data|
- data[:services]
- end
- end
-
- def calculate_reactive_cache
- { services: read_services, pods: read_pods }
- end
-
def ingress_service
- cluster.kubeclient.get_service('knative-ingressgateway', 'istio-system')
- end
-
- def services_for(ns: namespace)
- return [] unless services
- return [] unless ns
-
- services.select do |service|
- service.dig('metadata', 'namespace') == ns
- end
- end
-
- def service_pod_details(ns, service)
- with_reactive_cache do |data|
- data[:pods].select { |pod| filter_pods(pod, ns, service) }
- end
+ cluster.kubeclient.get_service('istio-ingressgateway', 'istio-system')
end
private
- def read_pods
- cluster.kubeclient.core_client.get_pods.as_json
- end
-
- def filter_pods(pod, namespace, service)
- pod["metadata"]["namespace"] == namespace && pod["metadata"]["labels"]["serving.knative.dev/service"] == service
- end
-
- def read_services
- client.get_services.as_json
- rescue Kubeclient::ResourceNotFoundError
- []
- end
-
def install_knative_metrics
["kubectl apply -f #{METRICS_CONFIG}"] if cluster.application_prometheus_available?
end
diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb
index fa7ce363531..a6b7617b830 100644
--- a/app/models/clusters/applications/prometheus.rb
+++ b/app/models/clusters/applications/prometheus.rb
@@ -2,7 +2,7 @@
module Clusters
module Applications
- class Prometheus < ActiveRecord::Base
+ class Prometheus < ApplicationRecord
include PrometheusAdapter
VERSION = '6.7.3'
@@ -16,10 +16,12 @@ module Clusters
default_value_for :version, VERSION
+ after_destroy :disable_prometheus_integration
+
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)
+ project.find_or_initialize_service('prometheus').update!(active: true)
end
end
end
@@ -47,6 +49,14 @@ module Clusters
)
end
+ def uninstall_command
+ Gitlab::Kubernetes::Helm::DeleteCommand.new(
+ name: name,
+ rbac: cluster.platform_kubernetes_rbac?,
+ files: files
+ )
+ end
+
def upgrade_command(values)
::Gitlab::Kubernetes::Helm::InstallCommand.new(
name: name,
@@ -82,6 +92,12 @@ module Clusters
private
+ def disable_prometheus_integration
+ cluster.projects.each do |project|
+ project.prometheus_service&.update!(active: false)
+ end
+ end
+
def kube_client
cluster&.kubeclient&.core_client
end
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index 941551dadaa..db7fd8524c2 100644
--- a/app/models/clusters/applications/runner.rb
+++ b/app/models/clusters/applications/runner.rb
@@ -2,8 +2,8 @@
module Clusters
module Applications
- class Runner < ActiveRecord::Base
- VERSION = '0.2.0'.freeze
+ class Runner < ApplicationRecord
+ VERSION = '0.5.2'.freeze
self.table_name = 'clusters_applications_runners'
@@ -13,7 +13,7 @@ module Clusters
include ::Clusters::Concerns::ApplicationData
belongs_to :runner, class_name: 'Ci::Runner', foreign_key: :runner_id
- delegate :project, to: :cluster
+ delegate :project, :group, to: :cluster
default_value_for :version, VERSION
@@ -29,6 +29,13 @@ module Clusters
content_values.to_yaml
end
+ # Need to investigate if pipelines run by this runner will stop upon the
+ # executor pod stopping
+ # I.e.run a pipeline, and uninstall runner while pipeline is running
+ def allowed_to_uninstall?
+ false
+ end
+
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
name: name,
@@ -55,12 +62,19 @@ module Clusters
end
def runner_create_params
- {
+ attributes = {
name: 'kubernetes-cluster',
- runner_type: :project_type,
- tag_list: %w(kubernetes cluster),
- projects: [project]
+ runner_type: cluster.cluster_type,
+ tag_list: %w[kubernetes cluster]
}
+
+ if cluster.group_type?
+ attributes[:groups] = [group]
+ elsif cluster.project_type?
+ attributes[:projects] = [project]
+ end
+
+ attributes
end
def gitlab_url
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index be3e6a05e1e..e1d6b2a802b 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -1,22 +1,26 @@
# frozen_string_literal: true
module Clusters
- class Cluster < ActiveRecord::Base
+ class Cluster < ApplicationRecord
include Presentable
include Gitlab::Utils::StrongMemoize
include FromUnion
+ include ReactiveCaching
self.table_name = 'clusters'
+ self.reactive_cache_key = -> (cluster) { [cluster.class.model_name.singular, cluster.id] }
+ PROJECT_ONLY_APPLICATIONS = {
+ Applications::Jupyter.application_name => Applications::Jupyter,
+ Applications::Knative.application_name => Applications::Knative
+ }.freeze
APPLICATIONS = {
Applications::Helm.application_name => Applications::Helm,
Applications::Ingress.application_name => Applications::Ingress,
Applications::CertManager.application_name => Applications::CertManager,
- Applications::Prometheus.application_name => Applications::Prometheus,
Applications::Runner.application_name => Applications::Runner,
- Applications::Jupyter.application_name => Applications::Jupyter,
- Applications::Knative.application_name => Applications::Knative
- }.freeze
+ Applications::Prometheus.application_name => Applications::Prometheus
+ }.merge(PROJECT_ONLY_APPLICATIONS).freeze
DEFAULT_ENVIRONMENT = '*'.freeze
KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN'.freeze
@@ -43,7 +47,6 @@ module Clusters
has_one :application_knative, class_name: 'Clusters::Applications::Knative'
has_many :kubernetes_namespaces
- has_one :kubernetes_namespace, -> { order(id: :desc) }, class_name: 'Clusters::KubernetesNamespace'
accepts_nested_attributes_for :provider_gcp, update_only: true
accepts_nested_attributes_for :platform_kubernetes, update_only: true
@@ -56,6 +59,8 @@ module Clusters
validate :no_groups, unless: :group_type?
validate :no_projects, unless: :project_type?
+ after_save :clear_reactive_cache!
+
delegate :status, to: :provider, allow_nil: true
delegate :status_reason, to: :provider, allow_nil: true
delegate :on_creation?, to: :provider, allow_nil: true
@@ -67,8 +72,10 @@ module Clusters
delegate :available?, to: :application_prometheus, prefix: true, allow_nil: true
delegate :available?, to: :application_knative, prefix: true, allow_nil: true
delegate :external_ip, to: :application_ingress, prefix: true, allow_nil: true
+ delegate :external_hostname, to: :application_ingress, prefix: true, allow_nil: true
alias_attribute :base_domain, :domain
+ alias_attribute :provided_by_user?, :user?
enum cluster_type: {
instance_type: 1,
@@ -90,6 +97,7 @@ module Clusters
scope :user_provided, -> { where(provider_type: ::Clusters::Cluster.provider_types[:user]) }
scope :gcp_provided, -> { where(provider_type: ::Clusters::Cluster.provider_types[:gcp]) }
scope :gcp_installed, -> { gcp_provided.includes(:provider_gcp).where(cluster_providers_gcp: { status: ::Clusters::Providers::Gcp.state_machines[:status].states[:created].value }) }
+ scope :managed, -> { where(managed: true) }
scope :default_environment, -> { where(environment_scope: DEFAULT_ENVIRONMENT) }
@@ -103,29 +111,35 @@ module Clusters
scope :preload_knative, -> {
preload(
- :kubernetes_namespace,
+ :kubernetes_namespaces,
:platform_kubernetes,
:application_knative
)
}
def self.ancestor_clusters_for_clusterable(clusterable, hierarchy_order: :asc)
+ return [] if clusterable.is_a?(Instance)
+
hierarchy_groups = clusterable.ancestors_upto(hierarchy_order: hierarchy_order).eager_load(:clusters)
hierarchy_groups = hierarchy_groups.merge(current_scope) if current_scope
- hierarchy_groups.flat_map(&:clusters)
+ hierarchy_groups.flat_map(&:clusters) + Instance.new.clusters
end
def status_name
- if provider
- provider.status_name
- else
- :created
+ provider&.status_name || connection_status.presence || :created
+ end
+
+ def connection_status
+ with_reactive_cache do |data|
+ data[:connection_status]
end
end
- def created?
- status_name == :created
+ def calculate_reactive_cache
+ return unless enabled?
+
+ { connection_status: retrieve_connection_status }
end
def applications
@@ -148,10 +162,6 @@ module Clusters
return platform_kubernetes if kubernetes?
end
- def managed?
- !user?
- end
-
def all_projects
if project_type?
projects
@@ -176,20 +186,24 @@ module Clusters
end
alias_method :group, :first_group
+ def instance
+ Instance.new if instance_type?
+ end
+
def kubeclient
platform_kubernetes.kubeclient if kubernetes?
end
+ def kubernetes_namespace_for(project)
+ find_or_initialize_kubernetes_namespace_for_project(project).namespace
+ end
+
def find_or_initialize_kubernetes_namespace_for_project(project)
- if project_type?
- kubernetes_namespaces.find_or_initialize_by(
- project: project,
- cluster_project: cluster_project
- )
- else
- kubernetes_namespaces.find_or_initialize_by(
- project: project
- )
+ attributes = { project: project }
+ attributes[:cluster_project] = cluster_project if project_type?
+
+ kubernetes_namespaces.find_or_initialize_by(attributes).tap do |namespace|
+ namespace.set_defaults
end
end
@@ -198,7 +212,7 @@ module Clusters
end
def kube_ingress_domain
- @kube_ingress_domain ||= domain.presence || instance_domain || legacy_auto_devops_domain
+ @kube_ingress_domain ||= domain.presence || instance_domain
end
def predefined_variables
@@ -209,12 +223,43 @@ module Clusters
end
end
+ def knative_services_finder(project)
+ @knative_services_finder ||= KnativeServicesFinder.new(self, project)
+ end
+
private
def instance_domain
@instance_domain ||= Gitlab::CurrentSettings.auto_devops_domain
end
+ def retrieve_connection_status
+ kubeclient.core_client.discover
+ rescue *Gitlab::Kubernetes::Errors::CONNECTION
+ :unreachable
+ rescue *Gitlab::Kubernetes::Errors::AUTHENTICATION
+ :authentication_failure
+ rescue Kubeclient::HttpError => e
+ kubeclient_error_status(e.message)
+ rescue => e
+ Gitlab::Sentry.track_acceptable_exception(e, extra: { cluster_id: id })
+
+ :unknown_failure
+ else
+ :connected
+ end
+
+ # KubeClient uses the same error class
+ # For connection errors (eg. timeout) and
+ # for Kubernetes errors.
+ def kubeclient_error_status(message)
+ if message&.match?(/timed out|timeout/i)
+ :unreachable
+ else
+ :authentication_failure
+ end
+ end
+
# To keep backward compatibility with AUTO_DEVOPS_DOMAIN
# environment variable, we need to ensure KUBE_INGRESS_BASE_DOMAIN
# is set if AUTO_DEVOPS_DOMAIN is set on any of the following options:
diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb
index 683b45331f6..4514498b84b 100644
--- a/app/models/clusters/concerns/application_core.rb
+++ b/app/models/clusters/concerns/application_core.rb
@@ -18,6 +18,16 @@ module Clusters
self.status = 'installable' if cluster&.application_helm_available?
end
+ def can_uninstall?
+ allowed_to_uninstall?
+ end
+
+ # All new applications should uninstall by default
+ # Override if there's dependencies that needs to be uninstalled first
+ def allowed_to_uninstall?
+ true
+ end
+
def self.application_name
self.to_s.demodulize.underscore
end
@@ -30,6 +40,12 @@ module Clusters
# Override if you need extra data synchronized
# from K8s after installation
end
+
+ def update_command
+ install_command.tap do |command|
+ command.version = version
+ end
+ end
end
end
end
diff --git a/app/models/clusters/concerns/application_data.rb b/app/models/clusters/concerns/application_data.rb
index 52498f123ff..3479fea415e 100644
--- a/app/models/clusters/concerns/application_data.rb
+++ b/app/models/clusters/concerns/application_data.rb
@@ -3,48 +3,52 @@
module Clusters
module Concerns
module ApplicationData
- extend ActiveSupport::Concern
+ def uninstall_command
+ Gitlab::Kubernetes::Helm::DeleteCommand.new(
+ name: name,
+ rbac: cluster.platform_kubernetes_rbac?,
+ files: files
+ )
+ end
- included do
- def repository
- nil
- end
+ def repository
+ nil
+ end
- def values
- File.read(chart_values_file)
- end
+ def values
+ File.read(chart_values_file)
+ end
- def files
- @files ||= begin
- files = { 'values.yaml': values }
+ def files
+ @files ||= begin
+ files = { 'values.yaml': values }
- files.merge!(certificate_files) if cluster.application_helm.has_ssl?
+ files.merge!(certificate_files) if cluster.application_helm.has_ssl?
- files
- end
+ files
end
+ end
- private
+ private
- def certificate_files
- {
- 'ca.pem': ca_cert,
- 'cert.pem': helm_cert.cert_string,
- 'key.pem': helm_cert.key_string
- }
- end
+ def certificate_files
+ {
+ 'ca.pem': ca_cert,
+ 'cert.pem': helm_cert.cert_string,
+ 'key.pem': helm_cert.key_string
+ }
+ end
- def ca_cert
- cluster.application_helm.ca_cert
- end
+ def ca_cert
+ cluster.application_helm.ca_cert
+ end
- def helm_cert
- @helm_cert ||= cluster.application_helm.issue_client_cert
- end
+ def helm_cert
+ @helm_cert ||= cluster.application_helm.issue_client_cert
+ end
- def chart_values_file
- "#{Rails.root}/vendor/#{name}/values.yaml"
- end
+ def chart_values_file
+ "#{Rails.root}/vendor/#{name}/values.yaml"
end
end
end
diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb
index 1273ed83abe..54a3dda6d75 100644
--- a/app/models/clusters/concerns/application_status.rb
+++ b/app/models/clusters/concerns/application_status.rb
@@ -25,9 +25,11 @@ module Clusters
state :updating, value: 4
state :updated, value: 5
state :update_errored, value: 6
+ state :uninstalling, value: 7
+ state :uninstall_errored, value: 8
event :make_scheduled do
- transition [:installable, :errored, :installed, :updated, :update_errored] => :scheduled
+ transition [:installable, :errored, :installed, :updated, :update_errored, :uninstall_errored] => :scheduled
end
event :make_installing do
@@ -40,8 +42,9 @@ module Clusters
end
event :make_errored do
- transition any - [:updating] => :errored
+ transition any - [:updating, :uninstalling] => :errored
transition [:updating] => :update_errored
+ transition [:uninstalling] => :uninstall_errored
end
event :make_updating do
@@ -52,6 +55,10 @@ module Clusters
transition any => :update_errored
end
+ event :make_uninstalling do
+ transition [:scheduled] => :uninstalling
+ end
+
before_transition any => [:scheduled] do |app_status, _|
app_status.status_reason = nil
end
@@ -65,7 +72,7 @@ module Clusters
app_status.status_reason = nil
end
- before_transition any => [:update_errored] do |app_status, transition|
+ before_transition any => [:update_errored, :uninstall_errored] do |app_status, transition|
status_reason = transition.args.first
app_status.status_reason = status_reason if status_reason
end
diff --git a/app/models/clusters/group.rb b/app/models/clusters/group.rb
index 2b08a9e47f0..27f39b53579 100644
--- a/app/models/clusters/group.rb
+++ b/app/models/clusters/group.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Clusters
- class Group < ActiveRecord::Base
+ class Group < ApplicationRecord
self.table_name = 'cluster_groups'
belongs_to :cluster, class_name: 'Clusters::Cluster'
diff --git a/app/models/clusters/instance.rb b/app/models/clusters/instance.rb
new file mode 100644
index 00000000000..d8a888d53ba
--- /dev/null
+++ b/app/models/clusters/instance.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Clusters
+ class Instance
+ def clusters
+ Clusters::Cluster.instance_type
+ end
+
+ def feature_available?(feature)
+ ::Feature.enabled?(feature, default_enabled: true)
+ end
+
+ def self.enabled?
+ ::Feature.enabled?(:instance_clusters, default_enabled: true)
+ end
+ end
+end
diff --git a/app/models/clusters/kubernetes_namespace.rb b/app/models/clusters/kubernetes_namespace.rb
index 73da6cb37d7..b0c4900546e 100644
--- a/app/models/clusters/kubernetes_namespace.rb
+++ b/app/models/clusters/kubernetes_namespace.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Clusters
- class KubernetesNamespace < ActiveRecord::Base
+ class KubernetesNamespace < ApplicationRecord
include Gitlab::Kubernetes
self.table_name = 'clusters_kubernetes_namespaces'
@@ -37,7 +37,7 @@ module Clusters
variables
.append(key: 'KUBE_SERVICE_ACCOUNT', value: service_account_name.to_s)
.append(key: 'KUBE_NAMESPACE', value: namespace.to_s)
- .append(key: 'KUBE_TOKEN', value: service_account_token.to_s, public: false)
+ .append(key: 'KUBE_TOKEN', value: service_account_token.to_s, public: false, masked: true)
.append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true)
end
end
diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb
index 46d0898014e..9b951578aee 100644
--- a/app/models/clusters/platforms/kubernetes.rb
+++ b/app/models/clusters/platforms/kubernetes.rb
@@ -2,7 +2,7 @@
module Clusters
module Platforms
- class Kubernetes < ActiveRecord::Base
+ class Kubernetes < ApplicationRecord
include Gitlab::Kubernetes
include ReactiveCaching
include EnumWithNil
@@ -41,7 +41,7 @@ module Clusters
validate :no_namespace, unless: :allow_user_defined_namespace?
# We expect to be `active?` only when enabled and cluster is created (the api_url is assigned)
- validates :api_url, url: true, presence: true
+ validates :api_url, public_url: true, presence: true
validates :token, presence: true
validates :ca_cert, certificate: true, allow_blank: true, if: :ca_cert_changed?
@@ -52,11 +52,14 @@ module Clusters
alias_attribute :ca_pem, :ca_cert
- delegate :project, to: :cluster, allow_nil: true
delegate :enabled?, to: :cluster, allow_nil: true
- delegate :managed?, to: :cluster, allow_nil: true
+ delegate :provided_by_user?, to: :cluster, allow_nil: true
delegate :allow_user_defined_namespace?, to: :cluster, allow_nil: true
- delegate :kubernetes_namespace, to: :cluster
+
+ # This is just to maintain compatibility with KubernetesService, which
+ # will be removed in https://gitlab.com/gitlab-org/gitlab-ce/issues/39217.
+ # It can be removed once KubernetesService is gone.
+ delegate :kubernetes_namespace_for, to: :cluster, allow_nil: true
alias_method :active?, :enabled?
@@ -68,14 +71,6 @@ module Clusters
default_value_for :authorization_type, :rbac
- def actual_namespace
- if namespace.present?
- namespace
- else
- default_namespace
- end
- end
-
def predefined_variables(project:)
Gitlab::Ci::Variables::Collection.new.tap do |variables|
variables.append(key: 'KUBE_URL', value: api_url)
@@ -88,16 +83,19 @@ module Clusters
if kubernetes_namespace = cluster.kubernetes_namespaces.has_service_account_token.find_by(project: project)
variables.concat(kubernetes_namespace.predefined_variables)
- elsif cluster.project_type?
- # From 11.5, every Clusters::Project should have at least one
- # Clusters::KubernetesNamespace, so once migration has been completed,
- # this 'else' branch will be removed. For more information, please see
- # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22433
+ elsif cluster.project_type? || !cluster.managed?
+ # As of 11.11 a user can create a cluster that they manage themselves,
+ # which replicates the existing project-level cluster behaviour.
+ # Once we have marked all project-level clusters that make use of this
+ # behaviour as "unmanaged", we can remove the `cluster.project_type?`
+ # check here.
+ project_namespace = cluster.kubernetes_namespace_for(project)
+
variables
.append(key: 'KUBE_URL', value: api_url)
- .append(key: 'KUBE_TOKEN', value: token, public: false)
- .append(key: 'KUBE_NAMESPACE', value: actual_namespace)
- .append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true)
+ .append(key: 'KUBE_TOKEN', value: token, public: false, masked: true)
+ .append(key: 'KUBE_NAMESPACE', value: project_namespace)
+ .append(key: 'KUBECONFIG', value: kubeconfig(project_namespace), public: false, file: true)
end
variables.concat(cluster.predefined_variables)
@@ -110,8 +108,10 @@ module Clusters
# short time later
def terminals(environment)
with_reactive_cache do |data|
- pods = filter_by_label(data[:pods], app: environment.slug)
- terminals = pods.flat_map { |pod| terminals_for_pod(api_url, actual_namespace, pod) }.compact
+ project = environment.project
+
+ pods = filter_by_project_environment(data[:pods], project.full_path_slug, environment.slug)
+ terminals = pods.flat_map { |pod| terminals_for_pod(api_url, cluster.kubernetes_namespace_for(project), pod) }.compact
terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) }
end
end
@@ -119,7 +119,7 @@ module Clusters
# Caches resources in the namespace so other calls don't need to block on
# network access
def calculate_reactive_cache
- return unless enabled? && project && !project.pending_delete?
+ return unless enabled?
# We may want to cache extra things in the future
{ pods: read_pods }
@@ -131,33 +131,16 @@ module Clusters
private
- def kubeconfig
+ def kubeconfig(namespace)
to_kubeconfig(
url: api_url,
- namespace: actual_namespace,
+ namespace: namespace,
token: token,
ca_pem: ca_pem)
end
- def default_namespace
- kubernetes_namespace&.namespace.presence || fallback_default_namespace
- end
-
- # DEPRECATED
- #
- # On 11.4 Clusters::KubernetesNamespace was introduced, this model will allow to
- # have multiple namespaces per project. This method will be removed after migration
- # has been completed.
- def fallback_default_namespace
- return unless project
-
- slug = "#{project.path}-#{project.id}".downcase
- Gitlab::NamespaceSanitizer.sanitize(slug)
- end
-
def build_kube_client!
raise "Incomplete settings" unless api_url
- raise "No namespace" if cluster.project_type? && actual_namespace.empty? # can probably remove this line once we remove #actual_namespace
unless (username && password) || token
raise "Either username/password or token is required to access API"
@@ -173,9 +156,13 @@ module Clusters
# Returns a hash of all pods in the namespace
def read_pods
- kubeclient = build_kube_client!
+ # TODO: The project lookup here should be moved (to environment?),
+ # which will enable reading pods from the correct namespace for group
+ # and instance clusters.
+ # This will be done in https://gitlab.com/gitlab-org/gitlab-ce/issues/61156
+ return [] unless cluster.project_type?
- kubeclient.get_pods(namespace: actual_namespace).as_json
+ kubeclient.get_pods(namespace: cluster.kubernetes_namespace_for(cluster.first_project)).as_json
rescue Kubeclient::ResourceNotFoundError
[]
end
@@ -219,7 +206,7 @@ module Clusters
end
def prevent_modification
- return unless managed?
+ return if provided_by_user?
if api_url_changed? || token_changed? || ca_pem_changed?
errors.add(:base, _('Cannot modify managed Kubernetes cluster'))
@@ -230,7 +217,7 @@ module Clusters
end
def update_kubernetes_namespace
- return unless namespace_changed?
+ return unless saved_change_to_namespace?
run_after_commit do
ClusterConfigureWorker.perform_async(cluster_id)
diff --git a/app/models/clusters/project.rb b/app/models/clusters/project.rb
index 15092b1c9d2..e0bf60164ba 100644
--- a/app/models/clusters/project.rb
+++ b/app/models/clusters/project.rb
@@ -1,13 +1,12 @@
# frozen_string_literal: true
module Clusters
- class Project < ActiveRecord::Base
+ class Project < ApplicationRecord
self.table_name = 'cluster_projects'
belongs_to :cluster, class_name: 'Clusters::Cluster'
belongs_to :project, class_name: '::Project'
has_many :kubernetes_namespaces, class_name: 'Clusters::KubernetesNamespace', foreign_key: :cluster_project_id
- has_one :kubernetes_namespace, -> { order(id: :desc) }, class_name: 'Clusters::KubernetesNamespace', foreign_key: :cluster_project_id
end
end
diff --git a/app/models/clusters/providers/gcp.rb b/app/models/clusters/providers/gcp.rb
index 16b59cd9d14..390748bf252 100644
--- a/app/models/clusters/providers/gcp.rb
+++ b/app/models/clusters/providers/gcp.rb
@@ -2,7 +2,7 @@
module Clusters
module Providers
- class Gcp < ActiveRecord::Base
+ class Gcp < ApplicationRecord
self.table_name = 'cluster_providers_gcp'
belongs_to :cluster, inverse_of: :provider_gcp, class_name: 'Clusters::Cluster'
diff --git a/app/models/commit.rb b/app/models/commit.rb
index f412d252e5c..fa0bf36ba49 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -13,6 +13,7 @@ class Commit
include StaticModel
include Presentable
include ::Gitlab::Utils::StrongMemoize
+ include CacheMarkdownField
attr_mentionable :safe_message, pipeline: :single_line
@@ -37,13 +38,9 @@ class Commit
# Used by GFM to match and present link extensions on node texts and hrefs.
LINK_EXTENSION_PATTERN = /(patch)/.freeze
- def banzai_render_context(field)
- pipeline = field == :description ? :commit_description : :single_line
- context = { pipeline: pipeline, project: self.project }
- context[:author] = self.author if self.author
-
- context
- end
+ cache_markdown_field :title, pipeline: :single_line
+ cache_markdown_field :full_title, pipeline: :single_line
+ cache_markdown_field :description, pipeline: :commit_description
class << self
def decorate(commits, project)
diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb
index a9a2e9c81eb..e8df46e1cc3 100644
--- a/app/models/commit_collection.rb
+++ b/app/models/commit_collection.rb
@@ -20,18 +20,51 @@ class CommitCollection
commits.each(&block)
end
- def authors
- emails = without_merge_commits.map(&:author_email).uniq
+ def committers
+ emails = without_merge_commits.map(&:committer_email).uniq
User.by_any_email(emails)
end
def without_merge_commits
strong_memoize(:without_merge_commits) do
- commits.reject(&:merge_commit?)
+ # `#enrich!` the collection to ensure all commits contain
+ # the necessary parent data
+ enrich!.commits.reject(&:merge_commit?)
end
end
+ def unenriched
+ commits.reject(&:gitaly_commit?)
+ end
+
+ def fully_enriched?
+ unenriched.empty?
+ end
+
+ # Batch load any commits that are not backed by full gitaly data, and
+ # replace them in the collection.
+ def enrich!
+ # A project is needed in order to fetch data from gitaly. Projects
+ # can be absent from commits in certain rare situations (like when
+ # viewing a MR of a deleted fork). In these cases, assume that the
+ # enriched data is not needed.
+ return self if project.blank? || fully_enriched?
+
+ # Batch load full Commits from the repository
+ # and map to a Hash of id => Commit
+ replacements = Hash[unenriched.map do |c|
+ [c.id, Commit.lazy(project, c.id)]
+ end.compact]
+
+ # Replace the commits, keeping the same order
+ @commits = @commits.map do |c|
+ replacements.fetch(c.id, c)
+ end
+
+ self
+ end
+
# Sets the pipeline status for every commit.
#
# Setting this status ahead of time removes the need for running a query for
diff --git a/app/models/commit_range.rb b/app/models/commit_range.rb
index 094747ee48d..08ca86bc902 100644
--- a/app/models/commit_range.rb
+++ b/app/models/commit_range.rb
@@ -28,12 +28,12 @@ class CommitRange
# The beginning and ending refs can be named or SHAs, and
# the range notation can be double- or triple-dot.
- REF_PATTERN = /[0-9a-zA-Z][0-9a-zA-Z_.-]*[0-9a-zA-Z\^]/
- PATTERN = /#{REF_PATTERN}\.{2,3}#{REF_PATTERN}/
+ REF_PATTERN = /[0-9a-zA-Z][0-9a-zA-Z_.-]*[0-9a-zA-Z\^]/.freeze
+ PATTERN = /#{REF_PATTERN}\.{2,3}#{REF_PATTERN}/.freeze
# In text references, the beginning and ending refs can only be SHAs
# between 7 and 40 hex characters.
- STRICT_PATTERN = /\h{7,40}\.{2,3}\h{7,40}/
+ STRICT_PATTERN = /\h{7,40}\.{2,3}\h{7,40}/.freeze
def self.reference_prefix
'@'
@@ -134,25 +134,25 @@ class CommitRange
end
def sha_from
- return nil unless @commit_from
+ return unless @commit_from
@commit_from.id
end
def sha_to
- return nil unless @commit_to
+ return unless @commit_to
@commit_to.id
end
def sha_start
- return nil unless sha_from
+ return unless sha_from
exclude_start? ? sha_from + '^' : sha_from
end
def commit_start
- return nil unless sha_start
+ return unless sha_start
if exclude_start?
@commit_start ||= project.commit(sha_start)
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 7f6562b63e5..be6f3e9c5b0 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class CommitStatus < ActiveRecord::Base
+class CommitStatus < ApplicationRecord
include HasStatus
include Importable
include AfterCommitQueue
@@ -66,7 +66,10 @@ class CommitStatus < ActiveRecord::Base
end
event :enqueue do
- transition [:created, :skipped, :manual, :scheduled] => :pending
+ # A CommitStatus will never have prerequisites, but this event
+ # is shared by Ci::Build, which cannot progress unless prerequisites
+ # are satisfied.
+ transition [:created, :preparing, :skipped, :manual, :scheduled] => :pending, unless: :any_unmet_prerequisites?
end
event :run do
@@ -74,26 +77,26 @@ class CommitStatus < ActiveRecord::Base
end
event :skip do
- transition [:created, :pending] => :skipped
+ transition [:created, :preparing, :pending] => :skipped
end
event :drop do
- transition [:created, :pending, :running, :scheduled] => :failed
+ transition [:created, :preparing, :pending, :running, :scheduled] => :failed
end
event :success do
- transition [:created, :pending, :running] => :success
+ transition [:created, :preparing, :pending, :running] => :success
end
event :cancel do
- transition [:created, :pending, :running, :manual, :scheduled] => :canceled
+ transition [:created, :preparing, :pending, :running, :manual, :scheduled] => :canceled
end
- before_transition [:created, :skipped, :manual, :scheduled] => :pending do |commit_status|
+ before_transition [:created, :preparing, :skipped, :manual, :scheduled] => :pending do |commit_status|
commit_status.queued_at = Time.now
end
- before_transition [:created, :pending] => :running do |commit_status|
+ before_transition [:created, :preparing, :pending] => :running do |commit_status|
commit_status.started_at = Time.now
end
@@ -137,7 +140,7 @@ class CommitStatus < ActiveRecord::Base
end
def locking_enabled?
- status_changed?
+ will_save_change_to_status?
end
def before_sha
@@ -180,6 +183,10 @@ class CommitStatus < ActiveRecord::Base
false
end
+ def any_unmet_prerequisites?
+ false
+ end
+
def auto_canceled?
canceled? && auto_canceled_by_id?
end
diff --git a/app/models/commit_status_enums.rb b/app/models/commit_status_enums.rb
index 152105d9429..45e08fa18fe 100644
--- a/app/models/commit_status_enums.rb
+++ b/app/models/commit_status_enums.rb
@@ -14,7 +14,8 @@ module CommitStatusEnums
runner_unsupported: 6,
stale_schedule: 7,
job_execution_timeout: 8,
- archived_failure: 9
+ archived_failure: 9,
+ unmet_prerequisites: 10
}
end
end
diff --git a/app/models/concerns/artifact_migratable.rb b/app/models/concerns/artifact_migratable.rb
deleted file mode 100644
index cbd63ba8876..00000000000
--- a/app/models/concerns/artifact_migratable.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-
-# Adapter class to unify the interface between mounted uploaders and the
-# Ci::Artifact model
-# Meant to be prepended so the interface can stay the same
-module ArtifactMigratable
- def artifacts_file
- job_artifacts_archive&.file || legacy_artifacts_file
- end
-
- def artifacts_metadata
- job_artifacts_metadata&.file || legacy_artifacts_metadata
- end
-
- def artifacts?
- !artifacts_expired? && artifacts_file.exists?
- end
-
- def artifacts_metadata?
- artifacts? && artifacts_metadata.exists?
- end
-
- def artifacts_file_changed?
- job_artifacts_archive&.file_changed? || attribute_changed?(:artifacts_file)
- end
-
- def remove_artifacts_file!
- if job_artifacts_archive
- job_artifacts_archive.destroy
- else
- remove_legacy_artifacts_file!
- end
- end
-
- def remove_artifacts_metadata!
- if job_artifacts_metadata
- job_artifacts_metadata.destroy
- else
- remove_legacy_artifacts_metadata!
- end
- end
-
- def artifacts_size
- read_attribute(:artifacts_size).to_i + job_artifacts.sum(:size).to_i
- end
-end
diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb
index 4e15b60ccd1..dc1735a7e48 100644
--- a/app/models/concerns/atomic_internal_id.rb
+++ b/app/models/concerns/atomic_internal_id.rb
@@ -7,7 +7,7 @@
#
# For example, let's generate internal ids for Issue per Project:
# ```
-# class Issue < ActiveRecord::Base
+# class Issue < ApplicationRecord
# has_internal_id :iid, scope: :project, init: ->(s) { s.project.issues.maximum(:iid) }
# end
# ```
@@ -53,6 +53,20 @@ module AtomicInternalId
value
end
+
+ define_method("reset_#{scope}_#{column}") do
+ if value = read_attribute(column)
+ scope_value = association(scope).reader
+ scope_attrs = { scope_value.class.table_name.singularize.to_sym => scope_value }
+ usage = self.class.table_name.to_sym
+
+ if InternalId.reset(self, scope_attrs, usage, value)
+ write_attribute(column, nil)
+ end
+ end
+
+ read_attribute(column)
+ end
end
end
end
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb
index 4687ec7d166..80278e07e65 100644
--- a/app/models/concerns/avatarable.rb
+++ b/app/models/concerns/avatarable.rb
@@ -91,7 +91,8 @@ module Avatarable
private
def retrieve_upload_from_batch(identifier)
- BatchLoader.for(identifier: identifier, model: self).batch(key: self.class) do |upload_params, loader, args|
+ BatchLoader.for(identifier: identifier, model: self)
+ .batch(key: self.class, cache: true, replace_methods: false) do |upload_params, loader, args|
model_class = args[:key]
paths = upload_params.flat_map do |params|
params[:model].upload_paths(params[:identifier])
diff --git a/app/models/concerns/blob_language_from_git_attributes.rb b/app/models/concerns/blob_language_from_git_attributes.rb
index 70213d22147..56e1276a220 100644
--- a/app/models/concerns/blob_language_from_git_attributes.rb
+++ b/app/models/concerns/blob_language_from_git_attributes.rb
@@ -5,7 +5,7 @@ module BlobLanguageFromGitAttributes
extend ActiveSupport::Concern
def language_from_gitattributes
- return nil unless project
+ return unless project
repository = project.repository
repository.gitattribute(path, 'gitlab-language')
diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb
index 1a8570b80c3..42203a5f214 100644
--- a/app/models/concerns/cache_markdown_field.rb
+++ b/app/models/concerns/cache_markdown_field.rb
@@ -7,40 +7,15 @@
# cache_markdown_field :foo
# cache_markdown_field :bar
# cache_markdown_field :baz, pipeline: :single_line
+# cache_markdown_field :baz, whitelisted: true
#
# Corresponding foo_html, bar_html and baz_html fields should exist.
module CacheMarkdownField
extend ActiveSupport::Concern
- # Increment this number every time the renderer changes its output
- CACHE_COMMONMARK_VERSION_START = 10
- CACHE_COMMONMARK_VERSION = 14
-
# changes to these attributes cause the cache to be invalidates
INVALIDATED_BY = %w[author project].freeze
- # Knows about the relationship between markdown and html field names, and
- # stores the rendering contexts for the latter
- class FieldData
- def initialize
- @data = {}
- end
-
- delegate :[], :[]=, to: :@data
-
- def markdown_fields
- @data.keys
- end
-
- def html_field(markdown_field)
- "#{markdown_field}_html"
- end
-
- def html_fields
- markdown_fields.map {|field| html_field(field) }
- end
- end
-
def skip_project_check?
false
end
@@ -76,24 +51,22 @@ module CacheMarkdownField
end.to_h
updates['cached_markdown_version'] = latest_cached_markdown_version
- updates.each {|html_field, data| write_attribute(html_field, data) }
+ updates.each { |field, data| write_markdown_field(field, data) }
end
def refresh_markdown_cache!
updates = refresh_markdown_cache
- return unless persisted? && Gitlab::Database.read_write?
-
- update_columns(updates)
+ save_markdown(updates)
end
def cached_html_up_to_date?(markdown_field)
- html_field = cached_markdown_fields.html_field(markdown_field)
+ return false if cached_html_for(markdown_field).nil? && __send__(markdown_field).present? # rubocop:disable GitlabSecurity/PublicSend
- return false if cached_html_for(markdown_field).nil? && !__send__(markdown_field).nil? # rubocop:disable GitlabSecurity/PublicSend
+ html_field = cached_markdown_fields.html_field(markdown_field)
- markdown_changed = attribute_changed?(markdown_field) || false
- html_changed = attribute_changed?(html_field) || false
+ markdown_changed = markdown_field_changed?(markdown_field)
+ html_changed = markdown_field_changed?(html_field)
latest_cached_markdown_version == cached_markdown_version &&
(html_changed || markdown_changed == html_changed)
@@ -108,21 +81,21 @@ module CacheMarkdownField
end
def cached_html_for(markdown_field)
- raise ArgumentError.new("Unknown field: #{field}") unless
+ raise ArgumentError.new("Unknown field: #{markdown_field}") unless
cached_markdown_fields.markdown_fields.include?(markdown_field)
__send__(cached_markdown_fields.html_field(markdown_field)) # rubocop:disable GitlabSecurity/PublicSend
end
def latest_cached_markdown_version
- @latest_cached_markdown_version ||= (CacheMarkdownField::CACHE_COMMONMARK_VERSION << 16) | local_version
+ @latest_cached_markdown_version ||= (Gitlab::MarkdownCache::CACHE_COMMONMARK_VERSION << 16) | local_version
end
def local_version
# because local_markdown_version is stored in application_settings which
# uses cached_markdown_version too, we check explicitly to avoid
# endless loop
- return local_markdown_version if has_attribute?(:local_markdown_version)
+ return local_markdown_version if respond_to?(:has_attribute?) && has_attribute?(:local_markdown_version)
settings = Gitlab::CurrentSettings.current_application_settings
@@ -141,27 +114,14 @@ module CacheMarkdownField
included do
cattr_reader :cached_markdown_fields do
- FieldData.new
+ Gitlab::MarkdownCache::FieldData.new
end
- # Always exclude _html fields from attributes (including serialization).
- # They contain unredacted HTML, which would be a security issue
- alias_method :attributes_before_markdown_cache, :attributes
- def attributes
- attrs = attributes_before_markdown_cache
-
- attrs.delete('cached_markdown_version')
-
- cached_markdown_fields.html_fields.each do |field|
- attrs.delete(field)
- end
-
- attrs
+ if self < ActiveRecord::Base
+ include Gitlab::MarkdownCache::ActiveRecord::Extension
+ else
+ prepend Gitlab::MarkdownCache::Redis::Extension
end
-
- # Using before_update here conflicts with elasticsearch-model somehow
- before_create :refresh_markdown_cache, if: :invalidated_markdown_cache?
- before_update :refresh_markdown_cache, if: :invalidated_markdown_cache?
end
class_methods do
@@ -179,10 +139,8 @@ module CacheMarkdownField
# The HTML becomes invalid if any dependent fields change. For now, assume
# author and project invalidate the cache in all circumstances.
define_method(invalidation_method) do
- changed_fields = changed_attributes.keys
- invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY]
- invalidations.delete(markdown_field.to_s) if changed_fields.include?("#{markdown_field}_html")
-
+ invalidations = changed_markdown_fields & [markdown_field.to_s, *INVALIDATED_BY]
+ invalidations.delete(markdown_field.to_s) if changed_markdown_fields.include?("#{markdown_field}_html")
!invalidations.empty? || !cached_html_up_to_date?(markdown_field)
end
end
diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb
new file mode 100644
index 00000000000..e1d5ce7f7d4
--- /dev/null
+++ b/app/models/concerns/ci/contextable.rb
@@ -0,0 +1,108 @@
+# frozen_string_literal: true
+
+module Ci
+ ##
+ # This module implements methods that provide context in form of
+ # essential CI/CD variables that can be used by a build / bridge job.
+ #
+ module Contextable
+ ##
+ # Variables in the environment name scope.
+ #
+ def scoped_variables(environment: expanded_environment_name)
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ variables.concat(predefined_variables)
+ 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(yaml_variables)
+ variables.concat(user_variables)
+ variables.concat(secret_group_variables)
+ variables.concat(secret_project_variables(environment: environment))
+ variables.concat(trigger_request.user_variables) if trigger_request
+ variables.concat(pipeline.variables)
+ variables.concat(pipeline.pipeline_schedule.job_variables) if pipeline.pipeline_schedule
+ end
+ end
+
+ ##
+ # Regular Ruby hash of scoped variables, without duplicates that are
+ # possible to be present in an array of hashes returned from `variables`.
+ #
+ def scoped_variables_hash
+ scoped_variables.to_hash
+ end
+
+ ##
+ # Variables that do not depend on the environment name.
+ #
+ def simple_variables
+ strong_memoize(:simple_variables) do
+ scoped_variables(environment: nil).to_runner_variables
+ end
+ end
+
+ def user_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ break variables if user.blank?
+
+ variables.append(key: 'GITLAB_USER_ID', value: user.id.to_s)
+ variables.append(key: 'GITLAB_USER_EMAIL', value: user.email)
+ variables.append(key: 'GITLAB_USER_LOGIN', value: user.username)
+ variables.append(key: 'GITLAB_USER_NAME', value: user.name)
+ end
+ end
+
+ def predefined_variables # rubocop:disable Metrics/AbcSize
+ 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_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.concat(legacy_variables)
+ end
+ end
+
+ 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_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?
+ end
+ end
+
+ def secret_group_variables
+ return [] unless project.group
+
+ project.group.ci_variables_for(git_ref, project)
+ end
+
+ def secret_project_variables(environment: persisted_environment)
+ project.ci_variables_for(ref: git_ref, environment: environment)
+ end
+ end
+end
diff --git a/app/models/concerns/ci/pipeline_delegator.rb b/app/models/concerns/ci/pipeline_delegator.rb
new file mode 100644
index 00000000000..dbc5ed1bc9a
--- /dev/null
+++ b/app/models/concerns/ci/pipeline_delegator.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+##
+# This module is mainly used by child associations of `Ci::Pipeline` that needs to look up
+# single source of truth. For example, `Ci::Build` has `git_ref` method, which behaves
+# slightly different from `Ci::Pipeline`'s `git_ref`. This is very confusing as
+# the system could behave differently time to time.
+# We should have a single interface in `Ci::Pipeline` and access the method always.
+module Ci
+ module PipelineDelegator
+ extend ActiveSupport::Concern
+
+ included do
+ delegate :merge_request_event?,
+ :merge_request_ref?,
+ :source_ref,
+ :source_ref_slug,
+ :legacy_detached_merge_request_pipeline?, to: :pipeline
+ end
+ end
+end
diff --git a/app/models/concerns/ci/processable.rb b/app/models/concerns/ci/processable.rb
index 1c78b1413a8..268fa8ec692 100644
--- a/app/models/concerns/ci/processable.rb
+++ b/app/models/concerns/ci/processable.rb
@@ -23,5 +23,9 @@ module Ci
def expanded_environment_name
raise NotImplementedError
end
+
+ def scoped_variables_hash
+ raise NotImplementedError
+ end
end
end
diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb
index 0107af5f8ec..9ac0d612db3 100644
--- a/app/models/concerns/deployment_platform.rb
+++ b/app/models/concerns/deployment_platform.rb
@@ -14,6 +14,7 @@ module DeploymentPlatform
def find_deployment_platform(environment)
find_cluster_platform_kubernetes(environment: environment) ||
find_group_cluster_platform_kubernetes_with_feature_guard(environment: environment) ||
+ find_instance_cluster_platform_kubernetes_with_feature_guard(environment: environment) ||
find_kubernetes_service_integration ||
build_cluster_and_deployment_platform
end
@@ -36,6 +37,18 @@ module DeploymentPlatform
.first&.platform_kubernetes
end
+ def find_instance_cluster_platform_kubernetes_with_feature_guard(environment: nil)
+ return unless Clusters::Instance.enabled?
+
+ find_instance_cluster_platform_kubernetes(environment: environment)
+ end
+
+ # EE would override this and utilize environment argument
+ def find_instance_cluster_platform_kubernetes(environment: nil)
+ Clusters::Instance.new.clusters.enabled.default_environment
+ .first&.platform_kubernetes
+ end
+
def find_kubernetes_service_integration
services.deployment.reorder(nil).find_by(active: true)
end
diff --git a/app/models/concerns/deprecated_assignee.rb b/app/models/concerns/deprecated_assignee.rb
new file mode 100644
index 00000000000..7f12ce39c96
--- /dev/null
+++ b/app/models/concerns/deprecated_assignee.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+# This module handles backward compatibility for import/export of Merge Requests after
+# multiple assignees feature was introduced. Also, it handles the scenarios where
+# the #26496 background migration hasn't finished yet.
+# Ideally, most of this code should be removed at #59457.
+module DeprecatedAssignee
+ extend ActiveSupport::Concern
+
+ def assignee_ids=(ids)
+ nullify_deprecated_assignee
+ super
+ end
+
+ def assignees=(users)
+ nullify_deprecated_assignee
+ super
+ end
+
+ def assignee_id=(id)
+ self.assignee_ids = Array(id)
+ end
+
+ def assignee=(user)
+ self.assignees = Array(user)
+ end
+
+ def assignee
+ assignees.first
+ end
+
+ def assignee_id
+ assignee_ids.first
+ end
+
+ def assignee_ids
+ if Gitlab::Database.read_only? && pending_assignees_population?
+ return Array(deprecated_assignee_id)
+ end
+
+ update_assignees_relation
+ super
+ end
+
+ def assignees
+ if Gitlab::Database.read_only? && pending_assignees_population?
+ return User.where(id: deprecated_assignee_id)
+ end
+
+ update_assignees_relation
+ super
+ end
+
+ private
+
+ # This will make the background migration process quicker (#26496) as it'll have less
+ # assignee_id rows to look through.
+ def nullify_deprecated_assignee
+ return unless persisted? && Gitlab::Database.read_only?
+
+ update_column(:assignee_id, nil)
+ end
+
+ # This code should be removed in the clean-up phase of the
+ # background migration (#59457).
+ def pending_assignees_population?
+ persisted? && deprecated_assignee_id && merge_request_assignees.empty?
+ end
+
+ # If there's an assignee_id and no relation, it means the background
+ # migration at #26496 didn't reach this merge request yet.
+ # This code should be removed in the clean-up phase of the
+ # background migration (#59457).
+ def update_assignees_relation
+ if pending_assignees_population?
+ transaction do
+ merge_request_assignees.create!(user_id: deprecated_assignee_id, merge_request_id: id)
+ update_column(:assignee_id, nil)
+ end
+ end
+ end
+
+ def deprecated_assignee_id
+ read_attribute(:assignee_id)
+ end
+end
diff --git a/app/models/concerns/feature_gate.rb b/app/models/concerns/feature_gate.rb
index 3f84de54ad5..bb095f113e2 100644
--- a/app/models/concerns/feature_gate.rb
+++ b/app/models/concerns/feature_gate.rb
@@ -2,7 +2,7 @@
module FeatureGate
def flipper_id
- return nil if new_record?
+ return if new_record?
"#{self.class.name}:#{id}"
end
diff --git a/app/models/concerns/group_descendant.rb b/app/models/concerns/group_descendant.rb
index 05cd4265133..cfffd845e43 100644
--- a/app/models/concerns/group_descendant.rb
+++ b/app/models/concerns/group_descendant.rb
@@ -22,7 +22,7 @@ module GroupDescendant
return [] if descendants.empty?
unless descendants.all? { |hierarchy| hierarchy.is_a?(GroupDescendant) }
- raise ArgumentError.new('element is not a hierarchy')
+ raise ArgumentError.new(_('element is not a hierarchy'))
end
all_hierarchies = descendants.map do |descendant|
@@ -56,7 +56,7 @@ module GroupDescendant
end
if parent.nil? && hierarchy_top.present?
- raise ArgumentError.new('specified top is not part of the tree')
+ raise ArgumentError.new(_('specified top is not part of the tree'))
end
if parent && parent != hierarchy_top
diff --git a/app/models/concerns/has_ref.rb b/app/models/concerns/has_ref.rb
index d7089294efc..fa0cf5ddfd2 100644
--- a/app/models/concerns/has_ref.rb
+++ b/app/models/concerns/has_ref.rb
@@ -1,10 +1,13 @@
# frozen_string_literal: true
+##
+# We will disable `ref` and `sha` attributes in `Ci::Build` in the future
+# and remove this module in favor of Ci::PipelineDelegator.
module HasRef
extend ActiveSupport::Concern
def branch?
- !tag?
+ !tag? && !merge_request_event?
end
def git_ref
@@ -14,4 +17,15 @@ module HasRef
Gitlab::Git::TAG_REF_PREFIX + ref.to_s
end
end
+
+ # A slugified version of the build ref, suitable for inclusion in URLs and
+ # domain names. Rules:
+ #
+ # * Lowercased
+ # * Anything not matching [a-z0-9-] is replaced with a -
+ # * Maximum length is 63 bytes
+ # * First/Last Character is not a hyphen
+ def ref_slug
+ Gitlab::Utils.slugify(ref.to_s)
+ end
end
diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb
index 0d2be4c61ab..78bcce2f592 100644
--- a/app/models/concerns/has_status.rb
+++ b/app/models/concerns/has_status.rb
@@ -5,14 +5,14 @@ module HasStatus
DEFAULT_STATUS = 'created'.freeze
BLOCKED_STATUS = %w[manual scheduled].freeze
- AVAILABLE_STATUSES = %w[created pending running success failed canceled skipped manual scheduled].freeze
+ AVAILABLE_STATUSES = %w[created preparing pending running success failed canceled skipped manual scheduled].freeze
STARTED_STATUSES = %w[running success failed skipped manual scheduled].freeze
- ACTIVE_STATUSES = %w[pending running].freeze
+ ACTIVE_STATUSES = %w[preparing pending running].freeze
COMPLETED_STATUSES = %w[success failed canceled skipped].freeze
- ORDERED_STATUSES = %w[failed pending running manual scheduled canceled success skipped created].freeze
+ ORDERED_STATUSES = %w[failed preparing pending running manual scheduled canceled success skipped created].freeze
STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3,
failed: 4, canceled: 5, skipped: 6, manual: 7,
- scheduled: 8 }.freeze
+ scheduled: 8, preparing: 9 }.freeze
UnknownStatusError = Class.new(StandardError)
@@ -26,6 +26,7 @@ module HasStatus
success = scope_relevant.success.select('count(*)').to_sql
manual = scope_relevant.manual.select('count(*)').to_sql
scheduled = scope_relevant.scheduled.select('count(*)').to_sql
+ preparing = scope_relevant.preparing.select('count(*)').to_sql
pending = scope_relevant.pending.select('count(*)').to_sql
running = scope_relevant.running.select('count(*)').to_sql
skipped = scope_relevant.skipped.select('count(*)').to_sql
@@ -37,12 +38,14 @@ module HasStatus
WHEN (#{builds})=(#{skipped}) THEN 'skipped'
WHEN (#{builds})=(#{success}) THEN 'success'
WHEN (#{builds})=(#{created}) THEN 'created'
+ WHEN (#{builds})=(#{preparing}) THEN 'preparing'
WHEN (#{builds})=(#{success})+(#{skipped}) THEN 'success'
WHEN (#{builds})=(#{success})+(#{skipped})+(#{canceled}) THEN 'canceled'
WHEN (#{builds})=(#{created})+(#{skipped})+(#{pending}) THEN 'pending'
WHEN (#{running})+(#{pending})>0 THEN 'running'
WHEN (#{manual})>0 THEN 'manual'
WHEN (#{scheduled})>0 THEN 'scheduled'
+ WHEN (#{preparing})>0 THEN 'preparing'
WHEN (#{created})>0 THEN 'running'
ELSE 'failed'
END)"
@@ -63,6 +66,10 @@ module HasStatus
def all_state_names
state_machines.values.flat_map(&:states).flat_map { |s| s.map(&:name) }
end
+
+ def completed_statuses
+ COMPLETED_STATUSES.map(&:to_sym)
+ end
end
included do
@@ -70,6 +77,7 @@ module HasStatus
state_machine :status, initial: :created do
state :created, value: 'created'
+ state :preparing, value: 'preparing'
state :pending, value: 'pending'
state :running, value: 'running'
state :failed, value: 'failed'
@@ -81,6 +89,7 @@ module HasStatus
end
scope :created, -> { where(status: 'created') }
+ scope :preparing, -> { where(status: 'preparing') }
scope :relevant, -> { where(status: AVAILABLE_STATUSES - ['created']) }
scope :running, -> { where(status: 'running') }
scope :pending, -> { where(status: 'pending') }
@@ -90,14 +99,14 @@ module HasStatus
scope :skipped, -> { where(status: 'skipped') }
scope :manual, -> { where(status: 'manual') }
scope :scheduled, -> { where(status: 'scheduled') }
- scope :alive, -> { where(status: [:created, :pending, :running]) }
+ scope :alive, -> { where(status: [:created, :preparing, :pending, :running]) }
scope :created_or_pending, -> { where(status: [:created, :pending]) }
scope :running_or_pending, -> { where(status: [:running, :pending]) }
scope :finished, -> { where(status: [:success, :failed, :canceled]) }
scope :failed_or_canceled, -> { where(status: [:failed, :canceled]) }
scope :cancelable, -> do
- where(status: [:running, :pending, :created, :scheduled])
+ where(status: [:running, :preparing, :pending, :created, :scheduled])
end
end
diff --git a/app/models/concerns/has_variable.rb b/app/models/concerns/has_variable.rb
index dfbe413a878..b4e99569071 100644
--- a/app/models/concerns/has_variable.rb
+++ b/app/models/concerns/has_variable.rb
@@ -4,6 +4,11 @@ module HasVariable
extend ActiveSupport::Concern
included do
+ enum variable_type: {
+ env_var: 1,
+ file: 2
+ }
+
validates :key,
presence: true,
length: { maximum: 255 },
@@ -21,9 +26,9 @@ module HasVariable
def key=(new_key)
super(new_key.to_s.strip)
end
+ end
- def to_runner_variable
- { key: key, value: value, public: false }
- end
+ def to_runner_variable
+ { key: key, value: value, public: false, file: file? }
end
end
diff --git a/app/models/concerns/ignorable_column.rb b/app/models/concerns/ignorable_column.rb
index 5c1f7dfcd2a..3bec44dc79b 100644
--- a/app/models/concerns/ignorable_column.rb
+++ b/app/models/concerns/ignorable_column.rb
@@ -5,7 +5,7 @@
#
# Example:
#
-# class User < ActiveRecord::Base
+# class User < ApplicationRecord
# include IgnorableColumn
#
# ignore_column :updated_at
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 429a63f83cc..127430cc68f 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -23,12 +23,13 @@ module Issuable
include Sortable
include CreatedAtFilterable
include UpdatedAtFilterable
+ include IssuableStates
include ClosedAtFilterable
# This object is used to gather issuable meta data for displaying
# upvotes, downvotes, notes and closing merge requests count for issues and merge requests
# lists avoiding n+1 queries and improving performance.
- IssuableMeta = Struct.new(:upvotes, :downvotes, :notes_count, :merge_requests_count)
+ IssuableMeta = Struct.new(:upvotes, :downvotes, :user_notes_count, :merge_requests_count)
included do
cache_markdown_field :title, pipeline: :single_line
@@ -36,8 +37,8 @@ module Issuable
redact_field :description
- belongs_to :author, class_name: "User"
- belongs_to :updated_by, class_name: "User"
+ belongs_to :author, class_name: 'User'
+ belongs_to :updated_by, class_name: 'User'
belongs_to :last_edited_by, class_name: 'User'
belongs_to :milestone
@@ -66,15 +67,9 @@ module Issuable
allow_nil: true,
prefix: true
- delegate :name,
- :email,
- :public_email,
- to: :assignee,
- allow_nil: true,
- prefix: true
-
validates :author, presence: true
validates :title, presence: true, length: { maximum: 255 }
+ validate :milestone_is_valid
scope :authored, ->(user) { where(author_id: user) }
scope :recent, -> { reorder(id: :desc) }
@@ -86,6 +81,19 @@ module Issuable
scope :only_opened, -> { with_state(:opened) }
scope :closed, -> { with_state(:closed) }
+ # rubocop:disable GitlabSecurity/SqlInjection
+ # The `to_ability_name` method is not an user input.
+ scope :assigned, -> do
+ where("EXISTS (SELECT TRUE FROM #{to_ability_name}_assignees WHERE #{to_ability_name}_id = #{to_ability_name}s.id)")
+ end
+ scope :unassigned, -> do
+ where("NOT EXISTS (SELECT TRUE FROM #{to_ability_name}_assignees WHERE #{to_ability_name}_id = #{to_ability_name}s.id)")
+ end
+ scope :assigned_to, ->(u) do
+ where("EXISTS (SELECT TRUE FROM #{to_ability_name}_assignees WHERE user_id = ? AND #{to_ability_name}_id = #{to_ability_name}s.id)", u.id)
+ end
+ # rubocop:enable GitlabSecurity/SqlInjection
+
scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") }
scope :order_milestone_due_desc, -> { left_joins_milestones.reorder('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC') }
scope :order_milestone_due_asc, -> { left_joins_milestones.reorder('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC') }
@@ -102,13 +110,14 @@ module Issuable
participant :author
participant :notes_with_associations
+ participant :assignees
strip_attributes :title
# 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?
- title_changed? || description_changed?
+ will_save_change_to_title? || will_save_change_to_description?
end
def allows_multiple_assignees?
@@ -118,6 +127,12 @@ module Issuable
def has_multiple_assignees?
assignees.count > 1
end
+
+ private
+
+ def milestone_is_valid
+ errors.add(:milestone_id, message: "is invalid") if milestone_id.present? && !milestone_available?
+ end
end
class_methods do
@@ -132,6 +147,15 @@ module Issuable
fuzzy_search(query, [:title])
end
+ # Available state values persisted in state_id column using state machine
+ #
+ # Override this on subclasses if different states are needed
+ #
+ # Check MergeRequest.available_states for example
+ def available_states
+ @available_states ||= { opened: 1, closed: 2 }.with_indifferent_access
+ end
+
# Searches for records with a matching title or description.
#
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
@@ -151,6 +175,10 @@ module Issuable
fuzzy_search(query, matched_columns)
end
+ def simple_sorts
+ super.except('name_asc', 'name_desc')
+ end
+
def sort_by_attribute(method, excluded_labels: [])
sorted =
case method.to_s
@@ -245,6 +273,14 @@ module Issuable
end
end
+ def milestone_available?
+ project_id == milestone&.project_id || project.ancestors_upto.compact.include?(milestone&.group)
+ end
+
+ def assignee_or_author?(user)
+ author_id == user.id || assignees.exists?(user.id)
+ end
+
def today?
Date.today == created_at.to_date
end
@@ -289,11 +325,7 @@ module Issuable
end
if old_assignees != assignees
- if self.is_a?(Issue)
- changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)]
- else
- changes[:assignee] = [old_assignees&.first&.hook_attrs, assignee&.hook_attrs]
- end
+ changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)]
end
if self.respond_to?(:total_time_spent)
@@ -330,10 +362,18 @@ module Issuable
def card_attributes
{
'Author' => author.try(:name),
- 'Assignee' => assignee.try(:name)
+ 'Assignee' => assignee_list
}
end
+ def assignee_list
+ assignees.map(&:name).to_sentence
+ end
+
+ def assignee_username_list
+ assignees.map(&:username).to_sentence
+ end
+
def notes_with_associations
# If A has_many Bs, and B has_many Cs, and you do
# `A.includes(b: :c).each { |a| a.b.includes(:c) }`, sadly ActiveRecord
diff --git a/app/models/concerns/issuable_states.rb b/app/models/concerns/issuable_states.rb
new file mode 100644
index 00000000000..b722c541580
--- /dev/null
+++ b/app/models/concerns/issuable_states.rb
@@ -0,0 +1,25 @@
+# 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 populate state_id column with new values
+ # and should be removed after the state column is removed.
+ # Check https://gitlab.com/gitlab-org/gitlab-ce/issues/51789 for more information
+ included do
+ before_save :set_state_id
+ end
+
+ def set_state_id
+ return if state.nil? || state.empty?
+
+ # Needed to prevent breaking some migration specs that
+ # rollback database to a point where state_id does not exist.
+ # We can use this guard clause for now since this file will
+ # be removed in the next release.
+ return unless self.has_attribute?(:state_id)
+
+ self.state_id = self.class.available_states[state]
+ end
+end
diff --git a/app/models/concerns/maskable.rb b/app/models/concerns/maskable.rb
new file mode 100644
index 00000000000..e0f2c41b836
--- /dev/null
+++ b/app/models/concerns/maskable.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Maskable
+ extend ActiveSupport::Concern
+
+ # * Single line
+ # * No escape characters
+ # * No variables
+ # * No spaces
+ # * Minimal length of 8 characters from the Base64 alphabets (RFC4648)
+ # * Absolutely no fun is allowed
+ REGEX = /\A[a-zA-Z0-9_+=\/-]{8,}\z/.freeze
+
+ included do
+ validates :masked, inclusion: { in: [true, false] }
+ validates :value, format: { with: REGEX }, if: :masked?
+ end
+
+ def to_runner_variable
+ super.merge(masked: masked?)
+ end
+end
diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb
index 055ffe04646..3deb86da6cf 100644
--- a/app/models/concerns/milestoneish.rb
+++ b/app/models/concerns/milestoneish.rb
@@ -1,28 +1,20 @@
# frozen_string_literal: true
module Milestoneish
- def closed_items_count(user)
- memoize_per_user(user, :closed_items_count) do
- (count_issues_by_state(user)['closed'] || 0) + merge_requests.closed_and_merged.size
- end
- end
-
- def total_items_count(user)
- memoize_per_user(user, :total_items_count) do
- total_issues_count(user) + merge_requests.size
- end
- end
-
def total_issues_count(user)
count_issues_by_state(user).values.sum
end
+ def closed_issues_count(user)
+ count_issues_by_state(user)['closed'].to_i
+ end
+
def complete?(user)
- total_items_count(user) > 0 && total_items_count(user) == closed_items_count(user)
+ total_issues_count(user) > 0 && total_issues_count(user) == closed_issues_count(user)
end
def percent_complete(user)
- ((closed_items_count(user) * 100) / total_items_count(user)).abs
+ closed_issues_count(user) * 100 / total_issues_count(user)
rescue ZeroDivisionError
0
end
@@ -46,12 +38,31 @@ module Milestoneish
end
end
+ def issue_participants_visible_by_user(user)
+ User.joins(:issue_assignees)
+ .where('issue_assignees.issue_id' => issues_visible_to_user(user).select(:id))
+ .distinct
+ end
+
+ def issue_labels_visible_by_user(user)
+ Label.joins(:label_links)
+ .where('label_links.target_id' => issues_visible_to_user(user).select(:id), 'label_links.target_type' => 'Issue')
+ .distinct
+ end
+
def sorted_issues(user)
issues_visible_to_user(user).preload_associations.sort_by_attribute('label_priority')
end
- def sorted_merge_requests
- merge_requests.sort_by_attribute('label_priority')
+ def sorted_merge_requests(user)
+ merge_requests_visible_to_user(user).sort_by_attribute('label_priority')
+ end
+
+ def merge_requests_visible_to_user(user)
+ memoize_per_user(user, :merge_requests_visible_to_user) do
+ MergeRequestsFinder.new(user, issues_finder_params)
+ .execute.where(milestone_id: milestoneish_id)
+ end
end
def upcoming?
diff --git a/app/models/concerns/mirror_authentication.rb b/app/models/concerns/mirror_authentication.rb
index e3e1a0441f8..948094221e5 100644
--- a/app/models/concerns/mirror_authentication.rb
+++ b/app/models/concerns/mirror_authentication.rb
@@ -79,7 +79,7 @@ module MirrorAuthentication
end
def ssh_public_key
- return nil if ssh_private_key.blank?
+ return if ssh_private_key.blank?
comment = "git@#{::Gitlab.config.gitlab.host}"
::SSHKey.new(ssh_private_key, comment: comment).ssh_public_key
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index 3c74034b527..4b428b0af83 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -3,16 +3,26 @@
module Noteable
extend ActiveSupport::Concern
- # `Noteable` class names that support resolvable notes.
- RESOLVABLE_TYPES = %w(MergeRequest).freeze
-
class_methods do
# `Noteable` class names that support replying to individual notes.
def replyable_types
%w(Issue MergeRequest)
end
+
+ # `Noteable` class names that support resolvable notes.
+ def resolvable_types
+ %w(MergeRequest)
+ end
end
+ # The timestamp of the note (e.g. the :created_at or :updated_at attribute if provided via
+ # API call)
+ def system_note_timestamp
+ @system_note_timestamp || Time.now # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
+ attr_writer :system_note_timestamp
+
def base_class_name
self.class.base_class.name
end
@@ -28,7 +38,7 @@ module Noteable
end
def supports_resolvable_notes?
- RESOLVABLE_TYPES.include?(base_class_name)
+ self.class.resolvable_types.include?(base_class_name)
end
def supports_discussions?
@@ -123,3 +133,5 @@ module Noteable
)
end
end
+
+Noteable.extend(Noteable::ClassMethods)
diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb
index 614c3242874..b140fca9b83 100644
--- a/app/models/concerns/participable.rb
+++ b/app/models/concerns/participable.rb
@@ -7,7 +7,7 @@
#
# Usage:
#
-# class Issue < ActiveRecord::Base
+# class Issue < ApplicationRecord
# include Participable
#
# # ...
diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb
index a29e80fe0c1..258c819f243 100644
--- a/app/models/concerns/prometheus_adapter.rb
+++ b/app/models/concerns/prometheus_adapter.rb
@@ -36,7 +36,7 @@ module PrometheusAdapter
def calculate_reactive_cache(query_class_name, *args)
return unless prometheus_client
- data = Kernel.const_get(query_class_name).new(prometheus_client_wrapper).query(*args)
+ data = Object.const_get(query_class_name, false).new(prometheus_client_wrapper).query(*args)
{
success: true,
data: data,
@@ -51,7 +51,7 @@ module PrometheusAdapter
end
def build_query_args(*args)
- args.map(&:id)
+ args.map { |arg| arg.respond_to?(:id) ? arg.id : arg }
end
end
end
diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb
index de77ca3e963..1e09cd89550 100644
--- a/app/models/concerns/reactive_caching.rb
+++ b/app/models/concerns/reactive_caching.rb
@@ -7,7 +7,7 @@
#
# Example of use:
#
-# class Foo < ActiveRecord::Base
+# class Foo < ApplicationRecord
# include ReactiveCaching
#
# self.reactive_cache_key = ->(thing) { ["foo", thing.id] }
@@ -29,6 +29,40 @@
# However, it will enqueue a background worker to call `#calculate_reactive_cache`
# and set an initial cache lifetime of ten minutes.
#
+# The background worker needs to find or generate the object on which
+# `with_reactive_cache` was called.
+# The default behaviour can be overridden by defining a custom
+# `reactive_cache_worker_finder`.
+# Otherwise the background worker will use the class name and primary key to get
+# the object using the ActiveRecord find_by method.
+#
+# class Bar
+# include ReactiveCaching
+#
+# self.reactive_cache_key = ->() { ["bar", "thing"] }
+# self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
+#
+# def self.from_cache(var1, var2)
+# # This method will be called by the background worker with "bar1" and
+# # "bar2" as arguments.
+# new(var1, var2)
+# end
+#
+# def initialize(var1, var2)
+# # ...
+# end
+#
+# def calculate_reactive_cache
+# # Expensive operation here. The return value of this method is cached
+# end
+#
+# def result
+# with_reactive_cache("bar1", "bar2") do |data|
+# # ...
+# end
+# end
+# end
+#
# Each time the background job completes, it stores the return value of
# `#calculate_reactive_cache`. It is also re-enqueued to run again after
# `reactive_cache_refresh_interval`, so keeping the stored value up to date.
@@ -52,6 +86,7 @@ module ReactiveCaching
class_attribute :reactive_cache_key
class_attribute :reactive_cache_lifetime
class_attribute :reactive_cache_refresh_interval
+ class_attribute :reactive_cache_worker_finder
# defaults
self.reactive_cache_lease_timeout = 2.minutes
@@ -59,6 +94,10 @@ module ReactiveCaching
self.reactive_cache_refresh_interval = 1.minute
self.reactive_cache_lifetime = 10.minutes
+ self.reactive_cache_worker_finder = ->(id, *_args) do
+ find_by(primary_key => id)
+ end
+
def calculate_reactive_cache(*args)
raise NotImplementedError
end
@@ -69,7 +108,7 @@ module ReactiveCaching
def with_reactive_cache(*args, &blk)
unless within_reactive_cache_lifetime?(*args)
refresh_reactive_cache!(*args)
- return nil
+ return
end
keep_alive_reactive_cache!(*args)
diff --git a/app/models/concerns/redactable.rb b/app/models/concerns/redactable.rb
index 5ad96d6cc46..53ae300ee2d 100644
--- a/app/models/concerns/redactable.rb
+++ b/app/models/concerns/redactable.rb
@@ -10,7 +10,7 @@
module Redactable
extend ActiveSupport::Concern
- UNSUBSCRIBE_PATTERN = %r{/sent_notifications/\h{32}/unsubscribe}
+ UNSUBSCRIBE_PATTERN = %r{/sent_notifications/\h{32}/unsubscribe}.freeze
class_methods do
def redact_field(field)
diff --git a/app/models/concerns/referable.rb b/app/models/concerns/referable.rb
index 58143a32fdc..4a506146de3 100644
--- a/app/models/concerns/referable.rb
+++ b/app/models/concerns/referable.rb
@@ -73,6 +73,7 @@ module Referable
(?<url>
#{Regexp.escape(Gitlab.config.gitlab.url)}
\/#{Project.reference_pattern}
+ (?:\/\-)?
\/#{Regexp.escape(route)}
\/#{pattern}
(?<path>
diff --git a/app/models/concerns/resolvable_note.rb b/app/models/concerns/resolvable_note.rb
index 16ea330701d..2d2d5fb7168 100644
--- a/app/models/concerns/resolvable_note.rb
+++ b/app/models/concerns/resolvable_note.rb
@@ -12,7 +12,7 @@ module ResolvableNote
validates :resolved_by, presence: true, if: :resolved?
# Keep this scope in sync with `#potentially_resolvable?`
- scope :potentially_resolvable, -> { where(type: RESOLVABLE_TYPES).where(noteable_type: Noteable::RESOLVABLE_TYPES) }
+ scope :potentially_resolvable, -> { where(type: RESOLVABLE_TYPES).where(noteable_type: Noteable.resolvable_types) }
# Keep this scope in sync with `#resolvable?`
scope :resolvable, -> { potentially_resolvable.user }
diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb
index e51b4e22c96..70ac873a030 100644
--- a/app/models/concerns/sha_attribute.rb
+++ b/app/models/concerns/sha_attribute.rb
@@ -16,6 +16,8 @@ module ShaAttribute
# the column is the correct type. In production it should behave like any other attribute.
# See https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/5502 for more discussion
def validate_binary_column_exists!(name)
+ return unless database_exists?
+
unless table_exists?
warn "WARNING: sha_attribute #{name.inspect} is invalid since the table doesn't exist - you may need to run database migrations"
return
@@ -35,5 +37,13 @@ module ShaAttribute
Gitlab::AppLogger.error "ShaAttribute initialization: #{error.message}"
raise
end
+
+ def database_exists?
+ ApplicationRecord.connection
+
+ true
+ rescue
+ false
+ end
end
end
diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb
index 29e48f0c5f7..df1a9e3fe6e 100644
--- a/app/models/concerns/sortable.rb
+++ b/app/models/concerns/sortable.rb
@@ -21,19 +21,21 @@ module Sortable
class_methods do
def order_by(method)
- case method.to_s
- when 'created_asc' then order_created_asc
- when 'created_date' then order_created_desc
- when 'created_desc' then order_created_desc
- when 'id_asc' then order_id_asc
- when 'id_desc' then order_id_desc
- when 'name_asc' then order_name_asc
- when 'name_desc' then order_name_desc
- when 'updated_asc' then order_updated_asc
- when 'updated_desc' then order_updated_desc
- else
- all
- end
+ simple_sorts.fetch(method.to_s, -> { all }).call
+ end
+
+ def simple_sorts
+ {
+ 'created_asc' => -> { order_created_asc },
+ 'created_date' => -> { order_created_desc },
+ 'created_desc' => -> { order_created_desc },
+ 'id_asc' => -> { order_id_asc },
+ 'id_desc' => -> { order_id_desc },
+ 'name_asc' => -> { order_name_asc },
+ 'name_desc' => -> { order_name_desc },
+ 'updated_asc' => -> { order_updated_asc },
+ 'updated_desc' => -> { order_updated_desc }
+ }
end
private
diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb
index 498996f4f80..a15dc19e07a 100644
--- a/app/models/concerns/storage/legacy_namespace.rb
+++ b/app/models/concerns/storage/legacy_namespace.rb
@@ -13,20 +13,20 @@ module Storage
raise Gitlab::UpdatePathError.new("Namespace #{name} (#{id}) cannot be moved because at least one project (e.g. #{proj_with_tags.name} (#{proj_with_tags.id})) has tags in container registry")
end
- parent_was = if parent_changed? && parent_id_was.present?
- Namespace.find(parent_id_was) # raise NotFound early if needed
+ parent_was = if saved_change_to_parent? && parent_id_before_last_save.present?
+ Namespace.find(parent_id_before_last_save) # raise NotFound early if needed
end
move_repositories
- if parent_changed?
+ if saved_change_to_parent?
former_parent_full_path = parent_was&.full_path
parent_full_path = parent&.full_path
Gitlab::UploadsTransfer.new.move_namespace(path, former_parent_full_path, parent_full_path)
Gitlab::PagesTransfer.new.move_namespace(path, former_parent_full_path, parent_full_path)
else
- Gitlab::UploadsTransfer.new.rename_namespace(full_path_was, full_path)
- Gitlab::PagesTransfer.new.rename_namespace(full_path_was, full_path)
+ Gitlab::UploadsTransfer.new.rename_namespace(full_path_before_last_save, full_path)
+ Gitlab::PagesTransfer.new.rename_namespace(full_path_before_last_save, full_path)
end
# If repositories moved successfully we need to
@@ -38,7 +38,7 @@ module Storage
write_projects_repository_config
rescue => e
# Raise if development/test environment, else just notify Sentry
- Gitlab::Sentry.track_exception(e, extra: { full_path_was: full_path_was, full_path: full_path, action: 'move_dir' })
+ Gitlab::Sentry.track_exception(e, extra: { 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
@@ -57,14 +57,14 @@ module Storage
# Move the namespace directory in all storages used by member projects
repository_storages.each do |repository_storage|
# Ensure old directory exists before moving it
- gitlab_shell.add_namespace(repository_storage, full_path_was)
+ gitlab_shell.add_namespace(repository_storage, full_path_before_last_save)
# Ensure new directory exists before moving it (if there's a parent)
gitlab_shell.add_namespace(repository_storage, parent.full_path) if parent
- unless gitlab_shell.mv_namespace(repository_storage, full_path_was, full_path)
+ unless gitlab_shell.mv_namespace(repository_storage, full_path_before_last_save, full_path)
- Rails.logger.error "Exception moving path #{repository_storage} from #{full_path_was} to #{full_path}"
+ Rails.logger.error "Exception moving path #{repository_storage} from #{full_path_before_last_save} to #{full_path}"
# if we cannot move namespace directory we should rollback
# db changes in order to prevent out of sync between db and fs
@@ -104,11 +104,5 @@ module Storage
end
end
end
-
- def remove_legacy_exports!
- legacy_export_path = File.join(Gitlab::ImportExport.storage_path, full_path_was)
-
- FileUtils.rm_rf(legacy_export_path)
- end
end
end
diff --git a/app/models/concerns/strip_attribute.rb b/app/models/concerns/strip_attribute.rb
index c9f5ba7793d..8f6a6244dd3 100644
--- a/app/models/concerns/strip_attribute.rb
+++ b/app/models/concerns/strip_attribute.rb
@@ -6,7 +6,7 @@
#
# Usage:
#
-# class Milestone < ActiveRecord::Base
+# class Milestone < ApplicationRecord
# strip_attributes :title
# end
#
diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb
index f147ce8ad6b..b42adad94ba 100644
--- a/app/models/concerns/taskable.rb
+++ b/app/models/concerns/taskable.rb
@@ -19,7 +19,7 @@ module Taskable
\s+ # whitespace prefix has to be always presented for a list item
(\[\s\]|\[[xX]\]) # checkbox
(\s.+) # followed by whitespace and some text.
- }x
+ }x.freeze
def self.get_tasks(content)
content.to_s.scan(ITEM_PATTERN).map do |checkbox, label|
@@ -75,4 +75,11 @@ module Taskable
def task_status_short
task_status(short: true)
end
+
+ def task_completion_status
+ @task_completion_status ||= {
+ count: tasks.summary.item_count,
+ completed_count: tasks.summary.complete_count
+ }
+ end
end
diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb
index f5bb559ceda..8c769be0489 100644
--- a/app/models/concerns/token_authenticatable.rb
+++ b/app/models/concerns/token_authenticatable.rb
@@ -26,34 +26,41 @@ module TokenAuthenticatable
end
end
- define_method(token_field) do
+ mod = token_authenticatable_module
+
+ mod.define_method(token_field) do
strategy.get_token(self)
end
- define_method("set_#{token_field}") do |token|
+ mod.define_method("set_#{token_field}") do |token|
strategy.set_token(self, token)
end
- define_method("ensure_#{token_field}") do
+ mod.define_method("ensure_#{token_field}") do
strategy.ensure_token(self)
end
# Returns a token, but only saves when the database is in read & write mode
- define_method("ensure_#{token_field}!") do
+ mod.define_method("ensure_#{token_field}!") do
strategy.ensure_token!(self)
end
# Resets the token, but only saves when the database is in read & write mode
- define_method("reset_#{token_field}!") do
+ mod.define_method("reset_#{token_field}!") do
strategy.reset_token!(self)
end
- define_method("#{token_field}_matches?") do |other_token|
+ mod.define_method("#{token_field}_matches?") do |other_token|
token = read_attribute(token_field)
token.present? && ActiveSupport::SecurityUtils.variable_size_secure_compare(other_token, token)
end
end
+ def token_authenticatable_module
+ @token_authenticatable_module ||=
+ const_set(:TokenAuthenticatable, Module.new).tap(&method(:include))
+ end
+
def token_authenticatable_fields
@token_authenticatable_fields ||= []
end
diff --git a/app/models/concerns/token_authenticatable_strategies/base.rb b/app/models/concerns/token_authenticatable_strategies/base.rb
index 01fb194281a..aafd0b538a3 100644
--- a/app/models/concerns/token_authenticatable_strategies/base.rb
+++ b/app/models/concerns/token_authenticatable_strategies/base.rb
@@ -39,25 +39,9 @@ module TokenAuthenticatableStrategies
instance.save! if Gitlab::Database.read_write?
end
- def fallback?
- unless options[:fallback].in?([true, false, nil])
- raise ArgumentError, 'fallback: needs to be a boolean value!'
- end
-
- options[:fallback] == true
- end
-
- def migrating?
- unless options[:migrating].in?([true, false, nil])
- raise ArgumentError, 'migrating: needs to be a boolean value!'
- end
-
- options[:migrating] == true
- end
-
def self.fabricate(model, field, options)
if options[:digest] && options[:encrypted]
- raise ArgumentError, 'Incompatible options set!'
+ raise ArgumentError, _('Incompatible options set!')
end
if options[:digest]
diff --git a/app/models/concerns/token_authenticatable_strategies/encrypted.rb b/app/models/concerns/token_authenticatable_strategies/encrypted.rb
index 152491aa6e9..4728cb658dc 100644
--- a/app/models/concerns/token_authenticatable_strategies/encrypted.rb
+++ b/app/models/concerns/token_authenticatable_strategies/encrypted.rb
@@ -2,28 +2,18 @@
module TokenAuthenticatableStrategies
class Encrypted < Base
- def initialize(*)
- super
-
- if migrating? && fallback?
- raise ArgumentError, '`fallback` and `migrating` options are not compatible!'
- end
- end
-
def find_token_authenticatable(token, unscoped = false)
return if token.blank?
- if fully_encrypted?
- return find_by_encrypted_token(token, unscoped)
- end
-
- if fallback?
+ if required?
+ find_by_encrypted_token(token, unscoped)
+ elsif optional?
find_by_encrypted_token(token, unscoped) ||
find_by_plaintext_token(token, unscoped)
elsif migrating?
find_by_plaintext_token(token, unscoped)
else
- raise ArgumentError, 'Unknown encryption phase!'
+ raise ArgumentError, _("Unknown encryption strategy: %{encrypted_strategy}!") % { encrypted_strategy: encrypted_strategy }
end
end
@@ -41,8 +31,8 @@ module TokenAuthenticatableStrategies
return super if instance.has_attribute?(encrypted_field)
- if fully_encrypted?
- raise ArgumentError, 'Using encrypted strategy when encrypted field is missing!'
+ if required?
+ raise ArgumentError, _('Using required encryption strategy when encrypted field is missing!')
else
insecure_strategy.ensure_token(instance)
end
@@ -53,8 +43,7 @@ module TokenAuthenticatableStrategies
encrypted_token = instance.read_attribute(encrypted_field)
token = Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token)
-
- token || (insecure_strategy.get_token(instance) if fallback?)
+ token || (insecure_strategy.get_token(instance) if optional?)
end
def set_token(instance, token)
@@ -62,16 +51,35 @@ module TokenAuthenticatableStrategies
instance[encrypted_field] = Gitlab::CryptoHelper.aes256_gcm_encrypt(token)
instance[token_field] = token if migrating?
- instance[token_field] = nil if fallback?
+ instance[token_field] = nil if optional?
token
end
- def fully_encrypted?
- !migrating? && !fallback?
+ def required?
+ encrypted_strategy == :required
+ end
+
+ def migrating?
+ encrypted_strategy == :migrating
+ end
+
+ def optional?
+ encrypted_strategy == :optional
end
protected
+ def encrypted_strategy
+ value = options[:encrypted]
+ value = value.call if value.is_a?(Proc)
+
+ unless value.in?([:required, :optional, :migrating])
+ raise ArgumentError, _('encrypted: needs to be a :required, :optional or :migrating!')
+ end
+
+ value
+ end
+
def find_by_plaintext_token(token, unscoped)
insecure_strategy.find_token_authenticatable(token, unscoped)
end
@@ -89,7 +97,7 @@ module TokenAuthenticatableStrategies
def token_set?(instance)
raw_token = instance.read_attribute(encrypted_field)
- unless fully_encrypted?
+ unless required?
raw_token ||= insecure_strategy.get_token(instance)
end
diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb
new file mode 100644
index 00000000000..1f881249322
--- /dev/null
+++ b/app/models/concerns/update_project_statistics.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+# This module is providing helpers for updating `ProjectStatistics` with `after_save` and `before_destroy` hooks.
+#
+# It deals with `ProjectStatistics.increment_statistic` making sure not to update statistics on a cascade delete from the
+# project, and keeping track of value deltas on each save. It updates the DB only when a change is needed.
+#
+# Example:
+#
+# module Ci
+# class JobArtifact < ApplicationRecord
+# include UpdateProjectStatistics
+#
+# update_project_statistics project_statistics_name: :build_artifacts_size
+# end
+# end
+#
+# Expectation:
+#
+# - `statistic_attribute` must be an ActiveRecord attribute
+# - The model must implement `project` and `project_id`. i.e. direct Project relationship or delegation
+#
+module UpdateProjectStatistics
+ extend ActiveSupport::Concern
+
+ class_methods do
+ attr_reader :project_statistics_name, :statistic_attribute
+
+ # Configure the model to update `project_statistics_name` on ProjectStatistics,
+ # when `statistic_attribute` changes
+ #
+ # - project_statistics_name: A column of `ProjectStatistics` to update
+ # - statistic_attribute: An attribute of the current model, default to `size`
+ #
+ def update_project_statistics(project_statistics_name:, statistic_attribute: :size)
+ @project_statistics_name = project_statistics_name
+ @statistic_attribute = statistic_attribute
+
+ after_save(:update_project_statistics_after_save, if: :update_project_statistics_attribute_changed?)
+ after_destroy(:update_project_statistics_after_destroy, unless: :project_destroyed?)
+ end
+
+ private :update_project_statistics
+ end
+
+ included do
+ private
+
+ def update_project_statistics_after_save
+ attr = self.class.statistic_attribute
+ delta = read_attribute(attr).to_i - attribute_before_last_save(attr).to_i
+
+ update_project_statistics(delta)
+ end
+
+ def update_project_statistics_attribute_changed?
+ saved_change_to_attribute?(self.class.statistic_attribute)
+ end
+
+ def update_project_statistics_after_destroy
+ update_project_statistics(-read_attribute(self.class.statistic_attribute).to_i)
+ 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
+ end
+end
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index cf057d774cf..39e12ac2b06 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ContainerRepository < ActiveRecord::Base
+class ContainerRepository < ApplicationRecord
include Gitlab::Utils::StrongMemoize
belongs_to :project
diff --git a/app/models/conversational_development_index/metric.rb b/app/models/conversational_development_index/metric.rb
index c54537572d6..b91123be87e 100644
--- a/app/models/conversational_development_index/metric.rb
+++ b/app/models/conversational_development_index/metric.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module ConversationalDevelopmentIndex
- class Metric < ActiveRecord::Base
+ class Metric < ApplicationRecord
include Presentable
self.table_name = 'conversational_development_index_metrics'
diff --git a/app/models/deploy_keys_project.rb b/app/models/deploy_keys_project.rb
index 71fd02fac86..15906ed8e06 100644
--- a/app/models/deploy_keys_project.rb
+++ b/app/models/deploy_keys_project.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class DeployKeysProject < ActiveRecord::Base
+class DeployKeysProject < ApplicationRecord
belongs_to :project
belongs_to :deploy_key, inverse_of: :deploy_keys_projects
diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb
index e3524305346..b0e570f52ba 100644
--- a/app/models/deploy_token.rb
+++ b/app/models/deploy_token.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class DeployToken < ActiveRecord::Base
+class DeployToken < ApplicationRecord
include Expirable
include TokenAuthenticatable
include PolicyActor
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 811e623b7f7..92c7311014a 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Deployment < ActiveRecord::Base
+class Deployment < ApplicationRecord
include AtomicInternalId
include IidRoutes
include AfterCommitQueue
@@ -47,6 +47,12 @@ class Deployment < ActiveRecord::Base
Deployments::SuccessWorker.perform_async(id)
end
end
+
+ after_transition any => [:success, :failed, :canceled] do |deployment|
+ deployment.run_after_commit do
+ Deployments::FinishedWorker.perform_async(id)
+ end
+ end
end
enum status: {
@@ -78,6 +84,19 @@ class Deployment < ActiveRecord::Base
Commit.truncate_sha(sha)
end
+ def cluster
+ platform = project.deployment_platform(environment: environment.name)
+
+ if platform.present? && platform.respond_to?(:cluster)
+ platform.cluster
+ end
+ end
+
+ def execute_hooks
+ deployment_data = Gitlab::DataBuilder::Deployment.build(self)
+ project.execute_services(deployment_data, :deployment_hooks)
+ end
+
def last?
self == environment.last_deployment
end
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index 279603496b0..f75c32633b1 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -15,7 +15,9 @@ class DiffNote < Note
validates :original_position, presence: true
validates :position, presence: true
validates :line_code, presence: true, line_code: true, if: :on_text?
- validates :noteable_type, inclusion: { in: noteable_types }
+ # We need to evaluate the `noteable` types when running the validation since
+ # EE might have added a type when the module was prepended
+ validates :noteable_type, inclusion: { in: -> (_note) { noteable_types } }
validate :positions_complete
validate :verify_supported
validate :diff_refs_match_commit, if: :for_commit?
@@ -41,6 +43,14 @@ class DiffNote < Note
create_note_diff_file(creation_params)
end
+ # Returns the diff file from `position`
+ def latest_diff_file
+ strong_memoize(:latest_diff_file) do
+ position.diff_file(repository)
+ end
+ end
+
+ # Returns the diff file from `original_position`
def diff_file
strong_memoize(:diff_file) do
enqueue_diff_file_creation_job if should_create_diff_file?
@@ -67,10 +77,10 @@ class DiffNote < Note
end
def supports_suggestion?
- return false unless noteable.supports_suggestion? && on_text?
+ return false unless noteable&.supports_suggestion? && on_text?
# We don't want to trigger side-effects of `diff_file` call.
- return false unless file = fetch_diff_file
- return false unless line = file.line_for_position(self.original_position)
+ return false unless file = latest_diff_file
+ return false unless line = file.line_for_position(self.position)
line&.suggestible?
end
@@ -80,7 +90,7 @@ class DiffNote < Note
end
def banzai_render_context(field)
- super.merge(suggestions_filter_enabled: supports_suggestion?)
+ super.merge(suggestions_filter_enabled: true)
end
private
@@ -103,7 +113,7 @@ class DiffNote < Note
if note_diff_file
diff = Gitlab::Git::Diff.new(note_diff_file.to_hash)
Gitlab::Diff::File.new(diff,
- repository: project.repository,
+ repository: repository,
diff_refs: original_position.diff_refs)
elsif created_at_diff?(noteable.diff_refs)
# We're able to use the already persisted diffs (Postgres) if we're
@@ -114,7 +124,7 @@ class DiffNote < Note
# `Diff::FileCollection::MergeRequestDiff`.
noteable.diffs(original_position.diff_options).diff_files.first
else
- original_position.diff_file(self.project.repository)
+ original_position.diff_file(repository)
end
# Since persisted diff files already have its content "unfolded"
@@ -129,7 +139,7 @@ class DiffNote < Note
end
def set_line_code
- self.line_code = self.position.line_code(self.project.repository)
+ self.line_code = self.position.line_code(repository)
end
def verify_supported
@@ -163,6 +173,10 @@ class DiffNote < Note
shas << self.position.head_sha
end
- project.repository.keep_around(*shas)
+ repository.keep_around(*shas)
+ end
+
+ def repository
+ noteable.respond_to?(:repository) ? noteable.repository : project.repository
end
end
diff --git a/app/models/email.rb b/app/models/email.rb
index 3ce6e792fa8..0ddaa049c3b 100644
--- a/app/models/email.rb
+++ b/app/models/email.rb
@@ -1,13 +1,13 @@
# frozen_string_literal: true
-class Email < ActiveRecord::Base
+class Email < ApplicationRecord
include Sortable
include Gitlab::SQL::Pattern
belongs_to :user
validates :user_id, presence: true
- validates :email, presence: true, uniqueness: true, email: true
+ validates :email, presence: true, uniqueness: true, devise_email: true
validate :unique_email, if: ->(email) { email.email_changed? }
scope :confirmed, -> { where.not(confirmed_at: nil) }
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 1fc088b12ae..aff20dae09b 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -1,10 +1,10 @@
# frozen_string_literal: true
-class Environment < ActiveRecord::Base
+class Environment < ApplicationRecord
include Gitlab::Utils::StrongMemoize
# Used to generate random suffixes for the slug
- LETTERS = 'a'..'z'
- NUMBERS = '0'..'9'
+ LETTERS = ('a'..'z').freeze
+ NUMBERS = ('0'..'9').freeze
SUFFIX_CHARS = LETTERS.to_a + NUMBERS.to_a
belongs_to :project, required: true
@@ -35,7 +35,7 @@ class Environment < ActiveRecord::Base
validates :external_url,
length: { maximum: 255 },
allow_nil: true,
- url: true
+ addressable_url: true
delegate :stop_action, :manual_actions, to: :last_deployment, allow_nil: true
@@ -119,7 +119,7 @@ class Environment < ActiveRecord::Base
def first_deployment_for(commit_sha)
ref = project.repository.ref_name_for_sha(ref_path, commit_sha)
- return nil unless ref
+ return unless ref
deployment_iid = ref.split('/').last
deployments.find_by(iid: deployment_iid)
@@ -130,7 +130,7 @@ class Environment < ActiveRecord::Base
end
def formatted_external_url
- return nil unless external_url
+ return unless external_url
external_url.gsub(%r{\A.*?://}, '')
end
@@ -155,11 +155,11 @@ class Environment < ActiveRecord::Base
end
def has_terminals?
- project.deployment_platform.present? && available? && last_deployment.present?
+ deployment_platform.present? && available? && last_deployment.present?
end
def terminals
- project.deployment_platform.terminals(self) if has_terminals?
+ deployment_platform.terminals(self) if has_terminals?
end
def has_metrics?
@@ -170,8 +170,10 @@ class Environment < ActiveRecord::Base
prometheus_adapter.query(:environment, self) if has_metrics?
end
- def additional_metrics
- prometheus_adapter.query(:additional_metrics_environment, self) if has_metrics?
+ def additional_metrics(*args)
+ return unless has_metrics?
+
+ prometheus_adapter.query(:additional_metrics_environment, self, *args.map(&:to_f))
end
# rubocop: disable CodeReuse/ServiceClass
@@ -243,6 +245,10 @@ class Environment < ActiveRecord::Base
self.environment_type || self.name
end
+ def name_without_type
+ @name_without_type ||= name.delete_prefix("#{environment_type}/")
+ end
+
def deployment_platform
strong_memoize(:deployment_platform) do
project.deployment_platform(environment: self.name)
diff --git a/app/models/epic.rb b/app/models/epic.rb
index ccd10593434..3693db1de33 100644
--- a/app/models/epic.rb
+++ b/app/models/epic.rb
@@ -2,7 +2,7 @@
# 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 < ActiveRecord::Base
+class Epic < ApplicationRecord
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 57283a78ea9..0b4fef5eac1 100644
--- a/app/models/error_tracking/project_error_tracking_setting.rb
+++ b/app/models/error_tracking/project_error_tracking_setting.rb
@@ -1,20 +1,34 @@
# frozen_string_literal: true
module ErrorTracking
- class ProjectErrorTrackingSetting < ActiveRecord::Base
+ class ProjectErrorTrackingSetting < ApplicationRecord
+ include Gitlab::Utils::StrongMemoize
include ReactiveCaching
+ SENTRY_API_ERROR_TYPE_MISSING_KEYS = 'missing_keys_in_sentry_response'
+ SENTRY_API_ERROR_TYPE_NON_20X_RESPONSE = 'non_20x_response_from_sentry'
+
+ API_URL_PATH_REGEXP = %r{
+ \A
+ (?<prefix>/api/0/projects/+)
+ (?:
+ (?<organization>[^/]+)/+
+ (?<project>[^/]+)/*
+ )?
+ \z
+ }x.freeze
+
self.reactive_cache_key = ->(setting) { [setting.class.model_name.singular, setting.project_id] }
belongs_to :project
- validates :api_url, length: { maximum: 255 }, public_url: true, url: { enforce_sanitization: true, ascii_only: true }, allow_nil: true
+ validates :api_url, length: { maximum: 255 }, public_url: { enforce_sanitization: true, ascii_only: true }, allow_nil: true
- validates :api_url, presence: true, if: :enabled
+ validates :api_url, presence: { message: 'is a required field' }, if: :enabled
validate :validate_api_url_path, if: :enabled
- validates :token, presence: true, if: :enabled
+ validates :token, presence: { message: 'is a required field' }, if: :enabled
attr_encrypted :token,
mode: :per_attribute_iv,
@@ -23,6 +37,11 @@ module ErrorTracking
after_save :clear_reactive_cache!
+ def api_url=(value)
+ super
+ clear_memoization(:api_url_slugs)
+ end
+
def project_name
super || project_name_from_slug
end
@@ -40,6 +59,8 @@ module ErrorTracking
end
def self.build_api_url_from(api_host:, project_slug:, organization_slug:)
+ return if api_host.blank?
+
uri = Addressable::URI.parse("#{api_host}/api/0/projects/#{organization_slug}/#{project_slug}/")
uri.path = uri.path.squeeze('/')
@@ -72,7 +93,9 @@ module ErrorTracking
{ issues: sentry_client.list_issues(**opts.symbolize_keys) }
end
rescue Sentry::Client::Error => e
- { error: e.message }
+ { error: e.message, error_type: SENTRY_API_ERROR_TYPE_NON_20X_RESPONSE }
+ rescue Sentry::Client::MissingKeysError => e
+ { error: e.message, error_type: SENTRY_API_ERROR_TYPE_MISSING_KEYS }
end
# http://HOST/api/0/projects/ORG/PROJECT
@@ -100,34 +123,39 @@ module ErrorTracking
end
def project_slug_from_api_url
- extract_slug(:project)
+ api_url_slug(:project)
end
def organization_slug_from_api_url
- extract_slug(:organization)
+ api_url_slug(:organization)
end
- def extract_slug(capture)
+ def api_url_slug(capture)
+ slugs = strong_memoize(:api_url_slugs) { extract_api_url_slugs || {} }
+ slugs[capture]
+ end
+
+ def extract_api_url_slugs
return if api_url.blank?
begin
url = Addressable::URI.parse(api_url)
rescue Addressable::URI::InvalidURIError
- return nil
+ return
end
- @slug_match ||= url.path.match(%r{^/api/0/projects/+(?<organization>[^/]+)/+(?<project>[^/|$]+)}) || {}
- @slug_match[capture]
+ url.path.match(API_URL_PATH_REGEXP)
end
def validate_api_url_path
return if api_url.blank?
- begin
- unless Addressable::URI.parse(api_url).path.starts_with?('/api/0/projects')
- errors.add(:api_url, 'path needs to start with /api/0/projects')
- end
- rescue Addressable::URI::InvalidURIError
+ unless api_url_slug(:prefix)
+ return errors.add(:api_url, 'is invalid')
+ end
+
+ unless api_url_slug(:organization)
+ errors.add(:project, 'is a required field')
end
end
end
diff --git a/app/models/event.rb b/app/models/event.rb
index 6a35bca72c5..738080eb584 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Event < ActiveRecord::Base
+class Event < ApplicationRecord
include Sortable
include IgnorableColumn
include FromUnion
@@ -68,7 +68,7 @@ class Event < ActiveRecord::Base
# Callbacks
after_create :reset_project_activity
- after_create :set_last_repository_updated_at, if: :push?
+ after_create :set_last_repository_updated_at, if: :push_action?
after_create :track_user_interacted_projects
# Scopes
@@ -138,11 +138,11 @@ class Event < ActiveRecord::Base
# rubocop:disable Metrics/CyclomaticComplexity
# rubocop:disable Metrics/PerceivedComplexity
def visible_to_user?(user = nil)
- if push? || commit_note?
+ if push_action? || commit_note?
Ability.allowed?(user, :download_code, project)
elsif membership_changed?
Ability.allowed?(user, :read_project, project)
- elsif created_project?
+ elsif created_project_action?
Ability.allowed?(user, :read_project, project)
elsif issue? || issue_note?
Ability.allowed?(user, :read_issue, note? ? note_target : target)
@@ -173,56 +173,56 @@ class Event < ActiveRecord::Base
target.try(:title)
end
- def created?
+ def created_action?
action == CREATED
end
- def push?
+ def push_action?
false
end
- def merged?
+ def merged_action?
action == MERGED
end
- def closed?
+ def closed_action?
action == CLOSED
end
- def reopened?
+ def reopened_action?
action == REOPENED
end
- def joined?
+ def joined_action?
action == JOINED
end
- def left?
+ def left_action?
action == LEFT
end
- def expired?
+ def expired_action?
action == EXPIRED
end
- def destroyed?
+ def destroyed_action?
action == DESTROYED
end
- def commented?
+ def commented_action?
action == COMMENTED
end
def membership_changed?
- joined? || left? || expired?
+ joined_action? || left_action? || expired_action?
end
- def created_project?
- created? && !target && target_type.nil?
+ def created_project_action?
+ created_action? && !target && target_type.nil?
end
def created_target?
- created? && target
+ created_action? && target
end
def milestone?
@@ -258,23 +258,23 @@ class Event < ActiveRecord::Base
end
def action_name
- if push?
+ if push_action?
push_action_name
- elsif closed?
+ elsif closed_action?
"closed"
- elsif merged?
+ elsif merged_action?
"accepted"
- elsif joined?
+ elsif joined_action?
'joined'
- elsif left?
+ elsif left_action?
'left'
- elsif expired?
+ elsif expired_action?
'removed due to membership expiration from'
- elsif destroyed?
+ elsif destroyed_action?
'destroyed'
- elsif commented?
+ elsif commented_action?
"commented on"
- elsif created_project?
+ elsif created_project_action?
created_project_action_name
else
"opened"
@@ -337,7 +337,7 @@ class Event < ActiveRecord::Base
end
def body?
- if push?
+ if push_action?
push_with_commits?
elsif note?
true
diff --git a/app/models/fork_network.rb b/app/models/fork_network.rb
index 1b9bf93cbbc..0323a8d222a 100644
--- a/app/models/fork_network.rb
+++ b/app/models/fork_network.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ForkNetwork < ActiveRecord::Base
+class ForkNetwork < ApplicationRecord
belongs_to :root_project, class_name: 'Project'
has_many :fork_network_members
has_many :projects, through: :fork_network_members
diff --git a/app/models/fork_network_member.rb b/app/models/fork_network_member.rb
index 36c66f21b0b..f18c306cf91 100644
--- a/app/models/fork_network_member.rb
+++ b/app/models/fork_network_member.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ForkNetworkMember < ActiveRecord::Base
+class ForkNetworkMember < ApplicationRecord
belongs_to :fork_network
belongs_to :project
belongs_to :forked_from_project, class_name: 'Project'
diff --git a/app/models/generic_commit_status.rb b/app/models/generic_commit_status.rb
index 3028bf21301..8a768b3a2c0 100644
--- a/app/models/generic_commit_status.rb
+++ b/app/models/generic_commit_status.rb
@@ -3,7 +3,7 @@
class GenericCommitStatus < CommitStatus
before_validation :set_default_values
- validates :target_url, url: true,
+ validates :target_url, addressable_url: true,
length: { maximum: 255 },
allow_nil: true
diff --git a/app/models/global_label.rb b/app/models/global_label.rb
index c5b2492bbf6..7c020dd3b3d 100644
--- a/app/models/global_label.rb
+++ b/app/models/global_label.rb
@@ -1,10 +1,12 @@
# frozen_string_literal: true
class GlobalLabel
+ include Presentable
+
attr_accessor :title, :labels
alias_attribute :name, :title
- delegate :color, :text_color, :description, to: :@first_label
+ delegate :color, :text_color, :description, :scoped_label?, to: :@first_label
def for_display
@first_label
@@ -23,4 +25,8 @@ class GlobalLabel
@labels = labels
@first_label = labels.find { |lbl| lbl.description.present? } || labels.first
end
+
+ def present(attributes)
+ super(attributes.merge(presenter_class: ::LabelPresenter))
+ end
end
diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb
index fd17745b035..59f5a7703e2 100644
--- a/app/models/global_milestone.rb
+++ b/app/models/global_milestone.rb
@@ -8,7 +8,9 @@ class GlobalMilestone
attr_reader :milestone
alias_attribute :name, :title
- delegate :title, :state, :due_date, :start_date, :participants, :project, :group, :expires_at, :closed?, :iid, :group_milestone?, :safe_title, :milestoneish_id, to: :milestone
+ delegate :title, :state, :due_date, :start_date, :participants, :project,
+ :group, :expires_at, :closed?, :iid, :group_milestone?, :safe_title,
+ :milestoneish_id, :parent, to: :milestone
def to_hash
{
diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb
index 077afffd358..116beac5c2a 100644
--- a/app/models/gpg_key.rb
+++ b/app/models/gpg_key.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class GpgKey < ActiveRecord::Base
+class GpgKey < ApplicationRecord
KEY_PREFIX = '-----BEGIN PGP PUBLIC KEY BLOCK-----'.freeze
KEY_SUFFIX = '-----END PGP PUBLIC KEY BLOCK-----'.freeze
diff --git a/app/models/gpg_key_subkey.rb b/app/models/gpg_key_subkey.rb
index 440b588bc78..110bf451136 100644
--- a/app/models/gpg_key_subkey.rb
+++ b/app/models/gpg_key_subkey.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class GpgKeySubkey < ActiveRecord::Base
+class GpgKeySubkey < ApplicationRecord
include ShaAttribute
sha_attribute :keyid
diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb
index 7f9ff7bbda6..46cac1d41bb 100644
--- a/app/models/gpg_signature.rb
+++ b/app/models/gpg_signature.rb
@@ -38,6 +38,15 @@ class GpgSignature < ApplicationRecord
.safe_find_or_create_by!(commit_sha: attributes[:commit_sha])
end
+ # Find commits that are lacking a signature in the database at present
+ def self.unsigned_commit_shas(commit_shas)
+ return [] if commit_shas.empty?
+
+ signed = GpgSignature.where(commit_sha: commit_shas).pluck(:commit_sha)
+
+ commit_shas - signed
+ end
+
def gpg_key=(model)
case model
when GpgKey
diff --git a/app/models/group.rb b/app/models/group.rb
index 52f503404af..cdb4e6e87f6 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -56,12 +56,12 @@ class Group < Namespace
validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 }
- add_authentication_token_field :runners_token, encrypted: true, migrating: true
+ add_authentication_token_field :runners_token, encrypted: -> { Feature.enabled?(:groups_tokens_optional_encryption, default_enabled: true) ? :optional : :required }
after_create :post_create_hook
after_destroy :post_destroy_hook
after_save :update_two_factor_requirement
- after_update :path_changed_hook, if: :path_changed?
+ after_update :path_changed_hook, if: :saved_change_to_path?
class << self
def sort_by_attribute(method)
@@ -126,10 +126,20 @@ class Group < Namespace
# Overrides notification_settings has_many association
# This allows to apply notification settings from parent groups
# to child groups and projects.
- def notification_settings
+ def notification_settings(hierarchy_order: nil)
source_type = self.class.base_class.name
+ settings = NotificationSetting.where(source_type: source_type, source_id: self_and_ancestors_ids)
- NotificationSetting.where(source_type: source_type, source_id: self_and_ancestors_ids)
+ return settings unless hierarchy_order && self_and_ancestors_ids.length > 1
+
+ settings
+ .joins("LEFT JOIN (#{self_and_ancestors(hierarchy_order: hierarchy_order).to_sql}) AS ordered_groups ON notification_settings.source_id = ordered_groups.id")
+ .select('notification_settings.*, ordered_groups.depth AS depth')
+ .order("ordered_groups.depth #{hierarchy_order}")
+ end
+
+ def notification_settings_for(user, hierarchy_order: nil)
+ notification_settings(hierarchy_order: hierarchy_order).where(user: user)
end
def to_reference(_from = nil, full: nil)
@@ -228,22 +238,21 @@ class Group < Namespace
def has_owner?(user)
return false unless user
- members_with_parents.owners.where(user_id: user).any?
+ members_with_parents.owners.exists?(user_id: user)
end
def has_maintainer?(user)
return false unless user
- members_with_parents.maintainers.where(user_id: user).any?
+ members_with_parents.maintainers.exists?(user_id: user)
end
# @deprecated
alias_method :has_master?, :has_maintainer?
# Check if user is a last owner of the group.
- # Parent owners are ignored for nested groups.
def last_owner?(user)
- owners.include?(user) && owners.size == 1
+ has_owner?(user) && members_with_parents.owners.size == 1
end
def ldap_synced?
@@ -405,10 +414,14 @@ class Group < Namespace
Feature.enabled?(:group_clusters, root_ancestor, default_enabled: true)
end
+ def project_creation_level
+ super || ::Gitlab::CurrentSettings.default_project_creation
+ end
+
private
def update_two_factor_requirement
- return unless require_two_factor_authentication_changed? || two_factor_grace_period_changed?
+ return unless saved_change_to_require_two_factor_authentication? || saved_change_to_two_factor_grace_period?
users.find_each(&:update_two_factor_requirement)
end
diff --git a/app/models/group_custom_attribute.rb b/app/models/group_custom_attribute.rb
index 22f14885657..5ac6e5f2550 100644
--- a/app/models/group_custom_attribute.rb
+++ b/app/models/group_custom_attribute.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class GroupCustomAttribute < ActiveRecord::Base
+class GroupCustomAttribute < ApplicationRecord
belongs_to :group
validates :group, :key, :value, presence: true
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index 1a8662db9fb..daf7ff4b771 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class WebHook < ActiveRecord::Base
+class WebHook < ApplicationRecord
include Sortable
attr_encrypted :token,
diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb
index 2d9f7594e8c..cfb1f3ec63b 100644
--- a/app/models/hooks/web_hook_log.rb
+++ b/app/models/hooks/web_hook_log.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class WebHookLog < ActiveRecord::Base
+class WebHookLog < ApplicationRecord
belongs_to :web_hook
serialize :request_headers, Hash # rubocop:disable Cop/ActiveRecordSerialize
diff --git a/app/models/identity.rb b/app/models/identity.rb
index acdde4f296b..1cbd50205ed 100644
--- a/app/models/identity.rb
+++ b/app/models/identity.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Identity < ActiveRecord::Base
+class Identity < ApplicationRecord
include Sortable
include CaseSensitivity
@@ -13,6 +13,7 @@ class Identity < ActiveRecord::Base
before_save :ensure_normalized_extern_uid, if: :extern_uid_changed?
after_destroy :clear_user_synced_attributes, if: :user_synced_attributes_metadata_from_provider?
+ scope :for_user, ->(user) { where(user: user) }
scope :with_provider, ->(provider) { where(provider: provider) }
scope :with_extern_uid, ->(provider, extern_uid) do
iwhere(extern_uid: normalize_uid(provider, extern_uid)).with_provider(provider)
diff --git a/app/models/identity/uniqueness_scopes.rb b/app/models/identity/uniqueness_scopes.rb
index 674b735903f..ce68371ae87 100644
--- a/app/models/identity/uniqueness_scopes.rb
+++ b/app/models/identity/uniqueness_scopes.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Identity < ActiveRecord::Base
+class Identity < ApplicationRecord
# This module and method are defined in a separate file to allow EE to
# redefine the `scopes` method before it is used in the `Identity` model.
module UniquenessScopes
diff --git a/app/models/import_export_upload.rb b/app/models/import_export_upload.rb
index f0cc5aafcd4..60f5491849a 100644
--- a/app/models/import_export_upload.rb
+++ b/app/models/import_export_upload.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ImportExportUpload < ActiveRecord::Base
+class ImportExportUpload < ApplicationRecord
include WithUploads
include ObjectStorage::BackgroundMove
diff --git a/app/models/individual_note_discussion.rb b/app/models/individual_note_discussion.rb
index b4a661ae5b4..d926e39f96e 100644
--- a/app/models/individual_note_discussion.rb
+++ b/app/models/individual_note_discussion.rb
@@ -14,7 +14,7 @@ class IndividualNoteDiscussion < Discussion
end
def can_convert_to_discussion?
- noteable.supports_replying_to_individual_notes? && Feature.enabled?(:reply_to_individual_notes)
+ noteable.supports_replying_to_individual_notes?
end
def convert_to_discussion!(save: false)
diff --git a/app/models/instance_configuration.rb b/app/models/instance_configuration.rb
index 11289887e00..a9b1962f24c 100644
--- a/app/models/instance_configuration.rb
+++ b/app/models/instance_configuration.rb
@@ -39,7 +39,7 @@ class InstanceConfiguration
def gitlab_ci
Settings.gitlab_ci
.to_h
- .merge(artifacts_max_size: { value: Settings.artifacts.max_size&.megabytes,
+ .merge(artifacts_max_size: { value: Gitlab::CurrentSettings.max_artifacts_size.megabytes,
default: 100.megabytes })
end
diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb
index e75c6eb2331..237401899db 100644
--- a/app/models/internal_id.rb
+++ b/app/models/internal_id.rb
@@ -15,7 +15,7 @@
# In order to leverage InternalId for other usages, the idea is to
# * Add `usage` value to enum
# * (Optionally) add columns to `internal_ids` if needed for scope.
-class InternalId < ActiveRecord::Base
+class InternalId < ApplicationRecord
belongs_to :project
belongs_to :namespace
@@ -55,7 +55,8 @@ class InternalId < ActiveRecord::Base
def track_greatest(subject, scope, usage, new_value, init)
return new_value unless available?
- InternalIdGenerator.new(subject, scope, usage, init).track_greatest(new_value)
+ InternalIdGenerator.new(subject, scope, usage)
+ .track_greatest(init, new_value)
end
def generate_next(subject, scope, usage, init)
@@ -63,7 +64,15 @@ class InternalId < ActiveRecord::Base
# This can be the case in other (unrelated) migration specs
return (init.call(subject) || 0) + 1 unless available?
- InternalIdGenerator.new(subject, scope, usage, init).generate
+ InternalIdGenerator.new(subject, scope, usage)
+ .generate(init)
+ end
+
+ def reset(subject, scope, usage, value)
+ return false unless available?
+
+ InternalIdGenerator.new(subject, scope, usage)
+ .reset(value)
end
# Flushing records is generally safe in a sense that those
@@ -78,12 +87,16 @@ class InternalId < ActiveRecord::Base
end
def available?
- @available_flag ||= ActiveRecord::Migrator.current_version >= REQUIRED_SCHEMA_VERSION # rubocop:disable Gitlab/PredicateMemoization
+ return true unless Rails.env.test?
+
+ Gitlab::SafeRequestStore.fetch(:internal_ids_available_flag) do
+ ActiveRecord::Migrator.current_version >= REQUIRED_SCHEMA_VERSION
+ end
end
# Flushes cached information about schema
def reset_column_information
- @available_flag = nil
+ Gitlab::SafeRequestStore[:internal_ids_available_flag] = nil
super
end
end
@@ -103,14 +116,11 @@ class InternalId < ActiveRecord::Base
# subject: The instance we're generating an internal id for. Gets passed to init if called.
# scope: Attributes that define the scope for id generation.
# usage: Symbol to define the usage of the internal id, see InternalId.usages
- # init: Block that gets called to initialize InternalId record if not present
- # Make sure to not throw exceptions in the absence of records (if this is expected).
- attr_reader :subject, :scope, :init, :scope_attrs, :usage
+ attr_reader :subject, :scope, :scope_attrs, :usage
- def initialize(subject, scope, usage, init)
+ def initialize(subject, scope, usage)
@subject = subject
@scope = scope
- @init = init
@usage = usage
raise ArgumentError, 'Scope is not well-defined, need at least one column for scope (given: 0)' if scope.empty?
@@ -121,23 +131,40 @@ class InternalId < ActiveRecord::Base
end
# Generates next internal id and returns it
- def generate
+ # init: Block that gets called to initialize InternalId record if not present
+ # Make sure to not throw exceptions in the absence of records (if this is expected).
+ def generate(init)
subject.transaction do
# Create a record in internal_ids if one does not yet exist
# and increment its last value
#
# Note this will acquire a ROW SHARE lock on the InternalId record
- (lookup || create_record).increment_and_save!
+ (lookup || create_record(init)).increment_and_save!
end
end
+ # Reset tries to rewind to `value-1`. This will only succeed,
+ # if `value` stored in database is equal to `last_value`.
+ # value: The expected last_value to decrement
+ def reset(value)
+ return false unless value
+
+ updated =
+ InternalId
+ .where(**scope, usage: usage_value)
+ .where(last_value: value)
+ .update_all('last_value = last_value - 1')
+
+ updated > 0
+ end
+
# Create a record in internal_ids if one does not yet exist
# and set its new_value if it is higher than the current last_value
#
# Note this will acquire a ROW SHARE lock on the InternalId record
- def track_greatest(new_value)
+ def track_greatest(init, new_value)
subject.transaction do
- (lookup || create_record).track_greatest_and_save!(new_value)
+ (lookup || create_record(init)).track_greatest_and_save!(new_value)
end
end
@@ -158,7 +185,7 @@ class InternalId < ActiveRecord::Base
# was faster in doing this, we'll realize once we hit the unique key constraint
# violation. We can safely roll-back the nested transaction and perform
# a lookup instead to retrieve the record.
- def create_record
+ def create_record(init)
subject.transaction(requires_new: true) do
InternalId.create!(
**scope,
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 0b46e949052..6da6fbe55cb 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -2,7 +2,7 @@
require 'carrierwave/orm/activerecord'
-class Issue < ActiveRecord::Base
+class Issue < ApplicationRecord
include AtomicInternalId
include IidRoutes
include Issuable
@@ -49,10 +49,6 @@ class Issue < ActiveRecord::Base
scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
- scope :assigned, -> { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') }
- scope :unassigned, -> { where('NOT EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') }
- scope :assigned_to, ->(u) { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = ? AND issue_id = issues.id)', u.id)}
-
scope :with_due_date, -> { where.not(due_date: nil) }
scope :without_due_date, -> { where(due_date: nil) }
scope :due_before, ->(date) { where('issues.due_date < ?', date) }
@@ -62,8 +58,10 @@ class Issue < ActiveRecord::Base
scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') }
scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') }
scope :order_closest_future_date, -> { reorder('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 :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) }
@@ -74,8 +72,6 @@ class Issue < ActiveRecord::Base
attr_spammable :title, spam_title: true
attr_spammable :description, spam_description: true
- participant :assignees
-
state_machine :state, initial: :opened do
event :close do
transition [:opened] => :closed
@@ -89,7 +85,7 @@ class Issue < ActiveRecord::Base
state :closed
before_transition any => :closed do |issue|
- issue.closed_at = Time.zone.now
+ issue.closed_at = issue.system_note_timestamp
end
before_transition closed: :opened do |issue|
@@ -135,9 +131,10 @@ class Issue < ActiveRecord::Base
def self.sort_by_attribute(method, excluded_labels: [])
case method.to_s
when 'closest_future_date' then order_closest_future_date
- when 'due_date' then order_due_date_asc
- when 'due_date_asc' then order_due_date_asc
- when 'due_date_desc' then order_due_date_desc
+ when 'due_date' then order_due_date_asc
+ when 'due_date_asc' then order_due_date_asc
+ when 'due_date_desc' then order_due_date_desc
+ when 'relative_position' then order_relative_position_asc
else
super
end
@@ -154,22 +151,6 @@ class Issue < ActiveRecord::Base
Gitlab::HookData::IssueBuilder.new(self).build
end
- # Returns a Hash of attributes to be used for Twitter card metadata
- def card_attributes
- {
- 'Author' => author.try(:name),
- 'Assignee' => assignee_list
- }
- end
-
- def assignee_or_author?(user)
- author_id == user.id || assignees.exists?(user.id)
- end
-
- def assignee_list
- assignees.map(&:name).to_sentence
- end
-
# `from` argument can be a Namespace or Project.
def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{iid}"
@@ -229,7 +210,13 @@ class Issue < ActiveRecord::Base
def visible_to_user?(user = nil)
return false unless project && project.feature_available?(:issues, user)
- user ? readable_by?(user) : publicly_visible?
+ return publicly_visible? unless user
+
+ return false unless readable_by?(user)
+
+ user.full_private_access? ||
+ ::Gitlab::ExternalAuthorization.access_allowed?(
+ user, project.external_authorization_classification_label)
end
def check_for_spam?
@@ -263,6 +250,10 @@ class Issue < ActiveRecord::Base
end
# rubocop: enable CodeReuse/ServiceClass
+ def merge_requests_count
+ merge_requests_closing_issues.count
+ end
+
private
def ensure_metrics
@@ -293,7 +284,7 @@ class Issue < ActiveRecord::Base
# Returns `true` if this Issue is visible to everybody.
def publicly_visible?
- project.public? && !confidential?
+ project.public? && !confidential? && !::Gitlab::ExternalAuthorization.enabled?
end
def expire_etag_cache
diff --git a/app/models/issue/metrics.rb b/app/models/issue/metrics.rb
index 0f5ee957ec9..8010cbc3d78 100644
--- a/app/models/issue/metrics.rb
+++ b/app/models/issue/metrics.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Issue::Metrics < ActiveRecord::Base
+class Issue::Metrics < ApplicationRecord
belongs_to :issue
def record!
diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb
index 400c0256945..fbd9be1fb43 100644
--- a/app/models/issue_assignee.rb
+++ b/app/models/issue_assignee.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class IssueAssignee < ActiveRecord::Base
+class IssueAssignee < ApplicationRecord
belongs_to :issue
belongs_to :assignee, class_name: "User", foreign_key: :user_id
end
diff --git a/app/models/key.rb b/app/models/key.rb
index 8f93418b88b..8aa25924c28 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -2,7 +2,7 @@
require 'digest/md5'
-class Key < ActiveRecord::Base
+class Key < ApplicationRecord
include AfterCommitQueue
include Sortable
@@ -59,6 +59,11 @@ class Key < ActiveRecord::Base
"key-#{id}"
end
+ # EE overrides this
+ def can_delete?
+ true
+ end
+
# rubocop: disable CodeReuse/ServiceClass
def update_last_used_at
Keys::LastUsedService.new(self).execute
diff --git a/app/models/label.rb b/app/models/label.rb
index 1c3db3eb35d..e9085e8bd25 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Label < ActiveRecord::Base
+class Label < ApplicationRecord
include CacheMarkdownField
include Referable
include Subscribable
@@ -8,6 +8,7 @@ class Label < ActiveRecord::Base
include OptionallySearch
include Sortable
include FromUnion
+ include Presentable
cache_markdown_field :description, pipeline: :single_line
@@ -126,6 +127,17 @@ class Label < ActiveRecord::Base
fuzzy_search(query, [:title, :description])
end
+ # Override Gitlab::SQL::Pattern.min_chars_for_partial_matching as
+ # label queries are never global, and so will not use a trigram
+ # index. That means we can have just one character in the LIKE.
+ def self.min_chars_for_partial_matching
+ 1
+ end
+
+ def self.by_ids(ids)
+ where(id: ids)
+ end
+
def open_issues_count(user = nil)
issues_count(user, state: 'opened')
end
@@ -222,6 +234,10 @@ class Label < ActiveRecord::Base
attributes
end
+ def present(attributes)
+ super(attributes.merge(presenter_class: ::LabelPresenter))
+ end
+
private
def issues_count(user, params = {})
diff --git a/app/models/label_link.rb b/app/models/label_link.rb
index 1d93a55e8e9..ffc0afd8e85 100644
--- a/app/models/label_link.rb
+++ b/app/models/label_link.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class LabelLink < ActiveRecord::Base
+class LabelLink < ApplicationRecord
include Importable
belongs_to :target, polymorphic: true, inverse_of: :label_links # rubocop:disable Cop/PolymorphicAssociations
diff --git a/app/models/label_note.rb b/app/models/label_note.rb
index 680952cf421..d6814f4a948 100644
--- a/app/models/label_note.rb
+++ b/app/models/label_note.rb
@@ -81,7 +81,7 @@ class LabelNote < Note
deleted = label_refs.count - existing_refs.count
deleted_str = deleted == 0 ? nil : "#{deleted} deleted"
- return nil unless refs_str || deleted_str
+ return unless refs_str || deleted_str
label_list_str = [refs_str, deleted_str].compact.join(' + ')
suffix = 'label'.pluralize(deleted > 0 ? deleted : existing_refs.count)
diff --git a/app/models/label_priority.rb b/app/models/label_priority.rb
index 8ed8bb7577f..8f8f36efbfe 100644
--- a/app/models/label_priority.rb
+++ b/app/models/label_priority.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class LabelPriority < ActiveRecord::Base
+class LabelPriority < ApplicationRecord
belongs_to :project
belongs_to :label
diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb
index 00dec6bb92b..e2c75bc7ee9 100644
--- a/app/models/legacy_diff_note.rb
+++ b/app/models/legacy_diff_note.rb
@@ -73,7 +73,7 @@ class LegacyDiffNote < Note
private
def find_diff
- return nil unless noteable
+ return unless noteable
return @diff if defined?(@diff)
@diff = noteable.raw_diffs(Commit.max_diff_options).find do |d|
diff --git a/app/models/lfs_file_lock.rb b/app/models/lfs_file_lock.rb
index 431d37e12e9..624b1d02e1a 100644
--- a/app/models/lfs_file_lock.rb
+++ b/app/models/lfs_file_lock.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class LfsFileLock < ActiveRecord::Base
+class LfsFileLock < ApplicationRecord
belongs_to :project
belongs_to :user
diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb
index 69c563545bb..5245dbc8d15 100644
--- a/app/models/lfs_object.rb
+++ b/app/models/lfs_object.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class LfsObject < ActiveRecord::Base
+class LfsObject < ApplicationRecord
include AfterCommitQueue
include ObjectStorage::BackgroundMove
@@ -13,7 +13,7 @@ class LfsObject < ActiveRecord::Base
mount_uploader :file, LfsObjectUploader
- after_save :update_file_store, if: :file_changed?
+ after_save :update_file_store, if: :saved_change_to_file?
def update_file_store
# The file.object_store is set during `uploader.store!`
diff --git a/app/models/lfs_objects_project.rb b/app/models/lfs_objects_project.rb
index 353602800d7..f9afb18c1d7 100644
--- a/app/models/lfs_objects_project.rb
+++ b/app/models/lfs_objects_project.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class LfsObjectsProject < ActiveRecord::Base
+class LfsObjectsProject < ApplicationRecord
belongs_to :project
belongs_to :lfs_object
diff --git a/app/models/list.rb b/app/models/list.rb
index 682af761ba0..17b1a8510cf 100644
--- a/app/models/list.rb
+++ b/app/models/list.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class List < ActiveRecord::Base
+class List < ApplicationRecord
belongs_to :board
belongs_to :label
diff --git a/app/models/member.rb b/app/models/member.rb
index 8e071a8ff21..c7583434148 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Member < ActiveRecord::Base
+class Member < ApplicationRecord
include AfterCommitQueue
include Sortable
include Importable
@@ -28,7 +28,7 @@ class Member < ActiveRecord::Base
presence: {
if: :invite?
},
- email: {
+ devise_email: {
allow_nil: true
},
uniqueness: {
@@ -80,6 +80,8 @@ class Member < ActiveRecord::Base
scope :owners_and_masters, -> { owners_and_maintainers } # @deprecated
scope :with_user, -> (user) { where(user: user) }
+ scope :with_source_id, ->(source_id) { where(source_id: source_id) }
+
scope :order_name_asc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'ASC')) }
scope :order_name_desc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'DESC')) }
scope :order_recent_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'DESC')) }
@@ -446,10 +448,10 @@ class Member < ActiveRecord::Base
end
def higher_access_level_than_group
- if highest_group_member && highest_group_member.access_level >= access_level
+ if highest_group_member && highest_group_member.access_level > access_level
error_parameters = { access: highest_group_member.human_access, group_name: highest_group_member.group.name }
- errors.add(:access_level, s_("should be higher than %{access} inherited membership from group %{group_name}") % error_parameters)
+ errors.add(:access_level, s_("should be greater than or equal to %{access} inherited membership from group %{group_name}") % error_parameters)
end
end
end
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index 2c9e1ba1d80..4cba69069bb 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -12,7 +12,9 @@ class GroupMember < Member
validates :source_type, format: { with: /\ANamespace\z/ }
default_scope { where(source_type: SOURCE_TYPE) }
- scope :in_groups, ->(groups) { where(source_id: groups.select(:id)) }
+ scope :of_groups, ->(groups) { where(source_id: groups.select(:id)) }
+
+ scope :count_users_by_group_id, -> { joins(:user).group(:source_id).count }
after_create :update_two_factor_requirement, unless: :invite?
after_destroy :update_two_factor_requirement, unless: :invite?
@@ -53,7 +55,7 @@ class GroupMember < Member
end
def post_update_hook
- if access_level_changed?
+ if saved_change_to_access_level?
run_after_commit { notification_service.update_group_member(self) }
end
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index 5372c6084f4..c64e2669b6a 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -111,7 +111,7 @@ class ProjectMember < Member
end
def post_update_hook
- if access_level_changed?
+ if saved_change_to_access_level?
run_after_commit { notification_service.update_project_member(self) }
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 75fca96ce0a..4fcaac75655 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class MergeRequest < ActiveRecord::Base
+class MergeRequest < ApplicationRecord
include AtomicInternalId
include IidRoutes
include Issuable
@@ -16,6 +16,7 @@ class MergeRequest < ActiveRecord::Base
include LabelEventable
include ReactiveCaching
include FromUnion
+ include DeprecatedAssignee
self.reactive_cache_key = ->(model) { [model.project.id, model.iid] }
self.reactive_cache_refresh_interval = 10.minutes
@@ -65,13 +66,15 @@ class MergeRequest < ActiveRecord::Base
dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :cached_closes_issues, through: :merge_requests_closing_issues, source: :issue
- has_many :merge_request_pipelines, foreign_key: 'merge_request_id', class_name: 'Ci::Pipeline'
+ has_many :pipelines_for_merge_request, foreign_key: 'merge_request_id', class_name: 'Ci::Pipeline'
+ has_many :suggestions, through: :notes
- belongs_to :assignee, class_name: "User"
+ has_many :merge_request_assignees
+ has_many :assignees, class_name: "User", through: :merge_request_assignees
serialize :merge_params, Hash # rubocop:disable Cop/ActiveRecordSerialize
- after_create :ensure_merge_request_diff, unless: :importing?
+ after_create :ensure_merge_request_diff
after_update :clear_memoized_shas
after_update :reload_diff_if_branch_changed
after_save :ensure_metrics
@@ -162,7 +165,7 @@ class MergeRequest < ActiveRecord::Base
validates :source_branch, presence: true
validates :target_project, presence: true
validates :target_branch, presence: true
- validates :merge_user, presence: true, if: :merge_when_pipeline_succeeds?, unless: :importing?
+ validates :merge_user, presence: true, if: :auto_merge_enabled?, unless: :importing?
validate :validate_branches, unless: [:allow_broken, :importing?, :closed_without_fork?]
validate :validate_fork, unless: :closed_without_fork?
validate :validate_target_project, on: :create
@@ -181,18 +184,44 @@ class MergeRequest < ActiveRecord::Base
end
scope :join_project, -> { joins(:target_project) }
scope :references_project, -> { references(:target_project) }
- scope :assigned, -> { where("assignee_id IS NOT NULL") }
- scope :unassigned, -> { where("assignee_id IS NULL") }
- scope :assigned_to, ->(u) { where(assignee_id: u.id)}
-
- participant :assignee
+ scope :with_api_entity_associations, -> {
+ preload(:assignees, :author, :notes, :labels, :milestone, :timelogs,
+ latest_merge_request_diff: [:merge_request_diff_commits],
+ metrics: [:latest_closed_by, :merged_by],
+ target_project: [:route, { namespace: :route }],
+ source_project: [:route, { namespace: :route }])
+ }
after_save :keep_around_commit
+ alias_attribute :project, :target_project
+ alias_attribute :project_id, :target_project_id
+ alias_attribute :auto_merge_enabled, :merge_when_pipeline_succeeds
+
def self.reference_prefix
'!'
end
+ def self.available_states
+ @available_states ||= super.merge(merged: 3, locked: 4)
+ end
+
+ # Returns the top 100 target branches
+ #
+ # The returned value is a Array containing branch names
+ # sort by updated_at of merge request:
+ #
+ # ['master', 'develop', 'production']
+ #
+ # limit - The maximum number of target branch to return.
+ def self.recent_target_branches(limit: 100)
+ group(:target_branch)
+ .select(:target_branch)
+ .reorder('MAX(merge_requests.updated_at) DESC')
+ .limit(limit)
+ .pluck(:target_branch)
+ end
+
def rebase_in_progress?
strong_memoize(:rebase_in_progress) do
# The source project can be deleted
@@ -206,7 +235,7 @@ class MergeRequest < ActiveRecord::Base
# branch head commit, for example checking if a merge request can be merged.
# For more information check: https://gitlab.com/gitlab-org/gitlab-ce/issues/40004
def actual_head_pipeline
- head_pipeline&.sha == diff_head_sha ? head_pipeline : nil
+ head_pipeline&.matches_sha_or_source_sha?(diff_head_sha) ? head_pipeline : nil
end
def merge_pipeline
@@ -286,12 +315,8 @@ class MergeRequest < ActiveRecord::Base
work_in_progress?(title) ? title : "WIP: #{title}"
end
- def commit_authors
- @commit_authors ||= commits.authors
- end
-
- def authors
- User.from_union([commit_authors, User.where(id: self.author_id)])
+ def committers
+ @committers ||= commits.committers
end
# Verifies if title has changed not taking into account WIP prefix
@@ -304,31 +329,6 @@ class MergeRequest < ActiveRecord::Base
Gitlab::HookData::MergeRequestBuilder.new(self).build
end
- # Returns a Hash of attributes to be used for Twitter card metadata
- def card_attributes
- {
- 'Author' => author.try(:name),
- 'Assignee' => assignee.try(:name)
- }
- end
-
- # These method are needed for compatibility with issues to not mess view and other code
- def assignees
- Array(assignee)
- end
-
- def assignee_ids
- Array(assignee_id)
- end
-
- def assignee_ids=(ids)
- write_attribute(:assignee_id, ids.last)
- end
-
- def assignee_or_author?(user)
- author_id == user.id || assignee_id == user.id
- end
-
# `from` argument can be a Namespace or Project.
def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{iid}"
@@ -392,7 +392,7 @@ class MergeRequest < ActiveRecord::Base
def merge_participants
participants = [author]
- if merge_when_pipeline_succeeds? && !participants.include?(merge_user)
+ if auto_merge_enabled? && !participants.include?(merge_user)
participants << merge_user
end
@@ -582,11 +582,15 @@ class MergeRequest < ActiveRecord::Base
end
def validate_branches
+ return unless target_project && source_project
+
if target_project == source_project && target_branch == source_branch
errors.add :branch_conflict, "You can't use same project/branch for source and target"
return
end
+ [:source_branch, :target_branch].each { |attr| validate_branch_name(attr) }
+
if opened?
similar_mrs = target_project
.merge_requests
@@ -607,6 +611,16 @@ class MergeRequest < ActiveRecord::Base
end
end
+ def validate_branch_name(attr)
+ return unless changes_include?(attr)
+
+ branch = read_attribute(attr)
+
+ return unless branch
+
+ errors.add(attr) unless Gitlab::GitRefValidator.validate_merge_request_branch(branch)
+ end
+
def validate_target_project
return true if target_project.merge_requests_enabled?
@@ -699,7 +713,7 @@ class MergeRequest < ActiveRecord::Base
end
def reload_diff_if_branch_changed
- if (source_branch_changed? || target_branch_changed?) &&
+ if (saved_change_to_source_branch? || saved_change_to_target_branch?) &&
(source_branch_head && target_branch_head)
reload_diff
end
@@ -711,19 +725,16 @@ class MergeRequest < ActiveRecord::Base
MergeRequests::ReloadDiffsService.new(self, current_user).execute
end
- # rubocop: enable CodeReuse/ServiceClass
- def check_if_can_be_merged
- return unless self.class.state_machines[:merge_status].check_state?(merge_status) && Gitlab::Database.read_write?
-
- can_be_merged =
- !broken? && project.repository.can_be_merged?(diff_head_sha, target_branch)
+ def check_mergeability
+ MergeRequests::MergeabilityCheckService.new(self).execute
+ end
+ # rubocop: enable CodeReuse/ServiceClass
- if can_be_merged
- mark_as_mergeable
- else
- mark_as_unmergeable
- end
+ # Returns boolean indicating the merge_status should be rechecked in order to
+ # switch to either can_be_merged or cannot_be_merged.
+ def recheck_merge_status?
+ self.class.state_machines[:merge_status].check_state?(merge_status)
end
def merge_event
@@ -749,7 +760,7 @@ class MergeRequest < ActiveRecord::Base
def mergeable?(skip_ci_check: false)
return false unless mergeable_state?(skip_ci_check: skip_ci_check)
- check_if_can_be_merged
+ check_mergeability
can_be_merged? && !should_be_rebased?
end
@@ -772,7 +783,7 @@ class MergeRequest < ActiveRecord::Base
project.ff_merge_must_be_possible? && !ff_merge_possible?
end
- def can_cancel_merge_when_pipeline_succeeds?(current_user)
+ def can_cancel_auto_merge?(current_user)
can_be_merged_by?(current_user) || self.author == current_user
end
@@ -791,6 +802,16 @@ class MergeRequest < ActiveRecord::Base
Gitlab::Utils.to_boolean(merge_params['force_remove_source_branch'])
end
+ def auto_merge_strategy
+ return unless auto_merge_enabled?
+
+ merge_params['auto_merge_strategy'] || AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS
+ end
+
+ def auto_merge_strategy=(strategy)
+ merge_params['auto_merge_strategy'] = strategy
+ end
+
def remove_source_branch?
should_remove_source_branch? || force_remove_source_branch?
end
@@ -807,15 +828,6 @@ class MergeRequest < ActiveRecord::Base
end
def related_notes
- # Fetch comments only from last 100 commits
- commits_for_notes_limit = 100
- commit_ids = commit_shas.take(commits_for_notes_limit)
-
- commit_notes = Note
- .except(:order)
- .where(project_id: [source_project_id, target_project_id])
- .for_commit_id(commit_ids)
-
# We're using a UNION ALL here since this results in better performance
# compared to using OR statements. We're using UNION ALL since the queries
# used won't produce any duplicates (e.g. a note for a commit can't also be
@@ -827,6 +839,16 @@ class MergeRequest < ActiveRecord::Base
alias_method :discussion_notes, :related_notes
+ def commit_notes
+ # Fetch comments only from last 100 commits
+ commit_ids = commit_shas.take(100)
+
+ Note
+ .user
+ .where(project_id: [source_project_id, target_project_id])
+ .for_commit_id(commit_ids)
+ end
+
def mergeable_discussions_state?
return true unless project.only_allow_merge_if_all_discussions_are_resolved?
@@ -837,10 +859,6 @@ class MergeRequest < ActiveRecord::Base
target_project != source_project
end
- def project
- target_project
- end
-
# If the merge request closes any issues, save this information in the
# `MergeRequestsClosingIssues` model. This is a performance optimization.
# Calculating this information for a number of merge requests requires
@@ -966,20 +984,6 @@ class MergeRequest < ActiveRecord::Base
end
end
- def reset_merge_when_pipeline_succeeds
- return unless merge_when_pipeline_succeeds?
-
- self.merge_when_pipeline_succeeds = false
- self.merge_user = nil
- if merge_params
- merge_params.delete('should_remove_source_branch')
- merge_params.delete('commit_message')
- merge_params.delete('squash_commit_message')
- end
-
- self.save
- end
-
# Return array of possible target branches
# depends on target project of MR
def target_branches
@@ -1049,6 +1053,16 @@ class MergeRequest < ActiveRecord::Base
@environments[current_user]
end
+ ##
+ # This method is for looking for active environments which created via pipelines for merge requests.
+ # Since deployments run on a merge request ref (e.g. `refs/merge-requests/:iid/head`),
+ # we cannot look up environments with source branch name.
+ def environments
+ return Environment.none unless actual_head_pipeline&.triggered_by_merge_request?
+
+ actual_head_pipeline.environments
+ end
+
def state_human_name
if merged?
"Merged"
@@ -1073,10 +1087,24 @@ class MergeRequest < ActiveRecord::Base
target_project.repository.fetch_source_branch!(source_project.repository, source_branch, ref_path)
end
+ # Returns the current merge-ref HEAD commit.
+ #
+ def merge_ref_head
+ project.repository.commit(merge_ref_path)
+ end
+
def ref_path
"refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/head"
end
+ def merge_ref_path
+ "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/merge"
+ end
+
+ def self.merge_request_ref?(ref)
+ ref.start_with?("refs/#{Repository::REF_MERGE_REQUEST}/")
+ end
+
def in_locked_state
begin
lock_mr
@@ -1113,12 +1141,18 @@ class MergeRequest < ActiveRecord::Base
diverged_commits_count > 0
end
- def all_pipelines(shas: all_commit_shas)
+ def all_pipelines
return Ci::Pipeline.none unless source_project
- @all_pipelines ||=
- source_project.ci_pipelines
- .for_merge_request(self, source_branch, all_commit_shas)
+ 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
+ end
end
def update_head_pipeline
@@ -1128,47 +1162,24 @@ class MergeRequest < ActiveRecord::Base
end
end
- def merge_request_pipeline_exists?
- merge_request_pipelines.exists?(sha: diff_head_sha)
- end
-
def has_test_reports?
- actual_head_pipeline&.has_test_reports?
+ actual_head_pipeline&.has_reports?(Ci::JobArtifact.test_reports)
end
def predefined_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
variables.append(key: 'CI_MERGE_REQUEST_ID', value: id.to_s)
variables.append(key: 'CI_MERGE_REQUEST_IID', value: iid.to_s)
-
- variables.append(key: 'CI_MERGE_REQUEST_REF_PATH',
- value: ref_path.to_s)
-
- variables.append(key: 'CI_MERGE_REQUEST_PROJECT_ID',
- value: project.id.to_s)
-
- variables.append(key: 'CI_MERGE_REQUEST_PROJECT_PATH',
- value: project.full_path)
-
- variables.append(key: 'CI_MERGE_REQUEST_PROJECT_URL',
- value: project.web_url)
-
- variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_NAME',
- value: target_branch.to_s)
-
- if source_project
- variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_ID',
- value: source_project.id.to_s)
-
- variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_PATH',
- value: source_project.full_path)
-
- variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_URL',
- value: source_project.web_url)
-
- variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_NAME',
- value: source_branch.to_s)
- end
+ variables.append(key: 'CI_MERGE_REQUEST_REF_PATH', value: ref_path.to_s)
+ variables.append(key: 'CI_MERGE_REQUEST_PROJECT_ID', value: project.id.to_s)
+ variables.append(key: 'CI_MERGE_REQUEST_PROJECT_PATH', value: project.full_path)
+ variables.append(key: 'CI_MERGE_REQUEST_PROJECT_URL', value: project.web_url)
+ variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_NAME', value: target_branch.to_s)
+ variables.append(key: 'CI_MERGE_REQUEST_TITLE', value: title)
+ variables.append(key: 'CI_MERGE_REQUEST_ASSIGNEES', value: assignee_username_list) if assignees.any?
+ variables.append(key: 'CI_MERGE_REQUEST_MILESTONE', value: milestone.title) if milestone
+ variables.append(key: 'CI_MERGE_REQUEST_LABELS', value: label_names.join(',')) if labels.present?
+ variables.concat(source_project_variables)
end
end
@@ -1300,7 +1311,7 @@ class MergeRequest < ActiveRecord::Base
end
def has_commits?
- merge_request_diff && commits_count > 0
+ merge_request_diff && commits_count.to_i > 0
end
def has_no_commits?
@@ -1369,10 +1380,20 @@ class MergeRequest < ActiveRecord::Base
source_project.repository.squash_in_progress?(id)
end
+ def find_actual_head_pipeline
+ all_pipelines.for_sha_or_source_sha(diff_head_sha).first
+ end
+
private
- def find_actual_head_pipeline
- source_project&.ci_pipelines
- &.latest_for_merge_request(self, source_branch, diff_head_sha)
+ def source_project_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ break variables unless source_project
+
+ variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_ID', value: source_project.id.to_s)
+ variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_PATH', value: source_project.full_path)
+ variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_URL', value: source_project.web_url)
+ variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_NAME', value: source_branch.to_s)
+ end
end
end
diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb
index 65e94a97b0a..05f8e18a2c1 100644
--- a/app/models/merge_request/metrics.rb
+++ b/app/models/merge_request/metrics.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class MergeRequest::Metrics < ActiveRecord::Base
+class MergeRequest::Metrics < ApplicationRecord
belongs_to :merge_request
belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id
belongs_to :latest_closed_by, class_name: 'User'
diff --git a/app/models/merge_request_assignee.rb b/app/models/merge_request_assignee.rb
new file mode 100644
index 00000000000..f0e6be51b7f
--- /dev/null
+++ b/app/models/merge_request_assignee.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class MergeRequestAssignee < ApplicationRecord
+ belongs_to :merge_request
+ belongs_to :assignee, class_name: "User", foreign_key: :user_id
+end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index e286a4e57f2..f45bd0e03de 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class MergeRequestDiff < ActiveRecord::Base
+class MergeRequestDiff < ApplicationRecord
include Sortable
include Importable
include ManualInverseAssociation
@@ -12,6 +12,10 @@ class MergeRequestDiff < ActiveRecord::Base
# Don't display more than 100 commits at once
COMMITS_SAFE_SIZE = 100
+ # Applies to closed or merged MRs when determining whether to migrate their
+ # diffs to external storage
+ EXTERNAL_DIFF_CUTOFF = 7.days.freeze
+
belongs_to :merge_request
manual_inverse_association :merge_request, :merge_request_diff
@@ -22,6 +26,8 @@ class MergeRequestDiff < ActiveRecord::Base
has_many :merge_request_diff_commits, -> { order(:merge_request_diff_id, :relative_order) }
+ validates :base_commit_sha, :head_commit_sha, :start_commit_sha, sha: true
+
state_machine :state, initial: :empty do
event :clean do
transition any => :without_files
@@ -45,7 +51,86 @@ class MergeRequestDiff < ActiveRecord::Base
joins(:merge_request_diff_commits).where(merge_request_diff_commits: { sha: sha }).reorder(nil)
end
+ 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 :not_latest_diffs, -> do
+ merge_requests = MergeRequest.arel_table
+ mr_diffs = arel_table
+
+ join_condition = merge_requests[:id].eq(mr_diffs[:merge_request_id])
+ .and(mr_diffs[:id].not_eq(merge_requests[:latest_merge_request_diff_id]))
+
+ arel_join = mr_diffs.join(merge_requests).on(join_condition)
+ joins(arel_join.join_sources)
+ end
+
+ scope :old_merged_diffs, -> (before) do
+ merge_requests = MergeRequest.arel_table
+ mr_metrics = MergeRequest::Metrics.arel_table
+ mr_diffs = arel_table
+
+ mr_join = mr_diffs
+ .join(merge_requests)
+ .on(mr_diffs[:merge_request_id].eq(merge_requests[:id]))
+
+ metrics_join_condition = mr_diffs[:merge_request_id]
+ .eq(mr_metrics[:merge_request_id])
+ .and(mr_metrics[:merged_at].not_eq(nil))
+
+ metrics_join = mr_diffs.join(mr_metrics).on(metrics_join_condition)
+
+ condition = MergeRequest.arel_table[:state].eq(:merged)
+ .and(MergeRequest::Metrics.arel_table[:merged_at].lteq(before))
+ .and(MergeRequest::Metrics.arel_table[:merged_at].not_eq(nil))
+
+ joins(metrics_join.join_sources, mr_join.join_sources).where(condition)
+ end
+
+ scope :old_closed_diffs, -> (before) do
+ condition = MergeRequest.arel_table[:state].eq(:closed)
+ .and(MergeRequest::Metrics.arel_table[:latest_closed_at].lteq(before))
+
+ joins(merge_request: :metrics).where(condition)
+ end
+
+ def self.ids_for_external_storage_migration(limit:)
+ # No point doing any work unless the feature is enabled
+ return [] unless Gitlab.config.external_diffs.enabled
+
+ case Gitlab.config.external_diffs.when
+ when 'always'
+ files_in_database.limit(limit).pluck(:id)
+ when 'outdated'
+ # Outdated is too complex to be a single SQL query, so split into three
+ before = EXTERNAL_DIFF_CUTOFF.ago
+
+ ids = files_in_database
+ .old_merged_diffs(before)
+ .limit(limit)
+ .pluck(:id)
+
+ return ids if ids.size >= limit
+
+ ids += files_in_database
+ .old_closed_diffs(before)
+ .limit(limit - ids.size)
+ .pluck(:id)
+
+ return ids if ids.size >= limit
+
+ ids + files_in_database
+ .not_latest_diffs
+ .limit(limit - ids.size)
+ .pluck(:id)
+ else
+ []
+ end
+ end
mount_uploader :external_diff, ExternalDiffUploader
@@ -53,7 +138,7 @@ class MergeRequestDiff < ActiveRecord::Base
# It allows you to override variables like head_commit_sha before getting diff.
after_create :save_git_content, unless: :importing?
- after_save :update_external_diff_store, if: :external_diff_changed?
+ after_save :update_external_diff_store, if: -> { !importing? && saved_change_to_external_diff? }
def self.find_by_diff_refs(diff_refs)
find_by(start_commit_sha: diff_refs.start_sha, head_commit_sha: diff_refs.head_sha, base_commit_sha: diff_refs.base_sha)
@@ -73,7 +158,14 @@ class MergeRequestDiff < ActiveRecord::Base
ensure_commit_shas
save_commits
save_diffs
+
+ # Another set of `after_save` hooks will be called here when we update the record
save
+ # We need to reset so that dirty tracking is reset when running the original set
+ # of `after_save` hooks that come after this `after_create` hook. Otherwise, the
+ # hooks that run when an attribute was changed are run twice.
+ reset
+
keep_around_commits
end
@@ -267,7 +359,7 @@ class MergeRequestDiff < ActiveRecord::Base
has_attribute?(:external_diff_store)
end
- def external_diff_changed?
+ def saved_change_to_external_diff?
super if has_attribute?(:external_diff)
end
@@ -284,32 +376,39 @@ class MergeRequestDiff < ActiveRecord::Base
return yield(@external_diff_file) if @external_diff_file
external_diff.open do |file|
- begin
- @external_diff_file = file
+ @external_diff_file = file
- yield(@external_diff_file)
- ensure
- @external_diff_file = nil
- end
+ yield(@external_diff_file)
+ ensure
+ @external_diff_file = nil
end
end
- private
+ # Transactionally migrate the current merge_request_diff_files entries to
+ # external storage. If external storage isn't an option for this diff, the
+ # method is a no-op.
+ def migrate_files_to_external_storage!
+ return if stored_externally? || !use_external_diff? || merge_request_diff_files.count == 0
- def create_merge_request_diff_files(diffs)
- rows =
- if has_attribute?(:external_diff) && Gitlab.config.external_diffs.enabled
- build_external_merge_request_diff_files(diffs)
- else
- build_merge_request_diff_files(diffs)
- end
+ rows = build_merge_request_diff_files(merge_request_diff_files)
- # Faster inserts
- Gitlab::Database.bulk_insert('merge_request_diff_files', rows)
+ transaction do
+ MergeRequestDiffFile.where(merge_request_diff_id: id).delete_all
+ create_merge_request_diff_files(rows)
+ save!
+ end
+
+ merge_request_diff_files.reset
end
- def build_external_merge_request_diff_files(diffs)
- rows = build_merge_request_diff_files(diffs)
+ private
+
+ def encode_in_base64?(diff_text)
+ (diff_text.encoding == Encoding::BINARY && !diff_text.ascii_only?) ||
+ diff_text.include?("\0")
+ end
+
+ def build_external_merge_request_diff_files(rows)
tempfile = build_external_diff_tempfile(rows)
self.external_diff = tempfile
@@ -320,16 +419,21 @@ class MergeRequestDiff < ActiveRecord::Base
tempfile&.unlink
end
+ def create_merge_request_diff_files(rows)
+ rows = build_external_merge_request_diff_files(rows) if use_external_diff?
+
+ # Faster inserts
+ Gitlab::Database.bulk_insert('merge_request_diff_files', rows)
+ end
+
def build_external_diff_tempfile(rows)
Tempfile.open(external_diff.filename) do |file|
- rows.inject(0) do |offset, row|
+ rows.each do |row|
data = row.delete(:diff)
- row[:external_diff_offset] = offset
- row[:external_diff_size] = data.size
+ row[:external_diff_offset] = file.pos
+ row[:external_diff_size] = data.bytesize
file.write(data)
-
- offset + data.size
end
file
@@ -348,7 +452,7 @@ class MergeRequestDiff < ActiveRecord::Base
diff_hash.tap do |hash|
diff_text = hash[:diff]
- if diff_text.encoding == Encoding::BINARY && !diff_text.ascii_only?
+ if encode_in_base64?(diff_text)
hash[:binary] = true
hash[:diff] = [diff_text].pack('m0')
end
@@ -356,6 +460,47 @@ class MergeRequestDiff < ActiveRecord::Base
end
end
+ def use_external_diff?
+ return false unless has_attribute?(:external_diff)
+ return false unless Gitlab.config.external_diffs.enabled
+
+ case Gitlab.config.external_diffs.when
+ when 'always'
+ true
+ when 'outdated'
+ outdated_by_merge? || outdated_by_closure? || old_version?
+ else
+ false # Disable external diffs if misconfigured
+ end
+ end
+
+ def outdated_by_merge?
+ return false unless merge_request&.metrics&.merged_at
+
+ merge_request.merged? && merge_request.metrics.merged_at < EXTERNAL_DIFF_CUTOFF.ago
+ end
+
+ def outdated_by_closure?
+ return false unless merge_request&.metrics&.latest_closed_at
+
+ merge_request.closed? && merge_request.metrics.latest_closed_at < EXTERNAL_DIFF_CUTOFF.ago
+ end
+
+ # We can't rely on `merge_request.latest_merge_request_diff_id` because that
+ # may have been changed in `save_git_content` without being reflected in
+ # the association's instance. This query is always subject to races, but
+ # the worst case is that we *don't* make a diff external when we could. The
+ # background worker will make it external at a later date.
+ def old_version?
+ latest_id = MergeRequest
+ .where(id: merge_request_id)
+ .limit(1)
+ .pluck(:latest_merge_request_diff_id)
+ .first
+
+ self.id != latest_id
+ end
+
def load_diffs(options)
# Ensure all diff files operate on the same external diff file instance if
# present. This reduces file open/close overhead.
@@ -389,7 +534,8 @@ class MergeRequestDiff < ActiveRecord::Base
if diff_collection.any?
new_attributes[:state] = :collected
- create_merge_request_diff_files(diff_collection)
+ rows = build_merge_request_diff_files(diff_collection)
+ create_merge_request_diff_files(rows)
end
# Set our state to 'overflow' to make the #empty? and #collected?
@@ -406,10 +552,10 @@ class MergeRequestDiff < ActiveRecord::Base
def save_commits
MergeRequestDiffCommit.create_bulk(self.id, compare.commits.reverse)
- # merge_request_diff_commits.reload is preferred way to reload associated
+ # merge_request_diff_commits.reset is preferred way to reload associated
# objects but it returns cached result for some reason in this case
# we can circumvent that by specifying that we need an uncached reload
- commits = self.class.uncached { merge_request_diff_commits.reload }
+ commits = self.class.uncached { merge_request_diff_commits.reset }
self.commits_count = commits.size
end
diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb
index 4ad3690512d..b897bbc8cf5 100644
--- a/app/models/merge_request_diff_commit.rb
+++ b/app/models/merge_request_diff_commit.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class MergeRequestDiffCommit < ActiveRecord::Base
+class MergeRequestDiffCommit < ApplicationRecord
include ShaAttribute
belongs_to :merge_request_diff
diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb
index e8d936e265c..01ee82ae398 100644
--- a/app/models/merge_request_diff_file.rb
+++ b/app/models/merge_request_diff_file.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class MergeRequestDiffFile < ActiveRecord::Base
+class MergeRequestDiffFile < ApplicationRecord
include Gitlab::EncodingHelper
include DiffFile
@@ -23,6 +23,6 @@ class MergeRequestDiffFile < ActiveRecord::Base
super
end
- binary? ? content.unpack('m0').first : content
+ binary? ? content.unpack1('m0') : content
end
end
diff --git a/app/models/merge_requests_closing_issues.rb b/app/models/merge_requests_closing_issues.rb
index 242b65bedc0..61af50841ee 100644
--- a/app/models/merge_requests_closing_issues.rb
+++ b/app/models/merge_requests_closing_issues.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class MergeRequestsClosingIssues < ActiveRecord::Base
+class MergeRequestsClosingIssues < ApplicationRecord
belongs_to :merge_request
belongs_to :issue
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index 26cfdc5ef30..37c129e843a 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Milestone < ActiveRecord::Base
+class Milestone < ApplicationRecord
# Represents a "No Milestone" state used for filtering Issues and Merge
# Requests that have no milestone assigned.
MilestoneStruct = Struct.new(:title, :name, :id)
@@ -37,6 +37,7 @@ class Milestone < ActiveRecord::Base
scope :active, -> { with_state(:active) }
scope :closed, -> { with_state(:closed) }
scope :for_projects, -> { where(group: nil).includes(:project) }
+ scope :started, -> { active.where('milestones.start_date <= CURRENT_DATE') }
scope :for_projects_and_groups, -> (projects, groups) do
projects = projects.compact if projects.is_a? Array
@@ -57,6 +58,7 @@ class Milestone < ActiveRecord::Base
validate :uniqueness_of_title, if: :title_changed?
validate :milestone_type_check
validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? }
+ validate :dates_within_4_digits
strip_attributes :title
@@ -149,7 +151,7 @@ class Milestone < ActiveRecord::Base
def self.upcoming_ids(projects, groups)
rel = unscoped
.for_projects_and_groups(projects, groups)
- .active.where('milestones.due_date > NOW()')
+ .active.where('milestones.due_date > CURRENT_DATE')
if Gitlab::Database.postgresql?
rel.order(:project_id, :group_id, :due_date).select('DISTINCT ON (project_id, group_id) id')
@@ -161,7 +163,7 @@ class Milestone < ActiveRecord::Base
ON milestones.project_id <=> earlier_milestones.project_id
AND milestones.group_id <=> earlier_milestones.group_id
AND milestones.due_date > earlier_milestones.due_date
- AND earlier_milestones.due_date > NOW()
+ AND earlier_milestones.due_date > CURRENT_DATE
AND earlier_milestones.state = 'active'
HEREDOC
@@ -290,22 +292,22 @@ class Milestone < ActiveRecord::Base
end
title_exists = relation.find_by_title(title)
- errors.add(:title, "already being used for another group or project milestone.") if title_exists
+ errors.add(:title, _("already being used for another group or project milestone.")) if title_exists
end
# Milestone should be either a project milestone or a group milestone
def milestone_type_check
if group_id && project_id
field = project_id_changed? ? :project_id : :group_id
- errors.add(field, "milestone should belong either to a project or a group.")
+ errors.add(field, _("milestone should belong either to a project or a group."))
end
end
def milestone_format_reference(format = :iid)
- raise ArgumentError, 'Unknown format' unless [:iid, :name].include?(format)
+ raise ArgumentError, _('Unknown format') unless [:iid, :name].include?(format)
if group_milestone? && format == :iid
- raise ArgumentError, 'Cannot refer to a group milestone by an internal id!'
+ raise ArgumentError, _('Cannot refer to a group milestone by an internal id!')
end
if format == :name && !name.include?('"')
@@ -321,7 +323,17 @@ class Milestone < ActiveRecord::Base
def start_date_should_be_less_than_due_date
if due_date <= start_date
- errors.add(:due_date, "must be greater than start date")
+ errors.add(:due_date, _("must be greater than start date"))
+ end
+ end
+
+ def dates_within_4_digits
+ if start_date && start_date > Date.new(9999, 12, 31)
+ errors.add(:start_date, _("date must not be after 9999-12-31"))
+ end
+
+ if due_date && due_date > Date.new(9999, 12, 31)
+ errors.add(:due_date, _("date must not be after 9999-12-31"))
end
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index f7592532c5b..3c270c7396a 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -11,6 +11,7 @@ class Namespace < ApplicationRecord
include IgnorableColumn
include FeatureGate
include FromUnion
+ include Gitlab::Utils::StrongMemoize
ignore_column :deleted_at
@@ -49,17 +50,20 @@ class Namespace < ApplicationRecord
validate :nesting_level_allowed
+ validates_associated :runners
+
delegate :name, to: :owner, allow_nil: true, prefix: true
+ delegate :avatar_url, to: :owner, allow_nil: true
after_commit :refresh_access_of_projects_invited_groups, on: :update, if: -> { previous_changes.key?('share_with_group_lock') }
before_create :sync_share_with_group_lock_with_parent
before_update :sync_share_with_group_lock_with_parent, if: :parent_changed?
- after_update :force_share_with_group_lock_on_descendants, if: -> { share_with_group_lock_changed? && share_with_group_lock? }
+ after_update :force_share_with_group_lock_on_descendants, if: -> { saved_change_to_share_with_group_lock? && share_with_group_lock? }
# Legacy Storage specific hooks
- after_update :move_dir, if: :path_or_parent_changed?
+ after_update :move_dir, if: :saved_change_to_path_or_parent?
before_destroy(prepend: true) { prepare_for_destroy }
after_destroy :rm_dir
@@ -72,8 +76,10 @@ class Namespace < ApplicationRecord
'namespaces.*',
'COALESCE(SUM(ps.storage_size), 0) AS storage_size',
'COALESCE(SUM(ps.repository_size), 0) AS repository_size',
+ 'COALESCE(SUM(ps.wiki_size), 0) AS wiki_size',
'COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size',
- 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size'
+ 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size',
+ 'COALESCE(SUM(ps.packages_size), 0) AS packages_size'
)
end
@@ -140,7 +146,7 @@ class Namespace < ApplicationRecord
def send_update_instructions
projects.each do |project|
- project.send_move_instructions("#{full_path_was}/#{project.path}")
+ project.send_move_instructions("#{full_path_before_last_save}/#{project.path}")
end
end
@@ -148,8 +154,12 @@ class Namespace < ApplicationRecord
type == 'Group' ? 'group' : 'user'
end
+ def user?
+ kind == 'user'
+ end
+
def find_fork_of(project)
- return nil unless project.fork_network
+ return unless project.fork_network
if Gitlab::SafeRequestStore.active?
forks_in_namespace = Gitlab::SafeRequestStore.fetch("namespaces:#{id}:forked_projects") do
@@ -196,12 +206,12 @@ class Namespace < ApplicationRecord
.ancestors(upto: top, hierarchy_order: hierarchy_order)
end
- def self_and_ancestors
+ def self_and_ancestors(hierarchy_order: nil)
return self.class.where(id: id) unless parent_id
Gitlab::ObjectHierarchy
.new(self.class.where(id: id))
- .base_and_ancestors
+ .base_and_ancestors(hierarchy_order: hierarchy_order)
end
# Returns all the descendants of the current namespace.
@@ -221,10 +231,6 @@ class Namespace < ApplicationRecord
[owner_id]
end
- def parent_changed?
- parent_id_changed?
- end
-
# Includes projects from this namespace and projects from all subgroups
# that belongs to this namespace
def all_projects
@@ -254,12 +260,12 @@ class Namespace < ApplicationRecord
false
end
- def full_path_was
- if parent_id_was.nil?
- path_was
+ def full_path_before_last_save
+ if parent_id_before_last_save.nil?
+ path_before_last_save
else
- previous_parent = Group.find_by(id: parent_id_was)
- previous_parent.full_path + '/' + path_was
+ previous_parent = Group.find_by(id: parent_id_before_last_save)
+ previous_parent.full_path + '/' + path_before_last_save
end
end
@@ -267,10 +273,34 @@ class Namespace < ApplicationRecord
owner.refresh_authorized_projects
end
+ def auto_devops_enabled?
+ first_auto_devops_config[:status]
+ end
+
+ def first_auto_devops_config
+ return { scope: :group, status: auto_devops_enabled } unless auto_devops_enabled.nil?
+
+ strong_memoize(:first_auto_devops_config) do
+ if has_parent?
+ parent.first_auto_devops_config
+ else
+ { scope: :instance, status: Gitlab::CurrentSettings.auto_devops_enabled? }
+ end
+ end
+ end
+
private
- def path_or_parent_changed?
- path_changed? || parent_changed?
+ def parent_changed?
+ parent_id_changed?
+ end
+
+ def saved_change_to_parent?
+ saved_change_to_parent_id?
+ end
+
+ def saved_change_to_path_or_parent?
+ saved_change_to_path? || saved_change_to_parent_id?
end
def refresh_access_of_projects_invited_groups
diff --git a/app/models/note.rb b/app/models/note.rb
index 1578ae9c4cc..081d6f91230 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -3,7 +3,7 @@
# A note on the root of an issue, merge request, commit, or snippet.
#
# A note of this type is never resolvable.
-class Note < ActiveRecord::Base
+class Note < ApplicationRecord
extend ActiveModel::Naming
include Participable
include Mentionable
@@ -313,6 +313,14 @@ class Note < ActiveRecord::Base
!system?
end
+ # Since we're using `updated_at` as `last_edited_at`, it could be touched by transforming / resolving a note.
+ # This makes sure it is only marked as edited when the note body is updated.
+ def edited?
+ return false if updated_by.blank?
+
+ super
+ end
+
def cross_reference_not_visible_for?(user)
cross_reference? && !all_referenced_mentionables_allowed?(user)
end
diff --git a/app/models/note_diff_file.rb b/app/models/note_diff_file.rb
index e369122003e..fcc9e2b3fd8 100644
--- a/app/models/note_diff_file.rb
+++ b/app/models/note_diff_file.rb
@@ -1,12 +1,16 @@
# frozen_string_literal: true
-class NoteDiffFile < ActiveRecord::Base
+class NoteDiffFile < ApplicationRecord
include DiffFile
scope :for_commit_or_unresolved, -> do
joins(:diff_note).where("resolved_at IS NULL OR noteable_type = 'Commit'")
end
+ scope :referencing_sha, -> (oids, project_id:) do
+ joins(:diff_note).where(notes: { project_id: project_id, commit_id: oids })
+ end
+
delegate :original_position, :project, to: :diff_note
belongs_to :diff_note, inverse_of: :note_diff_file
diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb
index 481c1d963c6..9b2bbb7eba5 100644
--- a/app/models/notification_recipient.rb
+++ b/app/models/notification_recipient.rb
@@ -47,14 +47,14 @@ class NotificationRecipient
def suitable_notification_level?
case notification_level
- when :disabled, nil
- false
- when :custom
- custom_enabled? || %i[participating mention].include?(@type)
- when :watch, :participating
- !action_excluded?
when :mention
@type == :mention
+ when :participating
+ @custom_action == :failed_pipeline || %i[participating mention].include?(@type)
+ when :custom
+ custom_enabled? || %i[participating mention].include?(@type)
+ when :watch
+ !excluded_watcher_action?
else
false
end
@@ -100,43 +100,37 @@ class NotificationRecipient
end
end
- def action_excluded?
- excluded_watcher_action? || excluded_participating_action?
- end
-
def excluded_watcher_action?
- return false unless @custom_action && notification_level == :watch
+ return false unless @custom_action
NotificationSetting::EXCLUDED_WATCHER_EVENTS.include?(@custom_action)
end
- def excluded_participating_action?
- return false unless @custom_action && notification_level == :participating
-
- NotificationSetting::EXCLUDED_PARTICIPATING_EVENTS.include?(@custom_action)
- end
-
private
def read_ability
- return nil if @skip_read_ability
+ return if @skip_read_ability
return @read_ability if instance_variable_defined?(:@read_ability)
@read_ability =
- case @target
- when Issuable
- :"read_#{@target.to_ability_name}"
- when Ci::Pipeline
+ if @target.is_a?(Ci::Pipeline)
:read_build # We have build trace in pipeline emails
- when ActiveRecord::Base
- :"read_#{@target.class.model_name.name.underscore}"
- else
- nil
+ elsif default_ability_for_target
+ :"read_#{default_ability_for_target}"
+ end
+ end
+
+ def default_ability_for_target
+ @default_ability_for_target ||=
+ if @target.respond_to?(:to_ability_name)
+ @target.to_ability_name
+ elsif @target.class.respond_to?(:model_name)
+ @target.class.model_name.name.underscore
end
end
def default_project
- return nil if @target.nil?
+ return if @target.nil?
return @target if @target.is_a?(Project)
return @target.project if @target.respond_to?(:project)
end
@@ -156,23 +150,11 @@ class NotificationRecipient
# Returns the notification_setting of the lowest group in hierarchy with non global level
def closest_non_global_group_notification_settting
return unless @group
- return if indexed_group_notification_settings.empty?
- notification_setting = nil
-
- @group.self_and_ancestors_ids.each do |id|
- notification_setting = indexed_group_notification_settings[id]
- break if notification_setting
- end
-
- notification_setting
- end
-
- def indexed_group_notification_settings
- strong_memoize(:indexed_group_notification_settings) do
- @group.notification_settings.where(user_id: user.id)
- .where.not(level: NotificationSetting.levels[:global])
- .index_by(&:source_id)
- end
+ @group
+ .notification_settings(hierarchy_order: :asc)
+ .where(user: user)
+ .where.not(level: NotificationSetting.levels[:global])
+ .first
end
end
diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb
index e82eaf4e069..8306b11a7b6 100644
--- a/app/models/notification_setting.rb
+++ b/app/models/notification_setting.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class NotificationSetting < ActiveRecord::Base
+class NotificationSetting < ApplicationRecord
include IgnorableColumn
ignore_column :events
@@ -54,14 +54,11 @@ class NotificationSetting < ActiveRecord::Base
self.class.email_events(source)
end
- EXCLUDED_PARTICIPATING_EVENTS = [
- :success_pipeline
- ].freeze
-
EXCLUDED_WATCHER_EVENTS = [
:push_to_merge_request,
- :issue_due
- ].push(*EXCLUDED_PARTICIPATING_EVENTS).freeze
+ :issue_due,
+ :success_pipeline
+ ].freeze
def self.find_or_create_for(source)
setting = find_or_initialize_by(source: source)
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index 7a33ade826b..524df30289e 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -1,10 +1,11 @@
# frozen_string_literal: true
-class PagesDomain < ActiveRecord::Base
+class PagesDomain < ApplicationRecord
VERIFICATION_KEY = 'gitlab-pages-verification-code'.freeze
VERIFICATION_THRESHOLD = 3.days.freeze
belongs_to :project
+ has_many :acme_orders, class_name: "PagesDomainAcmeOrder"
validates :domain, hostname: { allow_numeric_hostname: true }
validates :domain, uniqueness: { case_sensitive: false }
@@ -26,7 +27,7 @@ class PagesDomain < ActiveRecord::Base
after_initialize :set_verification_code
after_create :update_daemon
- after_update :update_daemon, if: :pages_config_changed?
+ after_update :update_daemon, if: :saved_change_to_pages_config?
after_destroy :update_daemon
scope :enabled, -> { where('enabled_until >= ?', Time.now ) }
@@ -38,6 +39,8 @@ class PagesDomain < ActiveRecord::Base
where(verified_at.eq(nil).or(enabled_until.eq(nil).or(enabled_until.lt(threshold))))
end
+ scope :for_removal, -> { where("remove_at < ?", Time.now) }
+
def verified?
!!verified_at
end
@@ -132,6 +135,14 @@ class PagesDomain < ActiveRecord::Base
"#{VERIFICATION_KEY}=#{verification_code}"
end
+ def certificate=(certificate)
+ super(certificate)
+
+ # set nil, if certificate is nil
+ self.certificate_valid_not_before = x509&.not_before
+ self.certificate_valid_not_after = x509&.not_after
+ end
+
private
def set_verification_code
@@ -146,21 +157,21 @@ class PagesDomain < ActiveRecord::Base
end
# rubocop: enable CodeReuse/ServiceClass
- def pages_config_changed?
- project_id_changed? ||
- domain_changed? ||
- certificate_changed? ||
- key_changed? ||
+ def saved_change_to_pages_config?
+ saved_change_to_project_id? ||
+ saved_change_to_domain? ||
+ saved_change_to_certificate? ||
+ saved_change_to_key? ||
became_enabled? ||
became_disabled?
end
def became_enabled?
- enabled_until.present? && !enabled_until_was.present?
+ enabled_until.present? && !enabled_until_before_last_save.present?
end
def became_disabled?
- !enabled_until.present? && enabled_until_was.present?
+ !enabled_until.present? && enabled_until_before_last_save.present?
end
def validate_matching_key
@@ -184,7 +195,7 @@ class PagesDomain < ActiveRecord::Base
end
def x509
- return unless certificate
+ return unless certificate.present?
@x509 ||= OpenSSL::X509::Certificate.new(certificate)
rescue OpenSSL::X509::CertificateError
diff --git a/app/models/pages_domain_acme_order.rb b/app/models/pages_domain_acme_order.rb
new file mode 100644
index 00000000000..63d7fbc8206
--- /dev/null
+++ b/app/models/pages_domain_acme_order.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class PagesDomainAcmeOrder < ApplicationRecord
+ belongs_to :pages_domain
+
+ scope :expired, -> { where("expires_at < ?", Time.now) }
+
+ validates :pages_domain, presence: true
+ validates :expires_at, presence: true
+ validates :url, presence: true
+ validates :challenge_token, presence: true
+ validates :challenge_file_content, presence: true
+ validates :private_key, presence: true
+
+ attr_encrypted :private_key,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ algorithm: 'aes-256-gcm',
+ encode: true
+
+ def self.find_by_domain_and_token(domain_name, challenge_token)
+ joins(:pages_domain).find_by(pages_domains: { domain: domain_name }, challenge_token: challenge_token)
+ end
+end
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index ed78a46eaf3..f69f0e2dccb 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -1,12 +1,10 @@
# frozen_string_literal: true
-class PersonalAccessToken < ActiveRecord::Base
+class PersonalAccessToken < ApplicationRecord
include Expirable
- include IgnorableColumn
include TokenAuthenticatable
add_authentication_token_field :token, digest: true
- ignore_column :token
REDIS_EXPIRY_TIME = 3.minutes
@@ -58,7 +56,7 @@ class PersonalAccessToken < ActiveRecord::Base
protected
def validate_scopes
- unless revoked || scopes.all? { |scope| Gitlab::Auth.available_scopes.include?(scope.to_sym) }
+ unless revoked || scopes.all? { |scope| Gitlab::Auth.all_available_scopes.include?(scope.to_sym) }
errors.add :scopes, "can only contain available scopes"
end
end
diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb
index 4635fc72dc7..50eed7344bd 100644
--- a/app/models/pool_repository.rb
+++ b/app/models/pool_repository.rb
@@ -3,11 +3,11 @@
# The PoolRepository model is the database equivalent of an ObjectPool for Gitaly
# That is; PoolRepository is the record in the database, ObjectPool is the
# repository on disk
-class PoolRepository < ActiveRecord::Base
+class PoolRepository < ApplicationRecord
include Shardable
include AfterCommitQueue
- has_one :source_project, class_name: 'Project'
+ belongs_to :source_project, class_name: 'Project'
validates :source_project, presence: true
has_many :member_projects, class_name: 'Project'
@@ -81,10 +81,7 @@ class PoolRepository < ActiveRecord::Base
object_pool.link(repository.raw)
end
- # This RPC can cause data loss, as not all objects are present the local repository
- def unlink_repository(repository)
- object_pool.unlink_repository(repository.raw)
-
+ def mark_obsolete_if_last(repository)
if member_projects.where.not(id: repository.project.id).exists?
true
else
@@ -102,7 +99,8 @@ class PoolRepository < ActiveRecord::Base
end
def inspect
- "#<#{self.class.name} id:#{id} state:#{state} disk_path:#{disk_path} source_project: #{source_project.full_path}>"
+ source = source_project ? source_project.full_path : 'nil'
+ "#<#{self.class.name} id:#{id} state:#{state} disk_path:#{disk_path} source_project: #{source}>"
end
private
diff --git a/app/models/postgresql/replication_slot.rb b/app/models/postgresql/replication_slot.rb
index e264fe88e47..74ccf23cf69 100644
--- a/app/models/postgresql/replication_slot.rb
+++ b/app/models/postgresql/replication_slot.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Postgresql
- class ReplicationSlot < ActiveRecord::Base
+ class ReplicationSlot < ApplicationRecord
self.table_name = 'pg_replication_slots'
# Returns true if there are any replication slots in use.
diff --git a/app/models/programming_language.rb b/app/models/programming_language.rb
index 5f0f313b7f9..375fbe9b5a9 100644
--- a/app/models/programming_language.rb
+++ b/app/models/programming_language.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProgrammingLanguage < ActiveRecord::Base
+class ProgrammingLanguage < ApplicationRecord
validates :name, presence: true
validates :color, allow_blank: false, color: true
diff --git a/app/models/project.rb b/app/models/project.rb
index 83f8d004a46..e64a4b313aa 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -2,7 +2,7 @@
require 'carrierwave/orm/activerecord'
-class Project < ActiveRecord::Base
+class Project < ApplicationRecord
include Gitlab::ConfigHelper
include Gitlab::ShellAdapter
include Gitlab::VisibilityLevel
@@ -38,7 +38,6 @@ class Project < ActiveRecord::Base
BoardLimitExceeded = Class.new(StandardError)
STATISTICS_ATTRIBUTE = 'repositories_count'.freeze
- NUMBER_OF_PERMITTED_BOARDS = 1
UNKNOWN_IMPORT_URL = 'http://unknown.git'.freeze
# Hashed Storage versions handle rolling out new storage to project and dependents models:
# nil: legacy
@@ -85,13 +84,13 @@ class Project < ActiveRecord::Base
default_value_for :snippets_enabled, gitlab_config_features.snippets
default_value_for :only_allow_merge_if_all_discussions_are_resolved, false
- add_authentication_token_field :runners_token, encrypted: true, migrating: true
+ add_authentication_token_field :runners_token, encrypted: -> { Feature.enabled?(:projects_tokens_optional_encryption, default_enabled: true) ? :optional : :required }
before_validation :mark_remote_mirrors_for_removal, if: -> { RemoteMirror.table_exists? }
before_save :ensure_runners_token
- after_save :update_project_statistics, if: :namespace_id_changed?
+ after_save :update_project_statistics, if: :saved_change_to_namespace_id?
after_save :create_import_state, if: ->(project) { project.import? && project.import_state.nil? }
@@ -117,7 +116,7 @@ class Project < ActiveRecord::Base
after_initialize :use_hashed_storage
after_create :check_repository_absence!
after_create :ensure_storage_path_exists
- after_save :ensure_storage_path_exists, if: :namespace_id_changed?
+ after_save :ensure_storage_path_exists, if: :saved_change_to_namespace_id?
acts_as_ordered_taggable
@@ -137,7 +136,7 @@ class Project < ActiveRecord::Base
alias_attribute :parent_id, :namespace_id
has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event'
- has_many :boards, before_add: :validate_board_limit
+ has_many :boards
# Project services
has_one :campfire_service
@@ -147,6 +146,7 @@ class Project < ActiveRecord::Base
has_one :pipelines_email_service
has_one :irker_service
has_one :pivotaltracker_service
+ has_one :hipchat_service
has_one :flowdock_service
has_one :assembla_service
has_one :asana_service
@@ -160,6 +160,7 @@ class Project < ActiveRecord::Base
has_one :pushover_service
has_one :jira_service
has_one :redmine_service
+ has_one :youtrack_service
has_one :custom_issue_tracker_service
has_one :bugzilla_service
has_one :gitlab_issue_tracker_service, inverse_of: :project
@@ -187,6 +188,7 @@ class Project < ActiveRecord::Base
has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :project_repository, inverse_of: :project
has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting'
+ has_one :metrics_setting, inverse_of: :project, class_name: 'ProjectMetricsSetting'
# Merge Requests for target project should be removed with it
has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project
@@ -290,12 +292,14 @@ class Project < ActiveRecord::Base
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 :remote_mirrors,
allow_destroy: true,
reject_if: ->(attrs) { attrs[:id].blank? && attrs[:url].blank? }
accepts_nested_attributes_for :error_tracking_setting, update_only: true
+ accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true
delegate :name, to: :owner, allow_nil: true, prefix: true
delegate :members, to: :team, prefix: true
@@ -306,13 +310,15 @@ class Project < ActiveRecord::Base
delegate :group_clusters_enabled?, to: :group, allow_nil: true
delegate :root_ancestor, 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
# Validations
validates :creator, presence: true, on: :create
validates :description, length: { maximum: 2000 }, allow_blank: true
validates :ci_config_path,
format: { without: %r{(\.{2}|\A/)},
- message: 'cannot include leading slash or directory traversal.' },
+ message: _('cannot include leading slash or directory traversal.') },
length: { maximum: 255 },
allow_blank: true
validates :name,
@@ -327,14 +333,14 @@ class Project < ActiveRecord::Base
validates :namespace, presence: true
validates :name, uniqueness: { scope: :namespace_id }
- validates :import_url, public_url: { protocols: ->(project) { project.persisted? ? VALID_MIRROR_PROTOCOLS : VALID_IMPORT_PROTOCOLS },
+ validates :import_url, public_url: { schemes: ->(project) { project.persisted? ? VALID_MIRROR_PROTOCOLS : VALID_IMPORT_PROTOCOLS },
ports: ->(project) { project.persisted? ? VALID_MIRROR_PORTS : VALID_IMPORT_PORTS },
enforce_user: true }, if: [:external_import?, :import_url_changed?]
validates :star_count, numericality: { greater_than_or_equal_to: 0 }
validate :check_personal_projects_limit, on: :create
validate :check_repository_path_availability, on: :update, if: ->(project) { project.renamed? }
- validate :visibility_level_allowed_by_group, if: -> { changes.has_key?(:visibility_level) }
- validate :visibility_level_allowed_as_fork, if: -> { changes.has_key?(:visibility_level) }
+ validate :visibility_level_allowed_by_group, if: :should_validate_visibility_level?
+ validate :visibility_level_allowed_as_fork, if: :should_validate_visibility_level?
validate :check_wiki_path_conflict
validate :validate_pages_https_only, if: -> { changes.has_key?(:pages_https_only) }
validates :repository_storage,
@@ -353,7 +359,8 @@ class Project < ActiveRecord::Base
# last_activity_at is throttled every minute, but last_repository_updated_at is updated with every push
scope :sorted_by_activity, -> { reorder("GREATEST(COALESCE(last_activity_at, '1970-01-01'), COALESCE(last_repository_updated_at, '1970-01-01')) DESC") }
- scope :sorted_by_stars, -> { reorder(star_count: :desc) }
+ scope :sorted_by_stars_desc, -> { reorder(star_count: :desc) }
+ scope :sorted_by_stars_asc, -> { reorder(star_count: :asc) }
scope :in_namespace, ->(namespace_ids) { where(namespace_id: namespace_ids) }
scope :personal, ->(user) { where(namespace_id: user.namespace_id) }
@@ -402,6 +409,7 @@ class Project < ActiveRecord::Base
scope :with_builds_enabled, -> { with_feature_enabled(:builds) }
scope :with_issues_enabled, -> { with_feature_enabled(:issues) }
scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) }
+ scope :with_merge_requests_available_for_user, ->(current_user) { with_feature_available_for_user(:merge_requests, current_user) }
scope :with_merge_requests_enabled, -> { with_feature_enabled(:merge_requests) }
scope :with_remote_mirrors, -> { joins(:remote_mirrors).where(remote_mirrors: { enabled: true }).distinct }
@@ -419,13 +427,13 @@ class Project < ActiveRecord::Base
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
chronic_duration_attr :build_timeout_human_readable, :build_timeout,
- default: 3600, error_message: 'Maximum job timeout has a value which could not be accepted'
+ default: 3600, error_message: _('Maximum job timeout has a value which could not be accepted')
validates :build_timeout, allow_nil: true,
numericality: { greater_than_or_equal_to: 10.minutes,
less_than: 1.month,
only_integer: true,
- message: 'needs to be beetween 10 minutes and 1 month' }
+ message: _('needs to be beetween 10 minutes and 1 month') }
# Used by Projects::CleanupService to hold a map of rewritten object IDs
mount_uploader :bfg_object_map, AttachmentUploader
@@ -459,10 +467,12 @@ class Project < ActiveRecord::Base
# Returns a collection of projects that is either public or visible to the
# logged in user.
- def self.public_or_visible_to_user(user = nil)
+ def self.public_or_visible_to_user(user = nil, min_access_level = nil)
+ min_access_level = nil if user&.admin?
+
if user
where('EXISTS (?) OR projects.visibility_level IN (?)',
- user.authorizations_for_projects,
+ user.authorizations_for_projects(min_access_level: min_access_level),
Gitlab::VisibilityLevel.levels_for_user(user))
else
public_to_user
@@ -472,30 +482,32 @@ class Project < ActiveRecord::Base
# project features may be "disabled", "internal", "enabled" or "public". If "internal",
# they are only available to team members. This scope returns projects where
# the feature is either public, enabled, or internal with permission for the user.
+ # Note: this scope doesn't enforce that the user has access to the projects, it just checks
+ # that the user has access to the feature. It's important to use this scope with others
+ # that checks project authorizations first.
#
# This method uses an optimised version of `with_feature_access_level` for
# logged in users to more efficiently get private projects with the given
# feature.
def self.with_feature_available_for_user(feature, user)
visible = [ProjectFeature::ENABLED, ProjectFeature::PUBLIC]
- min_access_level = ProjectFeature.required_minimum_access_level(feature)
if user&.admin?
with_feature_enabled(feature)
elsif user
+ min_access_level = ProjectFeature.required_minimum_access_level(feature)
column = ProjectFeature.quoted_access_level_column(feature)
with_project_feature
- .where(
- "(projects.visibility_level > :private AND (#{column} IS NULL OR #{column} >= (:public_visible) OR (#{column} = :private_visible AND EXISTS(:authorizations))))"\
- " OR (projects.visibility_level = :private AND (#{column} IS NULL OR #{column} >= :private_visible) AND EXISTS(:authorizations))",
- {
- private: Gitlab::VisibilityLevel::PRIVATE,
- public_visible: ProjectFeature::ENABLED,
- private_visible: ProjectFeature::PRIVATE,
- authorizations: user.authorizations_for_projects(min_access_level: min_access_level)
- })
+ .where("#{column} IS NULL OR #{column} IN (:public_visible) OR (#{column} = :private_visible AND EXISTS (:authorizations))",
+ {
+ public_visible: visible,
+ private_visible: ProjectFeature::PRIVATE,
+ authorizations: user.authorizations_for_projects(min_access_level: min_access_level)
+ })
else
+ # This has to be added to include features whose value is nil in the db
+ visible << nil
with_feature_access_level(feature, visible)
end
end
@@ -540,7 +552,9 @@ class Project < ActiveRecord::Base
when 'latest_activity_asc'
reorder(last_activity_at: :asc)
when 'stars_desc'
- sorted_by_stars
+ sorted_by_stars_desc
+ when 'stars_asc'
+ sorted_by_stars_asc
else
order_by(method)
end
@@ -586,6 +600,17 @@ class Project < ActiveRecord::Base
def group_ids
joins(:namespace).where(namespaces: { type: 'Group' }).select(:namespace_id)
end
+
+ # Returns ids of projects with milestones available for given user
+ #
+ # Used on queries to find milestones which user can see
+ # For example: Milestone.where(project_id: ids_with_milestone_available_for(user))
+ def ids_with_milestone_available_for(user)
+ with_issues_enabled = with_issues_available_for_user(user).select(:id)
+ with_merge_requests_enabled = with_merge_requests_available_for_user(user).select(:id)
+
+ from_union([with_issues_enabled, with_merge_requests_enabled]).select(:id)
+ end
end
def all_pipelines
@@ -630,12 +655,29 @@ class Project < ActiveRecord::Base
end
def has_auto_devops_implicitly_enabled?
- auto_devops&.enabled.nil? &&
- (Gitlab::CurrentSettings.auto_devops_enabled? || Feature.enabled?(:force_autodevops_on_by_default, self))
+ auto_devops_config = first_auto_devops_config
+
+ auto_devops_config[:scope] != :project && auto_devops_config[:status]
end
def has_auto_devops_implicitly_disabled?
- auto_devops&.enabled.nil? && !(Gitlab::CurrentSettings.auto_devops_enabled? || Feature.enabled?(:force_autodevops_on_by_default, self))
+ auto_devops_config = first_auto_devops_config
+
+ auto_devops_config[:scope] != :project && !auto_devops_config[:status]
+ end
+
+ def first_auto_devops_config
+ return namespace.first_auto_devops_config if auto_devops&.enabled.nil?
+
+ { scope: :project, status: auto_devops&.enabled || Feature.enabled?(:force_autodevops_on_by_default, self) }
+ end
+
+ def multiple_mr_assignees_enabled?
+ Feature.enabled?(:multiple_merge_request_assignees, self)
+ end
+
+ def daily_statistics_enabled?
+ Feature.enabled?(:project_daily_statistics, self, default_enabled: true)
end
def empty_repo?
@@ -832,7 +874,7 @@ class Project < ActiveRecord::Base
def mark_stuck_remote_mirrors_as_failed!
remote_mirrors.stuck.update_all(
update_status: :failed,
- last_error: 'The remote mirror took to long to complete.',
+ last_error: _('The remote mirror took to long to complete.'),
last_update_at: Time.now
)
end
@@ -864,19 +906,23 @@ class Project < ActiveRecord::Base
self.errors.add(:limit_reached, error % { limit: limit })
end
+ def should_validate_visibility_level?
+ new_record? || changes.has_key?(:visibility_level)
+ end
+
def visibility_level_allowed_by_group
return if visibility_level_allowed_by_group?
level_name = Gitlab::VisibilityLevel.level_name(self.visibility_level).downcase
group_level_name = Gitlab::VisibilityLevel.level_name(self.group.visibility_level).downcase
- self.errors.add(:visibility_level, "#{level_name} is not allowed in a #{group_level_name} group.")
+ self.errors.add(:visibility_level, _("%{level_name} is not allowed in a %{group_level_name} group.") % { level_name: level_name, group_level_name: group_level_name })
end
def visibility_level_allowed_as_fork
return if visibility_level_allowed_as_fork?
level_name = Gitlab::VisibilityLevel.level_name(self.visibility_level).downcase
- self.errors.add(:visibility_level, "#{level_name} is not allowed since the fork source project has lower visibility.")
+ self.errors.add(:visibility_level, _("%{level_name} is not allowed since the fork source project has lower visibility.") % { level_name: level_name })
end
def check_wiki_path_conflict
@@ -885,7 +931,7 @@ class Project < ActiveRecord::Base
path_to_check = path.ends_with?('.wiki') ? path.chomp('.wiki') : "#{path}.wiki"
if Project.where(namespace_id: namespace_id, path: path_to_check).exists?
- errors.add(:name, 'has already been taken')
+ errors.add(:name, _('has already been taken'))
end
end
@@ -905,7 +951,7 @@ class Project < ActiveRecord::Base
return unless pages_https_only?
unless pages_domains.all?(&:https?)
- errors.add(:pages_https_only, "cannot be enabled unless all domains have TLS certificates")
+ errors.add(:pages_https_only, _("cannot be enabled unless all domains have TLS certificates"))
end
end
@@ -1185,7 +1231,7 @@ class Project < ActiveRecord::Base
def valid_repo?
repository.exists?
rescue
- errors.add(:path, 'Invalid repository path')
+ errors.add(:path, _('Invalid repository path'))
false
end
@@ -1195,11 +1241,9 @@ class Project < ActiveRecord::Base
def repo_exists?
strong_memoize(:repo_exists) do
- begin
- repository.exists?
- rescue
- false
- end
+ repository.exists?
+ rescue
+ false
end
end
@@ -1225,7 +1269,7 @@ class Project < ActiveRecord::Base
end
def fork_source
- return nil unless forked?
+ return unless forked?
forked_from_project || fork_network&.root_project
end
@@ -1278,7 +1322,7 @@ class Project < ActiveRecord::Base
# Check if repository with same path already exists on disk we can
# skip this for the hashed storage because the path does not change
if legacy_storage? && repository_with_same_path_already_exists?
- errors.add(:base, 'There is already a repository with that name on disk')
+ errors.add(:base, _('There is already a repository with that name on disk'))
return false
end
@@ -1300,7 +1344,7 @@ class Project < ActiveRecord::Base
repository.after_create
true
else
- errors.add(:base, 'Failed to create repository via gitlab-shell')
+ errors.add(:base, _('Failed to create repository via gitlab-shell'))
false
end
end
@@ -1373,9 +1417,10 @@ class Project < ActiveRecord::Base
repository.raw_repository.write_ref('HEAD', "refs/heads/#{branch}")
repository.copy_gitattributes(branch)
repository.after_change_head
+ ProjectCacheWorker.perform_async(self.id, [], [:commit_count])
reload_default_branch
else
- errors.add(:base, "Could not change HEAD: branch '#{branch}' does not exist")
+ errors.add(:base, _("Could not change HEAD: branch '%{branch}' does not exist") % { branch: branch })
false
end
end
@@ -1413,7 +1458,7 @@ class Project < ActiveRecord::Base
# update visibility_level of forks
def update_forks_visibility_level
- return unless visibility_level < visibility_level_was
+ return unless visibility_level < visibility_level_before_last_save
forks.each do |forked_project|
if forked_project.visibility_level > visibility_level
@@ -1427,7 +1472,7 @@ class Project < ActiveRecord::Base
ProjectWiki.new(self, self.owner).wiki
true
rescue ProjectWiki::CouldNotCreateWikiError
- errors.add(:base, 'Failed create wiki')
+ errors.add(:base, _('Failed create wiki'))
false
end
@@ -1674,7 +1719,7 @@ class Project < ActiveRecord::Base
end
def export_path
- return nil unless namespace.present? || hashed_storage?(:repository)
+ return unless namespace.present? || hashed_storage?(:repository)
import_export_shared.archive_path
end
@@ -1838,7 +1883,7 @@ class Project < ActiveRecord::Base
# Set repository as writable again
def set_repository_writable!
with_lock do
- update_column(repository_read_only, false)
+ update_column(:repository_read_only, false)
end
end
@@ -1893,8 +1938,8 @@ class Project < ActiveRecord::Base
false
end
- def full_path_was
- File.join(namespace.full_path, previous_changes['path'].first)
+ def full_path_before_last_save
+ File.join(namespace.full_path, path_before_last_save)
end
alias_method :name_with_namespace, :full_name
@@ -1916,7 +1961,7 @@ class Project < ActiveRecord::Base
#
# @param [Symbol] feature that needs to be rolled out for the project (:repository, :attachments)
def hashed_storage?(feature)
- raise ArgumentError, "Invalid feature" unless HASHED_STORAGE_FEATURES.include?(feature)
+ raise ArgumentError, _("Invalid feature") unless HASHED_STORAGE_FEATURES.include?(feature)
self.storage_version && self.storage_version >= HASHED_STORAGE_FEATURES[feature]
end
@@ -1925,6 +1970,14 @@ class Project < ActiveRecord::Base
persisted? && path_changed?
end
+ def human_merge_method
+ if merge_method == :ff
+ 'Fast-forward'
+ else
+ merge_method.to_s.humanize
+ end
+ end
+
def merge_method
if self.merge_requests_ff_only_enabled
:ff
@@ -1957,9 +2010,19 @@ class Project < ActiveRecord::Base
return unless storage_upgradable?
if git_transfer_in_progress?
- ProjectMigrateHashedStorageWorker.perform_in(Gitlab::ReferenceCounter::REFERENCE_EXPIRE_TIME, id)
+ HashedStorage::ProjectMigrateWorker.perform_in(Gitlab::ReferenceCounter::REFERENCE_EXPIRE_TIME, id)
else
- ProjectMigrateHashedStorageWorker.perform_async(id)
+ HashedStorage::ProjectMigrateWorker.perform_async(id)
+ end
+ end
+
+ def rollback_to_legacy_storage!
+ return if legacy_storage?
+
+ if git_transfer_in_progress?
+ HashedStorage::ProjectRollbackWorker.perform_in(Gitlab::ReferenceCounter::REFERENCE_EXPIRE_TIME, id)
+ else
+ HashedStorage::ProjectRollbackWorker.perform_async(id)
end
end
@@ -1973,12 +2036,8 @@ class Project < ActiveRecord::Base
@storage = nil if storage_version_changed?
end
- def gl_repository(is_wiki:)
- Gitlab::GlRepository.gl_repository(self, is_wiki)
- end
-
- def reference_counter(wiki: false)
- Gitlab::ReferenceCounter.new(gl_repository(is_wiki: wiki))
+ def reference_counter(type: Gitlab::GlRepository::PROJECT)
+ Gitlab::ReferenceCounter.new(type.identifier_for_subject(self))
end
def badges
@@ -2009,6 +2068,11 @@ class Project < ActiveRecord::Base
fetch_branch_allows_collaboration(user, branch_name)
end
+ def external_authorization_classification_label
+ super || ::Gitlab::CurrentSettings.current_application_settings
+ .external_authorization_service_default_label
+ end
+
def licensed_features
[]
end
@@ -2075,7 +2139,7 @@ class Project < ActiveRecord::Base
end
def leave_pool_repository
- pool_repository&.unlink_repository(repository) && update_column(:pool_repository_id, nil)
+ pool_repository&.mark_obsolete_if_last(repository) && update_column(:pool_repository_id, nil)
end
def link_pool_repository
@@ -2095,13 +2159,11 @@ class Project < ActiveRecord::Base
end
def create_new_pool_repository
- pool = begin
- create_pool_repository!(shard: Shard.by_name(repository_storage), source_project: self)
- rescue ActiveRecord::RecordNotUnique
- pool_repository(true)
- end
+ pool = PoolRepository.safe_find_or_create_by!(shard: Shard.by_name(repository_storage), source_project: self)
+ update!(pool_repository: pool)
pool.schedule unless pool.scheduled?
+
pool
end
@@ -2122,14 +2184,14 @@ class Project < ActiveRecord::Base
end
def wiki_reference_count
- reference_counter(wiki: true).value
+ reference_counter(type: Gitlab::GlRepository::WIKI).value
end
def check_repository_absence!
return if skip_disk_validation
if repository_storage.blank? || repository_with_same_path_already_exists?
- errors.add(:base, 'There is already a repository with that name on disk')
+ errors.add(:base, _('There is already a repository with that name on disk'))
throw :abort
end
end
@@ -2162,17 +2224,6 @@ class Project < ActiveRecord::Base
"projects/#{id}/pushes_since_gc"
end
- # Similar to the normal callbacks that hook into the life cycle of an
- # Active Record object, you can also define callbacks that get triggered
- # when you add an object to an association collection. If any of these
- # callbacks throw an exception, the object will not be added to the
- # collection. Before you add a new board to the boards collection if you
- # already have 1, 2, or n it will fail, but it if you have 0 that is lower
- # than the number of permitted boards per project it won't fail.
- def validate_board_limit(board)
- raise BoardLimitExceeded, 'Number of permitted boards exceeded' if boards.size >= NUMBER_OF_PERMITTED_BOARDS
- end
-
def update_project_statistics
stats = statistics || build_statistics
stats.update(namespace_id: namespace_id)
@@ -2186,7 +2237,7 @@ class Project < ActiveRecord::Base
errors.delete(error)
end
- errors.add(:base, "The project is still being deleted. Please try again later.")
+ errors.add(:base, _("The project is still being deleted. Please try again later."))
end
def pending_delete_twin
diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb
index 2c590008db2..f95d3ab54e2 100644
--- a/app/models/project_authorization.rb
+++ b/app/models/project_authorization.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProjectAuthorization < ActiveRecord::Base
+class ProjectAuthorization < ApplicationRecord
include FromUnion
belongs_to :user
diff --git a/app/models/project_auto_devops.rb b/app/models/project_auto_devops.rb
index e353a6443c4..67c12363a3c 100644
--- a/app/models/project_auto_devops.rb
+++ b/app/models/project_auto_devops.rb
@@ -1,6 +1,10 @@
# frozen_string_literal: true
-class ProjectAutoDevops < ActiveRecord::Base
+class ProjectAutoDevops < ApplicationRecord
+ include IgnorableColumn
+
+ ignore_column :domain
+
belongs_to :project
enum deploy_strategy: {
@@ -12,31 +16,10 @@ class ProjectAutoDevops < ActiveRecord::Base
scope :enabled, -> { where(enabled: true) }
scope :disabled, -> { where(enabled: false) }
- validates :domain, allow_blank: true, hostname: { allow_numeric_hostname: true }
-
after_save :create_gitlab_deploy_token, if: :needs_to_create_deploy_token?
- def instance_domain
- Gitlab::CurrentSettings.auto_devops_domain
- end
-
- def has_domain?
- domain.present? || instance_domain.present?
- end
-
- # From 11.8, AUTO_DEVOPS_DOMAIN has been replaced by KUBE_INGRESS_BASE_DOMAIN.
- # See Clusters::Cluster#predefined_variables and https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/24580
- # for more info.
- #
- # Suppport AUTO_DEVOPS_DOMAIN is scheduled to be removed on
- # https://gitlab.com/gitlab-org/gitlab-ce/issues/52363
def predefined_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
- if has_domain?
- variables.append(key: 'AUTO_DEVOPS_DOMAIN',
- value: domain.presence || instance_domain)
- end
-
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 1dad235cc2b..492d50766ea 100644
--- a/app/models/project_ci_cd_setting.rb
+++ b/app/models/project_ci_cd_setting.rb
@@ -1,11 +1,23 @@
# frozen_string_literal: true
-class ProjectCiCdSetting < ActiveRecord::Base
+class ProjectCiCdSetting < ApplicationRecord
belongs_to :project, inverse_of: :ci_cd_settings
# The version of the schema that first introduced this model/table.
MINIMUM_SCHEMA_VERSION = 20180403035759
+ DEFAULT_GIT_DEPTH = 50
+
+ before_create :set_default_git_depth
+
+ validates :default_git_depth,
+ numericality: {
+ only_integer: true,
+ greater_than_or_equal_to: 0,
+ less_than_or_equal_to: 1000
+ },
+ allow_nil: true
+
def self.available?
@available ||=
ActiveRecord::Migrator.current_version >= MINIMUM_SCHEMA_VERSION
@@ -15,4 +27,10 @@ class ProjectCiCdSetting < ActiveRecord::Base
@available = nil
super
end
+
+ private
+
+ def set_default_git_depth
+ self.default_git_depth ||= DEFAULT_GIT_DEPTH
+ end
end
diff --git a/app/models/project_custom_attribute.rb b/app/models/project_custom_attribute.rb
index 4e767cb3b26..b0da586988a 100644
--- a/app/models/project_custom_attribute.rb
+++ b/app/models/project_custom_attribute.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProjectCustomAttribute < ActiveRecord::Base
+class ProjectCustomAttribute < ApplicationRecord
belongs_to :project
validates :project, :key, :value, presence: true
diff --git a/app/models/project_daily_statistic.rb b/app/models/project_daily_statistic.rb
new file mode 100644
index 00000000000..5ee11ab186e
--- /dev/null
+++ b/app/models/project_daily_statistic.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class ProjectDailyStatistic < ApplicationRecord
+ belongs_to :project
+
+ scope :of_project, -> (project) { where(project: project) }
+ scope :of_last_30_days, -> { where('date >= ?', 29.days.ago.utc.to_date) }
+ scope :sorted_by_date_desc, -> { order(project_id: :desc, date: :desc) }
+ scope :sum_fetch_count, -> { sum(:fetch_count) }
+end
diff --git a/app/models/project_deploy_token.rb b/app/models/project_deploy_token.rb
index 719c492a1ff..a55667496fb 100644
--- a/app/models/project_deploy_token.rb
+++ b/app/models/project_deploy_token.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProjectDeployToken < ActiveRecord::Base
+class ProjectDeployToken < ApplicationRecord
belongs_to :project
belongs_to :deploy_token, inverse_of: :project_deploy_tokens
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index f700090a493..6bcb051bff6 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProjectFeature < ActiveRecord::Base
+class ProjectFeature < ApplicationRecord
# == Project features permissions
#
# Grants access level to project tools
@@ -72,11 +72,13 @@ class ProjectFeature < ActiveRecord::Base
default_value_for :wiki_access_level, value: ENABLED, allows_nil: false
default_value_for :repository_access_level, value: ENABLED, allows_nil: false
+ scope :for_project_id, -> (project) { where(project: project) }
+
def feature_available?(feature, user)
# This feature might not be behind a feature flag at all, so default to true
return false unless ::Feature.enabled?(feature, user, default_enabled: true)
- get_permission(user, access_level(feature))
+ get_permission(user, feature)
end
def access_level(feature)
@@ -134,12 +136,12 @@ class ProjectFeature < ActiveRecord::Base
(FEATURES - %i(pages)).each {|f| validator.call("#{f}_access_level")}
end
- def get_permission(user, level)
- case level
+ def get_permission(user, feature)
+ case access_level(feature)
when DISABLED
false
when PRIVATE
- user && (project.team.member?(user) || user.full_private_access?)
+ team_access?(user, feature)
when ENABLED
true
when PUBLIC
@@ -148,4 +150,11 @@ class ProjectFeature < ActiveRecord::Base
true
end
end
+
+ def team_access?(user, feature)
+ return unless user
+ return true if user.full_private_access?
+
+ project.team.member?(user, ProjectFeature.required_minimum_access_level(feature))
+ end
end
diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb
index bc3759142ae..feaf172d48d 100644
--- a/app/models/project_group_link.rb
+++ b/app/models/project_group_link.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProjectGroupLink < ActiveRecord::Base
+class ProjectGroupLink < ApplicationRecord
include Expirable
GUEST = 10
@@ -14,7 +14,7 @@ class ProjectGroupLink < ActiveRecord::Base
validates :project_id, presence: true
validates :group, presence: true
- validates :group_id, uniqueness: { scope: [:project_id], message: "already shared with this group" }
+ validates :group_id, uniqueness: { scope: [:project_id], message: _("already shared with this group") }
validates :group_access, presence: true
validates :group_access, inclusion: { in: Gitlab::Access.values }, presence: true
validate :different_group
@@ -44,7 +44,7 @@ class ProjectGroupLink < ActiveRecord::Base
group_ids = project_group.ancestors.map(&:id).push(project_group.id)
if group_ids.include?(self.group.id)
- errors.add(:base, "Project cannot be shared with the group it is in or one of its ancestors.")
+ errors.add(:base, _("Project cannot be shared with the group it is in or one of its ancestors."))
end
end
diff --git a/app/models/project_import_data.rb b/app/models/project_import_data.rb
index aa0c121fe99..580e8dfd833 100644
--- a/app/models/project_import_data.rb
+++ b/app/models/project_import_data.rb
@@ -2,7 +2,7 @@
require 'carrierwave/orm/activerecord'
-class ProjectImportData < ActiveRecord::Base
+class ProjectImportData < ApplicationRecord
belongs_to :project, inverse_of: :import_data
attr_encrypted :credentials,
key: Settings.attr_encrypted_db_key_base,
diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb
index 488f0cb5971..1605345efd5 100644
--- a/app/models/project_import_state.rb
+++ b/app/models/project_import_state.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProjectImportState < ActiveRecord::Base
+class ProjectImportState < ApplicationRecord
include AfterCommitQueue
self.table_name = "project_mirror_data"
diff --git a/app/models/project_metrics_setting.rb b/app/models/project_metrics_setting.rb
new file mode 100644
index 00000000000..a2a7dc571a4
--- /dev/null
+++ b/app/models/project_metrics_setting.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class ProjectMetricsSetting < ApplicationRecord
+ belongs_to :project
+
+ validates :external_dashboard_url,
+ length: { maximum: 255 },
+ addressable_url: { enforce_sanitization: true, ascii_only: true }
+end
diff --git a/app/models/project_repository.rb b/app/models/project_repository.rb
index 38913f3f2f5..092efabd73f 100644
--- a/app/models/project_repository.rb
+++ b/app/models/project_repository.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProjectRepository < ActiveRecord::Base
+class ProjectRepository < ApplicationRecord
include Shardable
belongs_to :project, inverse_of: :project_repository
diff --git a/app/models/project_services/asana_service.rb b/app/models/project_services/asana_service.rb
index cc5f1207653..3e28dc23782 100644
--- a/app/models/project_services/asana_service.rb
+++ b/app/models/project_services/asana_service.rb
@@ -11,7 +11,7 @@ class AsanaService < Service
end
def description
- 'Asana - Teamwork without email'
+ s_('AsanaService|Asana - Teamwork without email')
end
def help
@@ -36,13 +36,13 @@ http://app.asana.com/-/account_api'
{
type: 'text',
name: 'api_key',
- placeholder: 'User Personal Access Token. User must have access to task, all comments will be attributed to this user.',
+ placeholder: s_('AsanaService|User Personal Access Token. User must have access to task, all comments will be attributed to this user.'),
required: true
},
{
type: 'text',
name: 'restrict_to_branch',
- placeholder: 'Comma-separated list of branches which will be automatically inspected. Leave blank to include all branches.'
+ placeholder: s_('AsanaService|Comma-separated list of branches which will be automatically inspected. Leave blank to include all branches.')
}
]
end
@@ -73,7 +73,7 @@ http://app.asana.com/-/account_api'
project_name = project.full_name
data[:commits].each do |commit|
- push_msg = "#{user} pushed to branch #{branch} of #{project_name} ( #{commit[:url]} ):"
+ push_msg = s_("AsanaService|%{user} pushed to branch %{branch} of %{project_name} ( %{commit_url} ):") % { user: user, branch: branch, project_name: project_name, commit_url: commit[:url] }
check_commit(commit[:message], push_msg)
end
end
diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb
index 71f5607dbdb..dfeb21680a9 100644
--- a/app/models/project_services/bamboo_service.rb
+++ b/app/models/project_services/bamboo_service.rb
@@ -31,15 +31,15 @@ class BambooService < CiService
end
def title
- 'Atlassian Bamboo CI'
+ s_('BambooService|Atlassian Bamboo CI')
end
def description
- 'A continuous integration and build server'
+ s_('BambooService|A continuous integration and build server')
end
def help
- 'You must set up automatic revision labeling and a repository trigger in Bamboo.'
+ s_('BambooService|You must set up automatic revision labeling and a repository trigger in Bamboo.')
end
def self.to_param
@@ -49,11 +49,11 @@ class BambooService < CiService
def fields
[
{ type: 'text', name: 'bamboo_url',
- placeholder: 'Bamboo root URL like https://bamboo.example.com', required: true },
+ placeholder: s_('BambooService|Bamboo root URL like https://bamboo.example.com'), required: true },
{ type: 'text', name: 'build_key',
- placeholder: 'Bamboo build plan key like KEY', required: true },
+ placeholder: s_('BambooService|Bamboo build plan key like KEY'), required: true },
{ type: 'text', name: 'username',
- placeholder: 'A user with API access, if applicable' },
+ placeholder: s_('BambooService|A user with API access, if applicable') },
{ type: 'password', name: 'password' }
]
end
diff --git a/app/models/project_services/campfire_service.rb b/app/models/project_services/campfire_service.rb
index 1d7877a1fb5..ad26e42a21b 100644
--- a/app/models/project_services/campfire_service.rb
+++ b/app/models/project_services/campfire_service.rb
@@ -57,7 +57,7 @@ class CampfireService < Service
# https://github.com/basecamp/campfire-api/blob/master/sections/messages.md#create-message
def speak(room_name, message, auth)
room = rooms(auth).find { |r| r["name"] == room_name }
- return nil unless room
+ return unless room
path = "/room/#{room["id"]}/speak.json"
body = {
diff --git a/app/models/project_services/chat_message/deployment_message.rb b/app/models/project_services/chat_message/deployment_message.rb
new file mode 100644
index 00000000000..dae3a56116e
--- /dev/null
+++ b/app/models/project_services/chat_message/deployment_message.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+module ChatMessage
+ class DeploymentMessage < BaseMessage
+ attr_reader :commit_title
+ attr_reader :commit_url
+ attr_reader :deployable_id
+ attr_reader :deployable_url
+ attr_reader :environment
+ attr_reader :short_sha
+ attr_reader :status
+ attr_reader :user_url
+
+ def initialize(data)
+ super
+
+ @commit_title = data[:commit_title]
+ @commit_url = data[:commit_url]
+ @deployable_id = data[:deployable_id]
+ @deployable_url = data[:deployable_url]
+ @environment = data[:environment]
+ @short_sha = data[:short_sha]
+ @status = data[:status]
+ @user_url = data[:user_url]
+ end
+
+ def attachments
+ [{
+ text: "#{project_link} with job #{deployment_link} by #{user_link}\n#{commit_link}: #{commit_title}",
+ color: color
+ }]
+ end
+
+ def activity
+ {}
+ end
+
+ private
+
+ def message
+ "Deploy to #{environment} #{humanized_status}"
+ end
+
+ def color
+ case status
+ when 'success'
+ 'good'
+ when 'canceled'
+ 'warning'
+ when 'failed'
+ 'danger'
+ else
+ '#334455'
+ end
+ end
+
+ def project_link
+ link(project_name, project_url)
+ end
+
+ def deployment_link
+ link("##{deployable_id}", deployable_url)
+ end
+
+ def user_link
+ link(user_combined_name, user_url)
+ end
+
+ def commit_link
+ link(short_sha, commit_url)
+ end
+
+ def humanized_status
+ status == 'success' ? 'succeeded' : status
+ end
+ end
+end
diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb
index c10ee07ccf4..7c9ecc6b821 100644
--- a/app/models/project_services/chat_notification_service.rb
+++ b/app/models/project_services/chat_notification_service.rb
@@ -33,7 +33,7 @@ class ChatNotificationService < Service
def self.supported_events
%w[push issue confidential_issue merge_request note confidential_note tag_push
- pipeline wiki_page]
+ pipeline wiki_page deployment]
end
def fields
@@ -122,6 +122,8 @@ class ChatNotificationService < Service
ChatMessage::PipelineMessage.new(data) if should_pipeline_be_notified?(data)
when "wiki_page"
ChatMessage::WikiPageMessage.new(data)
+ when "deployment"
+ ChatMessage::DeploymentMessage.new(data)
end
end
diff --git a/app/models/project_services/discord_service.rb b/app/models/project_services/discord_service.rb
index 21afd14dbff..4385834ed0a 100644
--- a/app/models/project_services/discord_service.rb
+++ b/app/models/project_services/discord_service.rb
@@ -4,11 +4,11 @@ require "discordrb/webhooks"
class DiscordService < ChatNotificationService
def title
- "Discord Notifications"
+ s_("DiscordService|Discord Notifications")
end
def description
- "Receive event notifications in Discord"
+ s_("DiscordService|Receive event notifications in Discord")
end
def self.to_param
@@ -33,6 +33,11 @@ class DiscordService < ChatNotificationService
# No-op.
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://discordapp.com/api/webhooks/…" },
diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb
index fb73d430fb1..45de64a9990 100644
--- a/app/models/project_services/emails_on_push_service.rb
+++ b/app/models/project_services/emails_on_push_service.rb
@@ -7,11 +7,11 @@ class EmailsOnPushService < Service
validates :recipients, presence: true, if: :valid_recipients?
def title
- 'Emails on push'
+ s_('EmailsOnPushService|Emails on push')
end
def description
- 'Email the commits and diff of each push to a list of recipients.'
+ s_('EmailsOnPushService|Email the commits and diff of each push to a list of recipients.')
end
def self.to_param
@@ -45,11 +45,11 @@ class EmailsOnPushService < Service
def fields
domains = Notify.allowed_email_domains.map { |domain| "user@#{domain}" }.join(", ")
[
- { type: 'checkbox', name: 'send_from_committer_email', title: "Send from committer",
- help: "Send notifications from the committer's email address if the domain is part of the domain GitLab is running on (e.g. #{domains})." },
- { type: 'checkbox', name: 'disable_diffs', title: "Disable code diffs",
- help: "Don't include possibly sensitive code diffs in notification body." },
- { type: 'textarea', name: 'recipients', placeholder: 'Emails separated by whitespace' }
+ { type: 'checkbox', name: 'send_from_committer_email', title: s_("EmailsOnPushService|Send from committer"),
+ help: s_("EmailsOnPushService|Send notifications from the committer's email address if the domain is part of the domain GitLab is running on (e.g. %{domains}).") % { domains: domains } },
+ { type: 'checkbox', name: 'disable_diffs', title: s_("EmailsOnPushService|Disable code diffs"),
+ help: s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") },
+ { type: 'textarea', name: 'recipients', placeholder: s_('EmailsOnPushService|Emails separated by whitespace') }
]
end
end
diff --git a/app/models/project_services/external_wiki_service.rb b/app/models/project_services/external_wiki_service.rb
index d2835c6ac82..593ce69b0fd 100644
--- a/app/models/project_services/external_wiki_service.rb
+++ b/app/models/project_services/external_wiki_service.rb
@@ -6,11 +6,11 @@ class ExternalWikiService < Service
validates :external_wiki_url, presence: true, public_url: true, if: :activated?
def title
- 'External Wiki'
+ s_('ExternalWikiService|External Wiki')
end
def description
- 'Replaces the link to the internal wiki with a link to an external wiki.'
+ s_('ExternalWikiService|Replaces the link to the internal wiki with a link to an external wiki.')
end
def self.to_param
@@ -19,7 +19,7 @@ class ExternalWikiService < Service
def fields
[
- { type: 'text', name: 'external_wiki_url', placeholder: 'The URL of the external Wiki', required: true }
+ { type: 'text', name: 'external_wiki_url', placeholder: s_('ExternalWikiService|The URL of the external Wiki'), required: true }
]
end
diff --git a/app/models/project_services/flowdock_service.rb b/app/models/project_services/flowdock_service.rb
index 76624263aab..094488cb431 100644
--- a/app/models/project_services/flowdock_service.rb
+++ b/app/models/project_services/flowdock_service.rb
@@ -9,7 +9,7 @@ class FlowdockService < Service
end
def description
- 'Flowdock is a collaboration web app for technical teams.'
+ s_('FlowdockService|Flowdock is a collaboration web app for technical teams.')
end
def self.to_param
@@ -18,7 +18,7 @@ class FlowdockService < Service
def fields
[
- { type: 'text', name: 'token', placeholder: 'Flowdock Git source token', required: true }
+ { type: 'text', name: 'token', placeholder: s_('FlowdockService|Flowdock Git source token'), required: true }
]
end
diff --git a/app/models/project_services/hangouts_chat_service.rb b/app/models/project_services/hangouts_chat_service.rb
index 272cd0f4e47..699cf1659d1 100644
--- a/app/models/project_services/hangouts_chat_service.rb
+++ b/app/models/project_services/hangouts_chat_service.rb
@@ -35,6 +35,11 @@ class HangoutsChatService < ChatNotificationService
'https://chat.googleapis.com/v1/spaces…'
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. #{webhook_placeholder}" },
diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb
new file mode 100644
index 00000000000..a69b7b4c4b6
--- /dev/null
+++ b/app/models/project_services/hipchat_service.rb
@@ -0,0 +1,311 @@
+# frozen_string_literal: true
+
+class HipchatService < Service
+ include ActionView::Helpers::SanitizeHelper
+
+ MAX_COMMITS = 3
+ HIPCHAT_ALLOWED_TAGS = %w[
+ a b i strong em br img pre code
+ table th tr td caption colgroup col thead tbody tfoot
+ ul ol li dl dt dd
+ ].freeze
+
+ prop_accessor :token, :room, :server, :color, :api_version
+ boolean_accessor :notify_only_broken_pipelines, :notify
+ validates :token, presence: true, if: :activated?
+
+ def initialize_properties
+ if properties.nil?
+ self.properties = {}
+ self.notify_only_broken_pipelines = true
+ end
+ end
+
+ def title
+ 'HipChat'
+ end
+
+ def description
+ 'Private group chat and IM'
+ end
+
+ def self.to_param
+ 'hipchat'
+ end
+
+ def fields
+ [
+ { type: 'text', name: 'token', placeholder: 'Room token', required: true },
+ { type: 'text', name: 'room', placeholder: 'Room name or ID' },
+ { type: 'checkbox', name: 'notify' },
+ { type: 'select', name: 'color', choices: %w(yellow red green purple gray random) },
+ { type: 'text', name: 'api_version',
+ placeholder: 'Leave blank for default (v2)' },
+ { type: 'text', name: 'server',
+ placeholder: 'Leave blank for default. https://hipchat.example.com' },
+ { type: 'checkbox', name: 'notify_only_broken_pipelines' }
+ ]
+ end
+
+ def self.supported_events
+ %w(push issue confidential_issue merge_request note confidential_note tag_push pipeline)
+ end
+
+ def execute(data)
+ return unless supported_events.include?(data[:object_kind])
+
+ message = create_message(data)
+ return unless message.present?
+
+ gate[room].send('GitLab', message, message_options(data)) # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ def test(data)
+ begin
+ result = execute(data)
+ rescue StandardError => error
+ return { success: false, result: error }
+ end
+
+ { success: true, result: result }
+ end
+
+ private
+
+ def gate
+ options = { api_version: api_version.present? ? api_version : 'v2' }
+ options[:server_url] = server unless server.blank?
+ @gate ||= HipChat::Client.new(token, options)
+ end
+
+ def message_options(data = nil)
+ { notify: notify.present? && Gitlab::Utils.to_boolean(notify), color: message_color(data) }
+ end
+
+ def create_message(data)
+ object_kind = data[:object_kind]
+
+ case object_kind
+ when "push", "tag_push"
+ create_push_message(data)
+ when "issue"
+ create_issue_message(data) unless update?(data)
+ when "merge_request"
+ create_merge_request_message(data) unless update?(data)
+ when "note"
+ create_note_message(data)
+ when "pipeline"
+ create_pipeline_message(data) if should_pipeline_be_notified?(data)
+ end
+ end
+
+ def render_line(text)
+ markdown(text.lines.first.chomp, pipeline: :single_line) if text
+ end
+
+ def create_push_message(push)
+ ref_type = Gitlab::Git.tag_ref?(push[:ref]) ? 'tag' : 'branch'
+ ref = Gitlab::Git.ref_name(push[:ref])
+
+ before = push[:before]
+ after = push[:after]
+
+ message = []
+ message << "#{push[:user_name]} "
+
+ if Gitlab::Git.blank_ref?(before)
+ message << "pushed new #{ref_type} <a href=\""\
+ "#{project_url}/commits/#{CGI.escape(ref)}\">#{ref}</a>"\
+ " to #{project_link}\n"
+ elsif Gitlab::Git.blank_ref?(after)
+ message << "removed #{ref_type} <b>#{ref}</b> from <a href=\"#{project.web_url}\">#{project_name}</a> \n"
+ else
+ message << "pushed to #{ref_type} <a href=\""\
+ "#{project.web_url}/commits/#{CGI.escape(ref)}\">#{ref}</a> "
+ message << "of <a href=\"#{project.web_url}\">#{project.full_name.gsub!(/\s/, '')}</a> "
+ message << "(<a href=\"#{project.web_url}/compare/#{before}...#{after}\">Compare changes</a>)"
+
+ push[:commits].take(MAX_COMMITS).each do |commit|
+ message << "<br /> - #{render_line(commit[:message])} (<a href=\"#{commit[:url]}\">#{commit[:id][0..5]}</a>)"
+ end
+
+ if push[:commits].count > MAX_COMMITS
+ message << "<br />... #{push[:commits].count - MAX_COMMITS} more commits"
+ end
+ end
+
+ message.join
+ end
+
+ def markdown(text, options = {})
+ return "" unless text
+
+ context = {
+ project: project,
+ pipeline: :email
+ }
+
+ Banzai.render(text, context)
+
+ context.merge!(options)
+
+ html = Banzai.render_and_post_process(text, context)
+ sanitized_html = sanitize(html, tags: HIPCHAT_ALLOWED_TAGS, attributes: %w[href title alt])
+
+ sanitized_html.truncate(200, separator: ' ', omission: '...')
+ end
+
+ def create_issue_message(data)
+ user_name = data[:user][:name]
+
+ obj_attr = data[:object_attributes]
+ obj_attr = HashWithIndifferentAccess.new(obj_attr)
+ title = render_line(obj_attr[:title])
+ state = obj_attr[:state]
+ issue_iid = obj_attr[:iid]
+ issue_url = obj_attr[:url]
+ description = obj_attr[:description]
+
+ issue_link = "<a href=\"#{issue_url}\">issue ##{issue_iid}</a>"
+
+ message = ["#{user_name} #{state} #{issue_link} in #{project_link}: <b>#{title}</b>"]
+ message << "<pre>#{markdown(description)}</pre>"
+
+ message.join
+ end
+
+ def create_merge_request_message(data)
+ user_name = data[:user][:name]
+
+ obj_attr = data[:object_attributes]
+ obj_attr = HashWithIndifferentAccess.new(obj_attr)
+ merge_request_id = obj_attr[:iid]
+ state = obj_attr[:state]
+ description = obj_attr[:description]
+ title = render_line(obj_attr[:title])
+
+ merge_request_url = "#{project_url}/merge_requests/#{merge_request_id}"
+ merge_request_link = "<a href=\"#{merge_request_url}\">merge request !#{merge_request_id}</a>"
+ message = ["#{user_name} #{state} #{merge_request_link} in " \
+ "#{project_link}: <b>#{title}</b>"]
+
+ message << "<pre>#{markdown(description)}</pre>"
+ message.join
+ end
+
+ def format_title(title)
+ "<b>#{render_line(title)}</b>"
+ end
+
+ def create_note_message(data)
+ data = HashWithIndifferentAccess.new(data)
+ user_name = data[:user][:name]
+
+ obj_attr = HashWithIndifferentAccess.new(data[:object_attributes])
+ note = obj_attr[:note]
+ note_url = obj_attr[:url]
+ noteable_type = obj_attr[:noteable_type]
+ commit_id = nil
+
+ case noteable_type
+ when "Commit"
+ commit_attr = HashWithIndifferentAccess.new(data[:commit])
+ commit_id = commit_attr[:id]
+ subject_desc = commit_id
+ subject_desc = Commit.truncate_sha(subject_desc)
+ subject_type = "commit"
+ title = format_title(commit_attr[:message])
+ when "Issue"
+ subj_attr = HashWithIndifferentAccess.new(data[:issue])
+ subject_id = subj_attr[:iid]
+ subject_desc = "##{subject_id}"
+ subject_type = "issue"
+ title = format_title(subj_attr[:title])
+ when "MergeRequest"
+ subj_attr = HashWithIndifferentAccess.new(data[:merge_request])
+ subject_id = subj_attr[:iid]
+ subject_desc = "!#{subject_id}"
+ subject_type = "merge request"
+ title = format_title(subj_attr[:title])
+ when "Snippet"
+ subj_attr = HashWithIndifferentAccess.new(data[:snippet])
+ subject_id = subj_attr[:id]
+ subject_desc = "##{subject_id}"
+ subject_type = "snippet"
+ title = format_title(subj_attr[:title])
+ end
+
+ subject_html = "<a href=\"#{note_url}\">#{subject_type} #{subject_desc}</a>"
+ message = ["#{user_name} commented on #{subject_html} in #{project_link}: "]
+ message << title
+
+ message << "<pre>#{markdown(note, ref: commit_id)}</pre>"
+ message.join
+ end
+
+ def create_pipeline_message(data)
+ pipeline_attributes = data[:object_attributes]
+ pipeline_id = pipeline_attributes[:id]
+ ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch'
+ ref = pipeline_attributes[:ref]
+ user_name = (data[:user] && data[:user][:name]) || 'API'
+ status = pipeline_attributes[:status]
+ duration = pipeline_attributes[:duration]
+
+ branch_link = "<a href=\"#{project_url}/commits/#{CGI.escape(ref)}\">#{ref}</a>"
+ pipeline_url = "<a href=\"#{project_url}/pipelines/#{pipeline_id}\">##{pipeline_id}</a>"
+
+ "#{project_link}: Pipeline #{pipeline_url} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status(status)} in #{duration} second(s)"
+ end
+
+ def message_color(data)
+ pipeline_status_color(data) || color || 'yellow'
+ end
+
+ def pipeline_status_color(data)
+ return unless data && data[:object_kind] == 'pipeline'
+
+ case data[:object_attributes][:status]
+ when 'success'
+ 'green'
+ else
+ 'red'
+ end
+ end
+
+ def project_name
+ project.full_name.gsub(/\s/, '')
+ end
+
+ def project_url
+ project.web_url
+ end
+
+ def project_link
+ "<a href=\"#{project_url}\">#{project_name}</a>"
+ end
+
+ def update?(data)
+ data[:object_attributes][:action] == 'update'
+ end
+
+ def humanized_status(status)
+ case status
+ when 'success'
+ 'passed'
+ else
+ status
+ end
+ end
+
+ def should_pipeline_be_notified?(data)
+ case data[:object_attributes][:status]
+ when 'success'
+ !notify_only_broken_pipelines?
+ when 'failed'
+ true
+ else
+ false
+ end
+ end
+end
diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb
index 83fd9a34438..fb76bc89c98 100644
--- a/app/models/project_services/irker_service.rb
+++ b/app/models/project_services/irker_service.rb
@@ -112,7 +112,7 @@ class IrkerService < Service
end
def consider_uri(uri)
- return nil if uri.scheme.nil?
+ return if uri.scheme.nil?
# Authorize both irc://domain.com/#chan and irc://domain.com/chan
if uri.is_a?(URI) && uri.scheme[/^ircs?\z/] && !uri.path.nil?
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index 9066a0b7f1d..7b4832b84a8 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -11,7 +11,7 @@ class JiraService < IssueTrackerService
validates :password, presence: true, if: :activated?
validates :jira_issue_transition_id,
- format: { with: Gitlab::Regex.jira_transition_id_regex, message: "transition ids can have only numbers which can be split with , or ;" },
+ format: { with: Gitlab::Regex.jira_transition_id_regex, message: s_("JiraService|transition ids can have only numbers which can be split with , or ;") },
allow_blank: true
# JIRA cloud version is deprecating authentication via username and password.
@@ -86,7 +86,7 @@ class JiraService < IssueTrackerService
if self.properties && self.properties['description'].present?
self.properties['description']
else
- 'Jira issue tracker'
+ s_('JiraService|Jira issue tracker')
end
end
@@ -96,11 +96,11 @@ class JiraService < IssueTrackerService
def fields
[
- { type: 'text', name: 'url', title: 'Web URL', placeholder: 'https://jira.example.com', required: true },
- { type: 'text', name: 'api_url', title: 'JIRA API URL', placeholder: 'If different from Web URL' },
- { type: 'text', name: 'username', title: 'Username or Email', placeholder: 'Use a username for server version and an email for cloud version', required: true },
- { type: 'password', name: 'password', title: 'Password or API token', placeholder: 'Use a password for server version and an API token for cloud version', required: true },
- { type: 'text', name: 'jira_issue_transition_id', title: 'Transition ID(s)', placeholder: 'Use , or ; to separate multiple transition IDs' }
+ { type: 'text', name: 'url', title: s_('JiraService|Web URL'), placeholder: 'https://jira.example.com', required: true },
+ { type: 'text', name: 'api_url', title: s_('JiraService|JIRA API URL'), placeholder: s_('JiraService|If different from Web URL') },
+ { type: 'text', name: 'username', title: s_('JiraService|Username or Email'), placeholder: s_('JiraService|Use a username for server version and an email for cloud version'), required: true },
+ { type: 'password', name: 'password', title: s_('JiraService|Password or API token'), placeholder: s_('JiraService|Use a password for server version and an API token for cloud version'), required: true },
+ { type: 'text', name: 'jira_issue_transition_id', title: s_('JiraService|Transition ID(s)'), placeholder: s_('JiraService|Use , or ; to separate multiple transition IDs') }
]
end
@@ -139,7 +139,7 @@ class JiraService < IssueTrackerService
def create_cross_reference_note(mentioned, noteable, author)
unless can_cross_reference?(noteable)
- return "Events for #{noteable.model_name.plural.humanize(capitalize: false)} are disabled."
+ return s_("JiraService|Events for %{noteable_model_name} are disabled.") % { noteable_model_name: noteable.model_name.plural.humanize(capitalize: false) }
end
jira_issue = jira_request { client.Issue.find(mentioned.id) }
@@ -205,17 +205,15 @@ class JiraService < IssueTrackerService
# if any transition fails it will log the error message and stop the transition sequence
def transition_issue(issue)
jira_issue_transition_id.scan(Gitlab::Regex.jira_transition_id_regex).each do |transition_id|
- begin
- issue.transitions.build.save!(transition: { id: transition_id })
- rescue => error
- log_error("Issue transition failed", error: error.message, client_url: client_url)
- return false
- end
+ issue.transitions.build.save!(transition: { id: transition_id })
+ rescue => error
+ log_error("Issue transition failed", error: error.message, client_url: client_url)
+ return false
end
end
def add_issue_solved_comment(issue, commit_id, commit_url)
- link_title = "GitLab: Solved by commit #{commit_id}."
+ link_title = "Solved by commit #{commit_id}."
comment = "Issue solved with [#{commit_id}|#{commit_url}]."
link_props = build_remote_link_props(url: commit_url, title: link_title, resolved: true)
send_message(issue, comment, link_props)
@@ -230,7 +228,7 @@ class JiraService < IssueTrackerService
project_name = data[:project][:name]
message = "[#{user_name}|#{user_url}] mentioned this issue in [a #{entity_name} of #{project_name}|#{entity_url}]:\n'#{entity_title.chomp}'"
- link_title = "GitLab: Mentioned on #{entity_name} - #{entity_title}"
+ link_title = "#{entity_name.capitalize} - #{entity_title}"
link_props = build_remote_link_props(url: entity_url, title: link_title)
unless comment_exists?(issue, message)
@@ -267,6 +265,7 @@ class JiraService < IssueTrackerService
def find_remote_link(issue, url)
links = jira_request { issue.remotelink.all }
+ return unless links
links.find { |link| link.object["url"] == url }
end
@@ -278,6 +277,7 @@ class JiraService < IssueTrackerService
{
GlobalID: 'GitLab',
+ relationship: 'mentioned on',
object: {
url: url,
title: title,
@@ -339,9 +339,9 @@ class JiraService < IssueTrackerService
def self.event_description(event)
case event
when "merge_request", "merge_request_events"
- "JIRA comments will be created when an issue gets referenced in a merge request."
+ s_("JiraService|JIRA comments will be created when an issue gets referenced in a merge request.")
when "commit", "commit_events"
- "JIRA comments will be created when an issue gets referenced in a commit."
+ s_("JiraService|JIRA comments will be created when an issue gets referenced in a commit.")
end
end
end
diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb
index f69edd60003..aa6b4aa1d5e 100644
--- a/app/models/project_services/kubernetes_service.rb
+++ b/app/models/project_services/kubernetes_service.rb
@@ -86,7 +86,7 @@ class KubernetesService < DeploymentService
]
end
- def actual_namespace
+ def kubernetes_namespace_for(project)
if namespace.present?
namespace
else
@@ -113,8 +113,8 @@ class KubernetesService < DeploymentService
Gitlab::Ci::Variables::Collection.new.tap do |variables|
variables
.append(key: 'KUBE_URL', value: api_url)
- .append(key: 'KUBE_TOKEN', value: token, public: false)
- .append(key: 'KUBE_NAMESPACE', value: actual_namespace)
+ .append(key: 'KUBE_TOKEN', value: token, public: false, masked: true)
+ .append(key: 'KUBE_NAMESPACE', value: kubernetes_namespace_for(project))
.append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true)
if ca_pem.present?
@@ -131,8 +131,10 @@ class KubernetesService < DeploymentService
# short time later
def terminals(environment)
with_reactive_cache do |data|
- pods = filter_by_label(data[:pods], app: environment.slug)
- terminals = pods.flat_map { |pod| terminals_for_pod(api_url, actual_namespace, pod) }
+ project = environment.project
+
+ pods = filter_by_project_environment(data[:pods], project.full_path_slug, environment.slug)
+ terminals = pods.flat_map { |pod| terminals_for_pod(api_url, kubernetes_namespace_for(project), pod) }.compact
terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) }
end
end
@@ -169,7 +171,7 @@ class KubernetesService < DeploymentService
def kubeconfig
to_kubeconfig(
url: api_url,
- namespace: actual_namespace,
+ namespace: kubernetes_namespace_for(project),
token: token,
ca_pem: ca_pem)
end
@@ -186,7 +188,7 @@ class KubernetesService < DeploymentService
end
def build_kube_client!
- raise "Incomplete settings" unless api_url && actual_namespace && token
+ raise "Incomplete settings" unless api_url && kubernetes_namespace_for(project) && token
Gitlab::Kubernetes::KubeClient.new(
api_url,
@@ -200,7 +202,7 @@ class KubernetesService < DeploymentService
def read_pods
kubeclient = build_kube_client!
- kubeclient.get_pods(namespace: actual_namespace).as_json
+ kubeclient.get_pods(namespace: kubernetes_namespace_for(project)).as_json
rescue Kubeclient::ResourceNotFoundError
[]
end
diff --git a/app/models/project_services/microsoft_teams_service.rb b/app/models/project_services/microsoft_teams_service.rb
index c34078f13c1..c22a6dc26f6 100644
--- a/app/models/project_services/microsoft_teams_service.rb
+++ b/app/models/project_services/microsoft_teams_service.rb
@@ -33,6 +33,11 @@ class MicrosoftTeamsService < ChatNotificationService
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. #{webhook_placeholder}" },
diff --git a/app/models/project_services/mock_ci_service.rb b/app/models/project_services/mock_ci_service.rb
index d8bba58dcbf..c5e5f4f6400 100644
--- a/app/models/project_services/mock_ci_service.rb
+++ b/app/models/project_services/mock_ci_service.rb
@@ -2,7 +2,7 @@
# For an example companion mocking service, see https://gitlab.com/gitlab-org/gitlab-mock-ci-service
class MockCiService < CiService
- ALLOWED_STATES = %w[failed canceled running pending success success_with_warnings skipped not_found].freeze
+ ALLOWED_STATES = %w[failed canceled running pending success success-with-warnings skipped not_found].freeze
prop_accessor :mock_service_url
validates :mock_service_url, presence: true, public_url: true, if: :activated?
diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb
index d60a6a7efa3..ae5d5038099 100644
--- a/app/models/project_services/pipelines_email_service.rb
+++ b/app/models/project_services/pipelines_email_service.rb
@@ -2,19 +2,19 @@
class PipelinesEmailService < Service
prop_accessor :recipients
- boolean_accessor :notify_only_broken_pipelines
+ boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch
validates :recipients, presence: true, if: :valid_recipients?
def initialize_properties
- self.properties ||= { notify_only_broken_pipelines: true }
+ self.properties ||= { notify_only_broken_pipelines: true, notify_only_default_branch: false }
end
def title
- 'Pipelines emails'
+ _('Pipelines emails')
end
def description
- 'Email the pipelines status to a list of recipients.'
+ _('Email the pipelines status to a list of recipients.')
end
def self.to_param
@@ -51,10 +51,12 @@ class PipelinesEmailService < Service
[
{ type: 'textarea',
name: 'recipients',
- placeholder: 'Emails separated by comma',
+ placeholder: _('Emails separated by comma'),
required: true },
{ type: 'checkbox',
- name: 'notify_only_broken_pipelines' }
+ name: 'notify_only_broken_pipelines' },
+ { type: 'checkbox',
+ name: 'notify_only_default_branch' }
]
end
@@ -67,6 +69,16 @@ class PipelinesEmailService < Service
end
def should_pipeline_be_notified?(data)
+ notify_for_pipeline_branch?(data) && notify_for_pipeline?(data)
+ end
+
+ def notify_for_pipeline_branch?(data)
+ return true unless notify_only_default_branch?
+
+ data[:object_attributes][:ref] == data[:project][:default_branch]
+ end
+
+ def notify_for_pipeline?(data)
case data[:object_attributes][:status]
when 'success'
!notify_only_broken_pipelines?
diff --git a/app/models/project_services/pivotaltracker_service.rb b/app/models/project_services/pivotaltracker_service.rb
index 617e502b639..c15993bdc06 100644
--- a/app/models/project_services/pivotaltracker_service.rb
+++ b/app/models/project_services/pivotaltracker_service.rb
@@ -11,7 +11,7 @@ class PivotaltrackerService < Service
end
def description
- 'Project Management Software (Source Commits Endpoint)'
+ s_('PivotalTrackerService|Project Management Software (Source Commits Endpoint)')
end
def self.to_param
@@ -23,14 +23,14 @@ class PivotaltrackerService < Service
{
type: 'text',
name: 'token',
- placeholder: 'Pivotal Tracker API token.',
+ placeholder: s_('PivotalTrackerService|Pivotal Tracker API token.'),
required: true
},
{
type: 'text',
name: 'restrict_to_branch',
- placeholder: 'Comma-separated list of branches which will be ' \
- 'automatically inspected. Leave blank to include all branches.'
+ placeholder: s_('PivotalTrackerService|Comma-separated list of branches which will be ' \
+ 'automatically inspected. Leave blank to include all branches.')
}
]
end
diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb
index 60cb2d380d5..c68a9d923c8 100644
--- a/app/models/project_services/prometheus_service.rb
+++ b/app/models/project_services/prometheus_service.rb
@@ -71,7 +71,7 @@ class PrometheusService < MonitoringService
end
def prometheus_client
- RestClient::Resource.new(api_url, max_redirects: 0) if api_url && manual_configuration? && active?
+ RestClient::Resource.new(api_url, max_redirects: 0) if should_return_client?
end
def prometheus_available?
@@ -83,6 +83,10 @@ class PrometheusService < MonitoringService
private
+ def should_return_client?
+ api_url && manual_configuration? && active? && valid?
+ end
+
def synchronize_service_state
self.active = prometheus_available? || manual_configuration?
diff --git a/app/models/project_services/pushover_service.rb b/app/models/project_services/pushover_service.rb
index 4e48c348b45..0d35bab7f80 100644
--- a/app/models/project_services/pushover_service.rb
+++ b/app/models/project_services/pushover_service.rb
@@ -11,7 +11,7 @@ class PushoverService < Service
end
def description
- 'Pushover makes it easy to get real-time notifications on your Android device, iPhone, iPad, and Desktop.'
+ s_('PushoverService|Pushover makes it easy to get real-time notifications on your Android device, iPhone, iPad, and Desktop.')
end
def self.to_param
@@ -20,15 +20,15 @@ class PushoverService < Service
def fields
[
- { type: 'text', name: 'api_key', placeholder: 'Your application key', required: true },
- { type: 'text', name: 'user_key', placeholder: 'Your user key', required: true },
- { type: 'text', name: 'device', placeholder: 'Leave blank for all active devices' },
+ { type: 'text', name: 'api_key', placeholder: s_('PushoverService|Your application key'), required: true },
+ { type: 'text', name: 'user_key', placeholder: s_('PushoverService|Your user key'), required: true },
+ { type: 'text', name: 'device', placeholder: s_('PushoverService|Leave blank for all active devices') },
{ type: 'select', name: 'priority', required: true, choices:
[
- ['Lowest Priority', -2],
- ['Low Priority', -1],
- ['Normal Priority', 0],
- ['High Priority', 1]
+ [s_('PushoverService|Lowest Priority'), -2],
+ [s_('PushoverService|Low Priority'), -1],
+ [s_('PushoverService|Normal Priority'), 0],
+ [s_('PushoverService|High Priority'), 1]
],
default_choice: 0 },
{ type: 'select', name: 'sound', choices:
@@ -73,15 +73,15 @@ class PushoverService < Service
message =
if Gitlab::Git.blank_ref?(before)
- "#{data[:user_name]} pushed new branch \"#{ref}\"."
+ s_("PushoverService|%{user_name} pushed new branch \"%{ref}\".") % { user_name: data[:user_name], ref: ref }
elsif Gitlab::Git.blank_ref?(after)
- "#{data[:user_name]} deleted branch \"#{ref}\"."
+ s_("PushoverService|%{user_name} deleted branch \"%{ref}\".") % { user_name: data[:user_name], ref: ref }
else
- "#{data[:user_name]} push to branch \"#{ref}\"."
+ s_("PushoverService|%{user_name} push to branch \"%{ref}\".") % { user_name: data[:user_name], ref: ref }
end
if data[:total_commits_count] > 0
- message = [message, "Total commits count: #{data[:total_commits_count]}"].join("\n")
+ message = [message, s_("PushoverService|Total commits count: %{total_commits_count}") % { total_commits_count: data[:total_commits_count] }].join("\n")
end
pushover_data = {
@@ -92,7 +92,7 @@ class PushoverService < Service
title: "#{project.full_name}",
message: message,
url: data[:project][:web_url],
- url_title: "See project #{project.full_name}"
+ url_title: s_("PushoverService|See project %{project_full_name}") % { project_full_name: project.full_name }
}
# Sound parameter MUST NOT be sent to API if not selected
diff --git a/app/models/project_services/youtrack_service.rb b/app/models/project_services/youtrack_service.rb
new file mode 100644
index 00000000000..175c2ebf197
--- /dev/null
+++ b/app/models/project_services/youtrack_service.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+class YoutrackService < IssueTrackerService
+ validates :project_url, :issues_url, presence: true, public_url: true, if: :activated?
+
+ prop_accessor :description, :project_url, :issues_url
+
+ # {PROJECT-KEY}-{NUMBER} Examples: YT-1, PRJ-1, gl-030
+ def self.reference_pattern(only_long: false)
+ if only_long
+ /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+)/
+ else
+ /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+)|(#{Issue.reference_prefix}(?<issue>\d+))/
+ end
+ end
+
+ def title
+ 'YouTrack'
+ end
+
+ def description
+ if self.properties && self.properties['description'].present?
+ self.properties['description']
+ else
+ 'YouTrack issue tracker'
+ end
+ end
+
+ def self.to_param
+ 'youtrack'
+ end
+
+ def fields
+ [
+ { type: 'text', name: 'description', placeholder: description },
+ { type: 'text', name: 'project_url', placeholder: 'Project url', required: true },
+ { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true }
+ ]
+ end
+end
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index 781a197d56f..11e3737298c 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -1,13 +1,22 @@
# frozen_string_literal: true
-class ProjectStatistics < ActiveRecord::Base
+class ProjectStatistics < ApplicationRecord
belongs_to :project
belongs_to :namespace
+ default_value_for :wiki_size, 0
+
+ # older migrations fail due to non-existent attribute without this
+ def wiki_size
+ has_attribute?(:wiki_size) ? super : 0
+ end
+
before_save :update_storage_size
- COLUMNS_TO_REFRESH = [:repository_size, :lfs_objects_size, :commit_count].freeze
- INCREMENTABLE_COLUMNS = { build_artifacts_size: %i[storage_size] }.freeze
+ COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count].freeze
+ INCREMENTABLE_COLUMNS = { build_artifacts_size: %i[storage_size], packages_size: %i[storage_size] }.freeze
+
+ scope :for_project_ids, ->(project_ids) { where(project_id: project_ids) }
def total_repository_size
repository_size + lfs_objects_size
@@ -27,17 +36,25 @@ class ProjectStatistics < ActiveRecord::Base
self.commit_count = project.repository.commit_count
end
- # Repository#size needs to be converted from MB to Byte.
def update_repository_size
self.repository_size = project.repository.size * 1.megabyte
end
+ def update_wiki_size
+ self.wiki_size = project.wiki.repository.size * 1.megabyte
+ end
+
def update_lfs_objects_size
self.lfs_objects_size = project.lfs_objects.sum(:size)
end
+ # older migrations fail due to non-existent attribute without this
+ def packages_size
+ has_attribute?(:packages_size) ? super : 0
+ end
+
def update_storage_size
- self.storage_size = repository_size + lfs_objects_size + build_artifacts_size
+ self.storage_size = repository_size + wiki_size + lfs_objects_size + build_artifacts_size + packages_size
end
# Since this incremental update method does not call update_storage_size above,
diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb
index c43bd45a62f..c91add6439f 100644
--- a/app/models/project_wiki.rb
+++ b/app/models/project_wiki.rb
@@ -13,6 +13,11 @@ class ProjectWiki
CouldNotCreateWikiError = Class.new(StandardError)
SIDEBAR = '_sidebar'
+ TITLE_ORDER = 'title'
+ CREATED_AT_ORDER = 'created_at'
+ DIRECTION_DESC = 'desc'
+ DIRECTION_ASC = 'asc'
+
# Returns a string describing what went wrong after
# an operation fails.
attr_reader :error_message
@@ -59,7 +64,7 @@ class ProjectWiki
# Returns the Gitlab::Git::Wiki object.
def wiki
@wiki ||= begin
- gl_repository = Gitlab::GlRepository.gl_repository(project, true)
+ gl_repository = Gitlab::GlRepository::WIKI.identifier_for_subject(project)
raw_repository = Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', gl_repository, full_path)
create_repo!(raw_repository) unless raw_repository.exists?
@@ -77,13 +82,28 @@ class ProjectWiki
end
def empty?
- pages(limit: 1).empty?
+ list_pages(limit: 1).empty?
end
+ # Lists wiki pages of the repository.
+ #
+ # limit - max number of pages returned by the method.
+ # sort - criterion by which the pages are sorted.
+ # direction - order of the sorted pages.
+ # load_content - option, which specifies whether the content inside the page
+ # will be loaded.
+ #
# Returns an Array of GitLab WikiPage instances or an
# empty Array if this Wiki has no pages.
- def pages(limit: 0)
- wiki.pages(limit: limit).map { |page| WikiPage.new(self, page, true) }
+ def list_pages(limit: 0, sort: nil, direction: DIRECTION_ASC, load_content: false)
+ wiki.list_pages(
+ limit: limit,
+ sort: sort,
+ direction_desc: direction == DIRECTION_DESC,
+ load_content: load_content
+ ).map do |page|
+ WikiPage.new(self, page, true)
+ end
end
# Finds a page within the repository based on a tile
@@ -151,7 +171,7 @@ class ProjectWiki
end
def repository
- @repository ||= Repository.new(full_path, @project, disk_path: disk_path, is_wiki: true)
+ @repository ||= Repository.new(full_path, @project, disk_path: disk_path, repo_type: Gitlab::GlRepository::WIKI)
end
def default_branch
@@ -183,7 +203,7 @@ class ProjectWiki
end
def commit_details(action, message = nil, title = nil)
- commit_message = message || default_message(action, title)
+ commit_message = message.presence || default_message(action, title)
git_user = Gitlab::Git::User.from_gitlab(@user)
Gitlab::Git::Wiki::CommitDetails.new(@user.id,
diff --git a/app/models/prometheus_metric.rb b/app/models/prometheus_metric.rb
index 5594594a48d..62090444f79 100644
--- a/app/models/prometheus_metric.rb
+++ b/app/models/prometheus_metric.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class PrometheusMetric < ActiveRecord::Base
+class PrometheusMetric < ApplicationRecord
belongs_to :project, validate: true, inverse_of: :prometheus_metrics
enum group: {
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index d075440b147..ee0c94c20af 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProtectedBranch < ActiveRecord::Base
+class ProtectedBranch < ApplicationRecord
include ProtectedRef
protected_ref_access_levels :merge, :push
@@ -18,13 +18,23 @@ class ProtectedBranch < ActiveRecord::Base
def self.protected?(project, ref_name)
return true if project.empty_repo? && default_branch_protected?
- refs = project.protected_branches.select(:name)
+ self.matching(ref_name, protected_refs: protected_refs(project)).present?
+ end
- self.matching(ref_name, protected_refs: refs).present?
+ def self.any_protected?(project, ref_names)
+ protected_refs(project).any? do |protected_ref|
+ ref_names.any? do |ref_name|
+ protected_ref.matches?(ref_name)
+ end
+ end
end
def self.default_branch_protected?
Gitlab::CurrentSettings.default_branch_protection == Gitlab::Access::PROTECTION_FULL ||
Gitlab::CurrentSettings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE
end
+
+ def self.protected_refs(project)
+ project.protected_branches.select(:name)
+ end
end
diff --git a/app/models/protected_branch/merge_access_level.rb b/app/models/protected_branch/merge_access_level.rb
index b0d5c64e931..de240e40316 100644
--- a/app/models/protected_branch/merge_access_level.rb
+++ b/app/models/protected_branch/merge_access_level.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
-class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base
+class ProtectedBranch::MergeAccessLevel < ApplicationRecord
include ProtectedBranchAccess
end
diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb
index b2a88229853..bde1d29ad7f 100644
--- a/app/models/protected_branch/push_access_level.rb
+++ b/app/models/protected_branch/push_access_level.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
-class ProtectedBranch::PushAccessLevel < ActiveRecord::Base
+class ProtectedBranch::PushAccessLevel < ApplicationRecord
include ProtectedBranchAccess
end
diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb
index d28ebabfe49..6b507429e57 100644
--- a/app/models/protected_tag.rb
+++ b/app/models/protected_tag.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProtectedTag < ActiveRecord::Base
+class ProtectedTag < ApplicationRecord
include ProtectedRef
validates :name, uniqueness: { scope: :project_id }
diff --git a/app/models/protected_tag/create_access_level.rb b/app/models/protected_tag/create_access_level.rb
index b06e55fb5dd..9fcfa7646a2 100644
--- a/app/models/protected_tag/create_access_level.rb
+++ b/app/models/protected_tag/create_access_level.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProtectedTag::CreateAccessLevel < ActiveRecord::Base
+class ProtectedTag::CreateAccessLevel < ApplicationRecord
include ProtectedTagAccess
def check_access(user)
diff --git a/app/models/push_event.rb b/app/models/push_event.rb
index 9c0267c3140..4698df39730 100644
--- a/app/models/push_event.rb
+++ b/app/models/push_event.rb
@@ -69,7 +69,7 @@ class PushEvent < Event
PUSHED
end
- def push?
+ def push_action?
true
end
diff --git a/app/models/push_event_payload.rb b/app/models/push_event_payload.rb
index c7769edf055..537859ec7b7 100644
--- a/app/models/push_event_payload.rb
+++ b/app/models/push_event_payload.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class PushEventPayload < ActiveRecord::Base
+class PushEventPayload < ApplicationRecord
include ShaAttribute
belongs_to :event, inverse_of: :push_event_payload
diff --git a/app/models/redirect_route.rb b/app/models/redirect_route.rb
index c6bd4bb6dfa..2e4769364c6 100644
--- a/app/models/redirect_route.rb
+++ b/app/models/redirect_route.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class RedirectRoute < ActiveRecord::Base
+class RedirectRoute < ApplicationRecord
belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :source, presence: true
diff --git a/app/models/release.rb b/app/models/release.rb
index 0dae5c90394..7bbeb3c9976 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Release < ActiveRecord::Base
+class Release < ApplicationRecord
include CacheMarkdownField
include Gitlab::Utils::StrongMemoize
@@ -15,6 +15,7 @@ class Release < ActiveRecord::Base
accepts_nested_attributes_for :links, allow_destroy: true
validates :description, :project, :tag, presence: true
+ validates :name, presence: true, on: :create
scope :sorted, -> { order(created_at: :desc) }
@@ -30,8 +31,11 @@ class Release < ActiveRecord::Base
actual_tag.nil?
end
- def assets_count
- links.count + sources.count
+ def assets_count(except: [])
+ links_count = links.count
+ sources_count = except.include?(:sources) ? 0 : sources.count
+
+ links_count + sources_count
end
def sources
diff --git a/app/models/releases/link.rb b/app/models/releases/link.rb
index 6c507c47752..58c2b98e524 100644
--- a/app/models/releases/link.rb
+++ b/app/models/releases/link.rb
@@ -1,12 +1,12 @@
# frozen_string_literal: true
module Releases
- class Link < ActiveRecord::Base
+ class Link < ApplicationRecord
self.table_name = 'release_links'
belongs_to :release
- validates :url, presence: true, url: { protocols: %w(http https ftp) }, uniqueness: { scope: :release }
+ validates :url, presence: true, addressable_url: { schemes: %w(http https ftp) }, uniqueness: { scope: :release }
validates :name, presence: true, uniqueness: { scope: :release }
scope :sorted, -> { order(created_at: :desc) }
diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb
index 5eba7ddd75c..af705b29f7a 100644
--- a/app/models/remote_mirror.rb
+++ b/app/models/remote_mirror.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class RemoteMirror < ActiveRecord::Base
+class RemoteMirror < ApplicationRecord
include AfterCommitQueue
include MirrorAuthentication
@@ -17,13 +17,13 @@ class RemoteMirror < ActiveRecord::Base
belongs_to :project, inverse_of: :remote_mirrors
- validates :url, presence: true, public_url: { protocols: %w(ssh git http https), allow_blank: true, enforce_user: true }
+ validates :url, presence: true, public_url: { schemes: %w(ssh git http https), allow_blank: true, enforce_user: true }
before_save :set_new_remote_name, if: :mirror_url_changed?
after_save :set_override_remote_mirror_available, unless: -> { Gitlab::CurrentSettings.current_application_settings.mirror_available }
- after_save :refresh_remote, if: :mirror_url_changed?
- after_update :reset_fields, if: :mirror_url_changed?
+ after_save :refresh_remote, if: :saved_change_to_mirror_url?
+ after_update :reset_fields, if: :saved_change_to_mirror_url?
after_commit :remove_remote, on: :destroy
@@ -133,6 +133,10 @@ class RemoteMirror < ActiveRecord::Base
end
alias_method :enabled?, :enabled
+ def disabled?
+ !enabled?
+ end
+
def updated_since?(timestamp)
last_update_started_at && last_update_started_at > timestamp && !update_failed?
end
@@ -248,7 +252,7 @@ class RemoteMirror < ActiveRecord::Base
# Before adding a new remote we have to delete the data from
# the previous remote name
- prev_remote_name = remote_name_was || fallback_remote_name
+ prev_remote_name = remote_name_before_last_save || fallback_remote_name
run_after_commit do
project.repository.async_remove_remote(prev_remote_name)
end
@@ -265,4 +269,8 @@ class RemoteMirror < ActiveRecord::Base
def mirror_url_changed?
url_changed? || credentials_changed?
end
+
+ def saved_change_to_mirror_url?
+ saved_change_to_url? || saved_change_to_credentials?
+ end
end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index ed55a6e572b..e05d3dd58ac 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -19,7 +19,7 @@ class Repository
include Gitlab::RepositoryCacheAdapter
- attr_accessor :full_path, :disk_path, :project, :is_wiki
+ attr_accessor :full_path, :disk_path, :project, :repo_type
delegate :ref_name_for_sha, to: :raw_repository
delegate :bundle_to_disk, to: :raw_repository
@@ -39,7 +39,8 @@ class Repository
changelog license_blob license_key gitignore
gitlab_ci_yml branch_names tag_names branch_count
tag_count avatar exists? root_ref has_visible_content?
- issue_template_names merge_request_template_names xcode_project?).freeze
+ issue_template_names merge_request_template_names
+ metrics_dashboard_paths xcode_project?).freeze
# Methods that use cache_method but only memoize the value
MEMOIZED_CACHED_METHODS = %i(license).freeze
@@ -57,15 +58,16 @@ class Repository
avatar: :avatar,
issue_template: :issue_template_names,
merge_request_template: :merge_request_template_names,
+ metrics_dashboard: :metrics_dashboard_paths,
xcode_config: :xcode_project?
}.freeze
- def initialize(full_path, project, disk_path: nil, is_wiki: false)
+ def initialize(full_path, project, disk_path: nil, repo_type: Gitlab::GlRepository::PROJECT)
@full_path = full_path
@disk_path = disk_path || full_path
@project = project
@commit_cache = {}
- @is_wiki = is_wiki
+ @repo_type = repo_type
end
def ==(other)
@@ -79,7 +81,7 @@ class Repository
end
def raw_repository
- return nil unless full_path
+ return unless full_path
@raw_repository ||= initialize_raw_repository
end
@@ -103,7 +105,7 @@ class Repository
end
def commit(ref = nil)
- return nil unless exists?
+ return unless exists?
return ref if ref.is_a?(::Commit)
find_commit(ref || root_ref)
@@ -265,16 +267,14 @@ class Repository
# to avoid unnecessary syncing.
def keep_around(*shas)
shas.each do |sha|
- begin
- next unless sha.present? && commit_by(oid: sha)
+ next unless sha.present? && commit_by(oid: sha)
- next if kept_around?(sha)
+ next if kept_around?(sha)
- # This will still fail if the file is corrupted (e.g. 0 bytes)
- raw_repository.write_ref(keep_around_ref_name(sha), sha)
- rescue Gitlab::Git::CommandError => ex
- Rails.logger.error "Unable to create keep-around reference for repository #{disk_path}: #{ex}"
- end
+ # This will still fail if the file is corrupted (e.g. 0 bytes)
+ raw_repository.write_ref(keep_around_ref_name(sha), sha)
+ rescue Gitlab::Git::CommandError => ex
+ Rails.logger.error "Unable to create keep-around reference for repository #{disk_path}: #{ex}"
end
end
@@ -283,14 +283,19 @@ class Repository
end
def diverging_commit_counts(branch)
+ return diverging_commit_counts_without_max(branch) if Feature.enabled?('gitaly_count_diverging_commits_no_max')
+
+ ## TODO: deprecate the below code after 12.0
@root_ref_hash ||= raw_repository.commit(root_ref).id
cache.fetch(:"diverging_commit_counts_#{branch.name}") do
# Rugged seems to throw a `ReferenceError` when given branch_names rather
# than SHA-1 hashes
+ branch_sha = branch.dereferenced_target.sha
+
number_commits_behind, number_commits_ahead =
raw_repository.diverging_commit_count(
@root_ref_hash,
- branch.dereferenced_target.sha,
+ branch_sha,
max_count: MAX_DIVERGING_COUNT)
if number_commits_behind + number_commits_ahead >= MAX_DIVERGING_COUNT
@@ -301,13 +306,30 @@ class Repository
end
end
- def archive_metadata(ref, storage_path, format = "tar.gz", append_sha:)
+ def diverging_commit_counts_without_max(branch)
+ @root_ref_hash ||= raw_repository.commit(root_ref).id
+ cache.fetch(:"diverging_commit_counts_without_max_#{branch.name}") do
+ # Rugged seems to throw a `ReferenceError` when given branch_names rather
+ # than SHA-1 hashes
+ branch_sha = branch.dereferenced_target.sha
+
+ number_commits_behind, number_commits_ahead =
+ raw_repository.diverging_commit_count(
+ @root_ref_hash,
+ branch_sha)
+
+ { behind: number_commits_behind, ahead: number_commits_ahead }
+ end
+ end
+
+ def archive_metadata(ref, storage_path, format = "tar.gz", append_sha:, path: nil)
raw_repository.archive_metadata(
ref,
storage_path,
project.path,
format,
- append_sha: append_sha
+ append_sha: append_sha,
+ path: path
)
end
@@ -464,7 +486,7 @@ class Repository
def after_import
expire_content_cache
- DetectRepositoryLanguagesWorker.perform_async(project.id, project.owner.id)
+ DetectRepositoryLanguagesWorker.perform_async(project.id)
end
# Runs code after a new commit has been pushed.
@@ -534,10 +556,9 @@ class Repository
end
def root_ref
- # When the repo does not exist, or there is no root ref, we raise this error so no data is cached.
- raw_repository&.root_ref or raise Gitlab::Git::Repository::NoRepository # rubocop:disable Style/AndOr
+ raw_repository&.root_ref
end
- cache_method :root_ref
+ cache_method_asymmetrically :root_ref
# Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/314
def exists?
@@ -604,6 +625,11 @@ class Repository
end
cache_method :merge_request_template_names, fallback: []
+ def metrics_dashboard_paths
+ Gitlab::Metrics::Dashboard::Finder.find_all_paths_from_source(project)
+ end
+ cache_method :metrics_dashboard_paths
+
def readme
head_tree&.readme
end
@@ -854,6 +880,12 @@ class Repository
end
end
+ def merge_to_ref(user, source_sha, merge_request, target_ref, message)
+ branch = merge_request.target_branch
+
+ raw.merge_to_ref(user, source_sha, branch, target_ref, message)
+ end
+
def ff_merge(user, source, target_branch, merge_request: nil)
their_commit_id = commit(source)&.id
raise 'Invalid merge source' if their_commit_id.nil?
@@ -1026,11 +1058,41 @@ class Repository
raw_repository.fetch_ref(source_repository.raw_repository, source_ref: source_ref, target_ref: target_ref)
end
+ # DEPRECATED: https://gitlab.com/gitlab-org/gitaly/issues/1628
+ def rebase_deprecated(user, merge_request)
+ rebase_sha = raw.rebase_deprecated(
+ user,
+ merge_request.id,
+ branch: merge_request.source_branch,
+ branch_sha: merge_request.source_branch_sha,
+ remote_repository: merge_request.target_project.repository.raw,
+ remote_branch: merge_request.target_branch
+ )
+
+ # To support the full deprecated behaviour, set the
+ # `rebase_commit_sha` for the merge_request here and return the value
+ merge_request.update(rebase_commit_sha: rebase_sha, merge_error: nil)
+
+ rebase_sha
+ end
+
def rebase(user, merge_request)
- 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)
+ if Feature.disabled?(:two_step_rebase, default_enabled: true)
+ 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
+ end
end
def squash(user, merge_request, message)
@@ -1061,6 +1123,19 @@ class Repository
blob.data
end
+ def create_if_not_exists
+ return if exists?
+
+ raw.create_repository
+ after_create
+ end
+
+ def blobs_metadata(paths, ref = 'HEAD')
+ references = Array.wrap(paths).map { |path| [ref, path] }
+
+ Gitlab::Git::Blob.batch_metadata(raw, references).map { |raw_blob| Blob.decorate(raw_blob) }
+ end
+
private
# TODO Generice finder, later split this on finders by Ref or Oid
@@ -1109,7 +1184,7 @@ class Repository
def initialize_raw_repository
Gitlab::Git::Repository.new(project.repository_storage,
disk_path + '.git',
- Gitlab::GlRepository.gl_repository(project, is_wiki),
+ repo_type.identifier_for_subject(project),
project.full_path)
end
end
diff --git a/app/models/repository_language.rb b/app/models/repository_language.rb
index b18142a2ac4..e6867f905e2 100644
--- a/app/models/repository_language.rb
+++ b/app/models/repository_language.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class RepositoryLanguage < ActiveRecord::Base
+class RepositoryLanguage < ApplicationRecord
belongs_to :project
belongs_to :programming_language
diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb
index 3fd96b9dc18..f2c7cb6a65d 100644
--- a/app/models/resource_label_event.rb
+++ b/app/models/resource_label_event.rb
@@ -2,7 +2,7 @@
# This model is not used yet, it will be used for:
# https://gitlab.com/gitlab-org/gitlab-ce/issues/48483
-class ResourceLabelEvent < ActiveRecord::Base
+class ResourceLabelEvent < ApplicationRecord
include Importable
include Gitlab::Utils::StrongMemoize
include CacheMarkdownField
diff --git a/app/models/route.rb b/app/models/route.rb
index 4b23dfa5778..91ea2966013 100644
--- a/app/models/route.rb
+++ b/app/models/route.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Route < ActiveRecord::Base
+class Route < ApplicationRecord
include CaseSensitivity
belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
@@ -14,26 +14,26 @@ class Route < ActiveRecord::Base
before_validation :delete_conflicting_orphaned_routes
after_create :delete_conflicting_redirects
- after_update :delete_conflicting_redirects, if: :path_changed?
+ after_update :delete_conflicting_redirects, if: :saved_change_to_path?
after_update :create_redirect_for_old_path
after_update :rename_descendants
scope :inside_path, -> (path) { where('routes.path LIKE ?', "#{sanitize_sql_like(path)}/%") }
def rename_descendants
- return unless path_changed? || name_changed?
+ return unless saved_change_to_path? || saved_change_to_name?
- descendant_routes = self.class.inside_path(path_was)
+ descendant_routes = self.class.inside_path(path_before_last_save)
descendant_routes.each do |route|
attributes = {}
- if path_changed? && route.path.present?
- attributes[:path] = route.path.sub(path_was, path)
+ if saved_change_to_path? && route.path.present?
+ attributes[:path] = route.path.sub(path_before_last_save, path)
end
- if name_changed? && name_was.present? && route.name.present?
- attributes[:name] = route.name.sub(name_was, name)
+ if saved_change_to_name? && name_before_last_save.present? && route.name.present?
+ attributes[:name] = route.name.sub(name_before_last_save, name)
end
if attributes.present?
@@ -65,7 +65,7 @@ class Route < ActiveRecord::Base
private
def create_redirect_for_old_path
- create_redirect(path_was) if path_changed?
+ create_redirect(path_before_last_save) if saved_change_to_path?
end
def delete_conflicting_orphaned_routes
diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb
index 6caab24143b..0427d5b9ca7 100644
--- a/app/models/sent_notification.rb
+++ b/app/models/sent_notification.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class SentNotification < ActiveRecord::Base
+class SentNotification < ApplicationRecord
serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiveRecordSerialize
belongs_to :project
diff --git a/app/models/serverless/function.rb b/app/models/serverless/function.rb
new file mode 100644
index 00000000000..5d4f8e0c9e2
--- /dev/null
+++ b/app/models/serverless/function.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Serverless
+ class Function
+ attr_accessor :name, :namespace
+
+ def initialize(project, name, namespace)
+ @project = project
+ @name = name
+ @namespace = namespace
+ end
+
+ def id
+ @project.id.to_s + "/" + @name + "/" + @namespace
+ end
+
+ def self.find_by_id(id)
+ array = id.split("/")
+ project = Project.find_by_id(array[0])
+ name = array[1]
+ namespace = array[2]
+
+ self.new(project, name, namespace)
+ end
+ end
+end
diff --git a/app/models/service.rb b/app/models/service.rb
index 3461e0bfe70..9896aa12e90 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -2,7 +2,7 @@
# To add new service you should build a class inherited from Service
# and implement a set of methods
-class Service < ActiveRecord::Base
+class Service < ApplicationRecord
include Sortable
include Importable
include ProjectServicesLoggable
@@ -50,6 +50,7 @@ class Service < ActiveRecord::Base
scope :job_hooks, -> { where(job_events: true, active: true) }
scope :pipeline_hooks, -> { where(pipeline_events: true, active: true) }
scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) }
+ scope :deployment_hooks, -> { where(deployment_events: true, active: true) }
scope :external_issue_trackers, -> { issue_trackers.active.without_defaults }
scope :deployment, -> { where(category: 'deployment') }
@@ -255,6 +256,7 @@ class Service < ActiveRecord::Base
external_wiki
flowdock
hangouts_chat
+ hipchat
irker
jira
kubernetes
@@ -266,6 +268,7 @@ class Service < ActiveRecord::Base
prometheus
pushover
redmine
+ youtrack
slack_slash_commands
slack
teamcity
@@ -333,6 +336,8 @@ class Service < ActiveRecord::Base
"Event will be triggered when a wiki page is created/updated"
when "commit", "commit_events"
"Event will be triggered when a commit is created/updated"
+ when "deployment"
+ "Event will be triggered when a deployment finishes"
end
end
diff --git a/app/models/shard.rb b/app/models/shard.rb
index e39d4232486..335a279c6aa 100644
--- a/app/models/shard.rb
+++ b/app/models/shard.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Shard < ActiveRecord::Base
+class Shard < ApplicationRecord
# Store shard names from the configuration file in the database. This is not a
# list of active shards - we just want to assign an immutable, unique ID to
# every shard name for easy indexing / referencing.
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index f23ddd64fe3..f4fdac2558c 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Snippet < ActiveRecord::Base
+class Snippet < ApplicationRecord
include Gitlab::VisibilityLevel
include Redactable
include CacheMarkdownField
diff --git a/app/models/spam_log.rb b/app/models/spam_log.rb
index ef3f974b959..5b9ece8373f 100644
--- a/app/models/spam_log.rb
+++ b/app/models/spam_log.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class SpamLog < ActiveRecord::Base
+class SpamLog < ApplicationRecord
belongs_to :user
validates :user, presence: true
diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb
index fd23cc9ac87..b6fb39ee81f 100644
--- a/app/models/ssh_host_key.rb
+++ b/app/models/ssh_host_key.rb
@@ -27,7 +27,7 @@ class SshHostKey
def self.find_by(opts = {})
opts = HashWithIndifferentAccess.new(opts)
- return nil unless opts.key?(:id)
+ return unless opts.key?(:id)
project_id, url = opts[:id].split(':', 2)
project = Project.find_by(id: project_id)
diff --git a/app/models/storage/legacy_project.rb b/app/models/storage/legacy_project.rb
index 76ac5c13c18..b483c677be9 100644
--- a/app/models/storage/legacy_project.rb
+++ b/app/models/storage/legacy_project.rb
@@ -30,7 +30,7 @@ module Storage
end
def rename_repo(old_full_path: nil, new_full_path: nil)
- old_full_path ||= project.full_path_was
+ old_full_path ||= project.full_path_before_last_save
new_full_path ||= project.build_full_path
if gitlab_shell.mv_repository(repository_storage, old_full_path, new_full_path)
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
index 0f6ee0ddf7e..24a2b8b5167 100644
--- a/app/models/subscription.rb
+++ b/app/models/subscription.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Subscription < ActiveRecord::Base
+class Subscription < ApplicationRecord
belongs_to :user
belongs_to :project
belongs_to :subscribable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb
index 7eee4fbbe5f..22e2f11230d 100644
--- a/app/models/suggestion.rb
+++ b/app/models/suggestion.rb
@@ -1,11 +1,19 @@
# frozen_string_literal: true
class Suggestion < ApplicationRecord
+ include Suggestible
+
belongs_to :note, inverse_of: :suggestions
validates :note, presence: true
validates :commit_id, presence: true, if: :applied?
- delegate :original_position, :position, :noteable, to: :note
+ delegate :position, :noteable, to: :note
+
+ scope :active, -> { where(outdated: false) }
+
+ def diff_file
+ note.latest_diff_file
+ end
def project
noteable.source_project
@@ -19,42 +27,37 @@ class Suggestion < ApplicationRecord
position.file_path
end
- def diff_file
- repository = project.repository
- position.diff_file(repository)
- end
-
- # For now, suggestions only serve as a way to send patches that
- # will change a single line (being able to apply multiple in the same place),
- # which explains `from_line` and `to_line` being the same line.
- # We'll iterate on that in https://gitlab.com/gitlab-org/gitlab-ce/issues/53310
- # when allowing multi-line suggestions.
- def from_line
- position.new_line
- end
- alias_method :to_line, :from_line
-
- def from_original_line
- original_position.new_line
- end
- alias_method :to_original_line, :from_original_line
-
# `from_line_index` and `to_line_index` represents diff/blob line numbers in
# index-like way (N-1).
def from_line_index
from_line - 1
end
- alias_method :to_line_index, :from_line_index
- def appliable?
- return false unless note.supports_suggestion?
+ def to_line_index
+ to_line - 1
+ end
+ def appliable?(cached: true)
!applied? &&
noteable.opened? &&
+ !outdated?(cached: cached) &&
+ note.supports_suggestion? &&
different_content? &&
note.active?
end
+ # Overwrites outdated column
+ def outdated?(cached: true)
+ return super() if cached
+ return true unless diff_file
+
+ from_content != fetch_from_content
+ end
+
+ def target_line
+ position.new_line
+ end
+
private
def different_content?
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index d555ebe5322..55da37c9545 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class SystemNoteMetadata < ActiveRecord::Base
+class SystemNoteMetadata < ApplicationRecord
# These notes's action text might contain a reference that is external.
# We should always force a deep validation upon references that are found
# in this note type.
diff --git a/app/models/term_agreement.rb b/app/models/term_agreement.rb
index 9b3c8ac68bd..a4a9dc10282 100644
--- a/app/models/term_agreement.rb
+++ b/app/models/term_agreement.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class TermAgreement < ActiveRecord::Base
+class TermAgreement < ApplicationRecord
belongs_to :term, class_name: 'ApplicationSetting::Term'
belongs_to :user
diff --git a/app/models/timelog.rb b/app/models/timelog.rb
index e04c644a53a..048134fbf04 100644
--- a/app/models/timelog.rb
+++ b/app/models/timelog.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Timelog < ActiveRecord::Base
+class Timelog < ApplicationRecord
validates :time_spent, :user, presence: true
validate :issuable_id_is_present
diff --git a/app/models/todo.rb b/app/models/todo.rb
index d9b86d941b6..f1fc5e599eb 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Todo < ActiveRecord::Base
+class Todo < ApplicationRecord
include Sortable
include FromUnion
@@ -31,8 +31,16 @@ class Todo < ActiveRecord::Base
belongs_to :note
belongs_to :project
belongs_to :group
- belongs_to :target, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations
+ belongs_to :target, -> {
+ if self.klass.respond_to?(:with_api_entity_associations)
+ self.with_api_entity_associations
+ else
+ self
+ end
+ }, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations
+
belongs_to :user
+ belongs_to :issue, -> { where("target_type = 'Issue'") }, foreign_key: :target_id
delegate :name, :email, to: :author, prefix: true, allow_nil: true
@@ -52,6 +60,8 @@ class Todo < ActiveRecord::Base
scope :for_type, -> (type) { where(target_type: type) }
scope :for_target, -> (id) { where(target_id: id) }
scope :for_commit, -> (id) { where(commit_id: id) }
+ scope :with_api_entity_associations, -> { preload(:target, :author, :note, group: :route, project: [:route, { namespace: :route }]) }
+ scope :joins_issue_and_assignees, -> { left_joins(issue: :assignees) }
state_machine :state, initial: :pending do
event :done do
diff --git a/app/models/trending_project.rb b/app/models/trending_project.rb
index 7b22e8cb760..810dee672b2 100644
--- a/app/models/trending_project.rb
+++ b/app/models/trending_project.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class TrendingProject < ActiveRecord::Base
+class TrendingProject < ApplicationRecord
belongs_to :project
# The number of months to include in the trending calculation.
diff --git a/app/models/u2f_registration.rb b/app/models/u2f_registration.rb
index 37598173fd1..81415eb383b 100644
--- a/app/models/u2f_registration.rb
+++ b/app/models/u2f_registration.rb
@@ -2,7 +2,7 @@
# Registration information for U2F (universal 2nd factor) devices, like Yubikeys
-class U2fRegistration < ActiveRecord::Base
+class U2fRegistration < ApplicationRecord
belongs_to :user
def self.register(user, app_id, params, challenges)
@@ -19,7 +19,7 @@ class U2fRegistration < ActiveRecord::Base
user: user,
name: params[:name])
rescue JSON::ParserError, NoMethodError, ArgumentError
- registration.errors.add(:base, 'Your U2F device did not send a valid JSON response.')
+ registration.errors.add(:base, _('Your U2F device did not send a valid JSON response.'))
rescue U2F::Error => e
registration.errors.add(:base, e.message)
end
diff --git a/app/models/upload.rb b/app/models/upload.rb
index 20860f14b83..ca74f16b3b8 100644
--- a/app/models/upload.rb
+++ b/app/models/upload.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Upload < ActiveRecord::Base
+class Upload < ApplicationRecord
# Upper limit for foreground checksum processing
CHECKSUM_THRESHOLD = 100.megabytes
@@ -45,7 +45,7 @@ class Upload < ActiveRecord::Base
end
def absolute_path
- raise ObjectStorage::RemoteStoreError, "Remote object has no absolute path." unless local?
+ raise ObjectStorage::RemoteStoreError, _("Remote object has no absolute path.") unless local?
return path unless relative_path?
uploader_class.absolute_path(self)
@@ -71,10 +71,10 @@ class Upload < ActiveRecord::Base
# Help sysadmins find missing upload files
if persisted? && !exist
if Gitlab::Sentry.enabled?
- Raven.capture_message("Upload file does not exist", extra: self.attributes)
+ Raven.capture_message(_("Upload file does not exist"), extra: self.attributes)
end
- Gitlab::Metrics.counter(:upload_file_does_not_exist_total, 'The number of times an upload record could not find its file').increment
+ Gitlab::Metrics.counter(:upload_file_does_not_exist_total, _('The number of times an upload record could not find its file')).increment
end
exist
diff --git a/app/models/user.rb b/app/models/user.rb
index ee51c35d576..2eb5c63a4cc 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -105,6 +105,7 @@ class User < ApplicationRecord
has_many :groups, through: :group_members
has_many :owned_groups, -> { where(members: { access_level: Gitlab::Access::OWNER }) }, through: :group_members, source: :group
has_many :maintainers_groups, -> { where(members: { access_level: Gitlab::Access::MAINTAINER }) }, through: :group_members, source: :group
+ has_many :developer_groups, -> { where(members: { access_level: ::Gitlab::Access::DEVELOPER }) }, through: :group_members, source: :group
has_many :owned_or_maintainers_groups,
-> { where(members: { access_level: [Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) },
through: :group_members,
@@ -159,12 +160,12 @@ class User < ApplicationRecord
# Validations
#
# Note: devise :validatable above adds validations for :email and :password
- validates :name, presence: true
+ validates :name, presence: true, length: { maximum: 128 }
validates :email, confirmation: true
validates :notification_email, presence: true
- validates :notification_email, email: true, if: ->(user) { user.notification_email != user.email }
- validates :public_email, presence: true, uniqueness: true, email: true, allow_blank: true
- validates :commit_email, email: true, allow_nil: true, if: ->(user) { user.commit_email != user.email }
+ validates :notification_email, devise_email: true, if: ->(user) { user.notification_email != user.email }
+ validates :public_email, presence: true, uniqueness: true, devise_email: true, allow_blank: true
+ validates :commit_email, devise_email: true, allow_nil: true, if: ->(user) { user.commit_email != user.email }
validates :bio, length: { maximum: 255 }, allow_blank: true
validates :projects_limit,
presence: true,
@@ -193,7 +194,7 @@ class User < ApplicationRecord
before_validation :ensure_namespace_correct
before_save :ensure_namespace_correct # in case validation is skipped
after_validation :set_username_errors
- after_update :username_changed_hook, if: :username_changed?
+ after_update :username_changed_hook, if: :saved_change_to_username?
after_destroy :post_destroy_hook
after_destroy :remove_key_cache
after_commit(on: :update) do
@@ -229,6 +230,9 @@ class User < ApplicationRecord
delegate :notes_filter_for, to: :user_preference
delegate :set_notes_filter, to: :user_preference
delegate :first_day_of_week, :first_day_of_week=, to: :user_preference
+ delegate :timezone, :timezone=, to: :user_preference
+ delegate :time_display_relative, :time_display_relative=, to: :user_preference
+ delegate :time_format_in_24h, :time_format_in_24h=, to: :user_preference
accepts_nested_attributes_for :user_preference, update_only: true
@@ -276,6 +280,7 @@ class User < ApplicationRecord
scope :by_username, -> (usernames) { iwhere(username: Array(usernames).map(&:to_s)) }
scope :for_todos, -> (todos) { where(id: todos.select(:user_id)) }
scope :with_emails, -> { preload(:emails) }
+ scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) }
# Limits the users to those that have TODOs, optionally in the given state.
#
@@ -432,7 +437,7 @@ class User < ApplicationRecord
fuzzy_arel_match(:name, query, lower_exact_match: true)
.or(fuzzy_arel_match(:username, query, lower_exact_match: true))
.or(arel_table[:email].eq(query))
- ).reorder(order % { query: ActiveRecord::Base.connection.quote(query) }, :name)
+ ).reorder(order % { query: ApplicationRecord.connection.quote(query) }, :name)
end
# Limits the result set to users _not_ in the given query/list of IDs.
@@ -470,7 +475,7 @@ class User < ApplicationRecord
end
def by_login(login)
- return nil unless login
+ return unless login
if login.include?('@'.freeze)
unscoped.iwhere(email: login).take
@@ -515,7 +520,7 @@ class User < ApplicationRecord
def ghost
email = 'ghost%s@example.com'
unique_internal(where(ghost: true), 'ghost', email) do |u|
- u.bio = 'This is a "Ghost User", created to hold all issues authored by users that have since been deleted. This user cannot be removed.'
+ u.bio = _('This is a "Ghost User", created to hold all issues authored by users that have since been deleted. This user cannot be removed.')
u.name = 'Ghost User'
end
end
@@ -535,20 +540,16 @@ class User < ApplicationRecord
username
end
- def self.internal_attributes
- [:ghost]
- end
-
def internal?
- self.class.internal_attributes.any? { |a| self[a] }
+ ghost?
end
def self.internal
- where(Hash[internal_attributes.zip([true] * internal_attributes.size)])
+ where(ghost: true)
end
def self.non_internal
- where(internal_attributes.map { |attr| "#{attr} IS NOT TRUE" }.join(" AND "))
+ where('ghost IS NOT TRUE')
end
#
@@ -624,32 +625,32 @@ class User < ApplicationRecord
def namespace_move_dir_allowed
if namespace&.any_project_has_container_registry_tags?
- errors.add(:username, 'cannot be changed if a personal project has container registry tags.')
+ errors.add(:username, _('cannot be changed if a personal project has container registry tags.'))
end
end
def unique_email
if !emails.exists?(email: email) && Email.exists?(email: email)
- errors.add(:email, 'has already been taken')
+ errors.add(:email, _('has already been taken'))
end
end
def owns_notification_email
return if temp_oauth_email?
- errors.add(:notification_email, "is not an email you own") unless all_emails.include?(notification_email)
+ errors.add(:notification_email, _("is not an email you own")) unless all_emails.include?(notification_email)
end
def owns_public_email
return if public_email.blank?
- errors.add(:public_email, "is not an email you own") unless all_emails.include?(public_email)
+ errors.add(:public_email, _("is not an email you own")) unless all_emails.include?(public_email)
end
def owns_commit_email
return if read_attribute(:commit_email).blank?
- errors.add(:commit_email, "is not an email you own") unless verified_emails.include?(commit_email)
+ errors.add(:commit_email, _("is not an email you own")) unless verified_emails.include?(commit_email)
end
# Define commit_email-related attribute methods explicitly instead of relying
@@ -759,11 +760,15 @@ class User < ApplicationRecord
# Typically used in conjunction with projects table to get projects
# a user has been given access to.
+ # The param `related_project_column` is the column to compare to the
+ # project_authorizations. By default is projects.id
#
# Example use:
# `Project.where('EXISTS(?)', user.authorizations_for_projects)`
- def authorizations_for_projects(min_access_level: nil)
- authorizations = project_authorizations.select(1).where('project_authorizations.project_id = projects.id')
+ def authorizations_for_projects(min_access_level: nil, related_project_column: 'projects.id')
+ authorizations = project_authorizations
+ .select(1)
+ .where("project_authorizations.project_id = #{related_project_column}")
return authorizations unless min_access_level.present?
@@ -882,7 +887,12 @@ class User < ApplicationRecord
# rubocop: enable CodeReuse/ServiceClass
def several_namespaces?
- owned_groups.any? || maintainers_groups.any?
+ union_sql = ::Gitlab::SQL::Union.new(
+ [owned_groups,
+ maintainers_groups,
+ groups_with_developer_maintainer_project_access]).to_sql
+
+ ::Group.from("(#{union_sql}) #{::Group.table_name}").any?
end
def namespace_id
@@ -917,6 +927,10 @@ class User < ApplicationRecord
DeployKey.unscoped.in_projects(authorized_projects.pluck(:id)).distinct(:id)
end
+ def highest_role
+ members.maximum(:access_level) || Gitlab::Access::NO_ACCESS
+ end
+
def accessible_deploy_keys
@accessible_deploy_keys ||= begin
key_ids = project_deploy_keys.pluck(:id)
@@ -1164,12 +1178,24 @@ class User < ApplicationRecord
@manageable_namespaces ||= [namespace] + manageable_groups
end
- def manageable_groups
- Gitlab::ObjectHierarchy.new(owned_or_maintainers_groups).base_and_descendants
+ def manageable_groups(include_groups_with_developer_maintainer_access: false)
+ owned_and_maintainer_group_hierarchy = Gitlab::ObjectHierarchy.new(owned_or_maintainers_groups).base_and_descendants
+
+ if include_groups_with_developer_maintainer_access
+ union_sql = ::Gitlab::SQL::Union.new(
+ [owned_and_maintainer_group_hierarchy,
+ groups_with_developer_maintainer_project_access]).to_sql
+
+ ::Group.from("(#{union_sql}) #{::Group.table_name}")
+ else
+ owned_and_maintainer_group_hierarchy
+ end
end
- def manageable_groups_with_routes
- manageable_groups.eager_load(:route).order('routes.path')
+ def manageable_groups_with_routes(include_groups_with_developer_maintainer_access: false)
+ manageable_groups(include_groups_with_developer_maintainer_access: include_groups_with_developer_maintainer_access)
+ .eager_load(:route)
+ .order('routes.path')
end
def namespaces
@@ -1471,15 +1497,6 @@ class User < ApplicationRecord
devise_mailer.__send__(notification, self, *args).deliver_later # rubocop:disable GitlabSecurity/PublicSend
end
- # This works around a bug in Devise 4.2.0 that erroneously causes a user to
- # be considered active in MySQL specs due to a sub-second comparison
- # issue. For more details, see: https://gitlab.com/gitlab-org/gitlab-ee/issues/2362#note_29004709
- def confirmation_period_valid?
- return false if self.class.allow_unconfirmed_access_for == 0.days
-
- super
- end
-
def ensure_user_rights_and_limits
if external?
self.can_create_group = false
@@ -1568,4 +1585,16 @@ class User < ApplicationRecord
ensure
Gitlab::ExclusiveLease.cancel(lease_key, uuid)
end
+
+ def groups_with_developer_maintainer_project_access
+ project_creation_levels = [::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS]
+
+ if ::Gitlab::CurrentSettings.default_project_creation == ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS
+ project_creation_levels << nil
+ end
+
+ developer_groups_hierarchy = ::Gitlab::ObjectHierarchy.new(developer_groups).base_and_descendants
+ ::Group.where(id: developer_groups_hierarchy.select(:id),
+ project_creation_level: project_creation_levels)
+ end
end
diff --git a/app/models/user_agent_detail.rb b/app/models/user_agent_detail.rb
index e2b2e7f1df9..fea1fce3c8d 100644
--- a/app/models/user_agent_detail.rb
+++ b/app/models/user_agent_detail.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class UserAgentDetail < ActiveRecord::Base
+class UserAgentDetail < ApplicationRecord
belongs_to :subject, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :user_agent, :ip_address, :subject_id, :subject_type, presence: true
diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb
index 76e7bc06b4e..027ee44c6a9 100644
--- a/app/models/user_callout.rb
+++ b/app/models/user_callout.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class UserCallout < ActiveRecord::Base
+class UserCallout < ApplicationRecord
belongs_to :user
# We use `UserCalloutEnums.feature_names` here so that EE can more easily
diff --git a/app/models/user_custom_attribute.rb b/app/models/user_custom_attribute.rb
index e0ffe8ebbfd..727975c3f6e 100644
--- a/app/models/user_custom_attribute.rb
+++ b/app/models/user_custom_attribute.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class UserCustomAttribute < ActiveRecord::Base
+class UserCustomAttribute < ApplicationRecord
belongs_to :user
validates :user_id, :key, :value, presence: true
diff --git a/app/models/user_interacted_project.rb b/app/models/user_interacted_project.rb
index ae6778e49be..f6f72f4b77a 100644
--- a/app/models/user_interacted_project.rb
+++ b/app/models/user_interacted_project.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class UserInteractedProject < ActiveRecord::Base
+class UserInteractedProject < ApplicationRecord
belongs_to :user
belongs_to :project
@@ -26,16 +26,14 @@ class UserInteractedProject < ActiveRecord::Base
cached_exists?(attributes) do
transaction(requires_new: true) do
- begin
- where(attributes).select(1).first || create!(attributes)
- true # not caching the whole record here for now
- rescue ActiveRecord::RecordNotUnique
- # Note, above queries are not atomic and prone
- # to race conditions (similar like #find_or_create!).
- # In the case where we hit this, the record we want
- # already exists - shortcut and return.
- true
- end
+ where(attributes).select(1).first || create!(attributes)
+ true # not caching the whole record here for now
+ rescue ActiveRecord::RecordNotUnique
+ # Note, above queries are not atomic and prone
+ # to race conditions (similar like #find_or_create!).
+ # In the case where we hit this, the record we want
+ # already exists - shortcut and return.
+ true
end
end
end
diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb
index 32d0407800f..f1326f4c8cb 100644
--- a/app/models/user_preference.rb
+++ b/app/models/user_preference.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class UserPreference < ActiveRecord::Base
+class UserPreference < ApplicationRecord
# We could use enums, but Rails 4 doesn't support multiple
# enum options with same name for multiple fields, also it creates
# extra methods that aren't really needed here.
@@ -10,6 +10,10 @@ class UserPreference < ActiveRecord::Base
validates :issue_notes_filter, :merge_request_notes_filter, inclusion: { in: NOTES_FILTERS.values }, presence: true
+ default_value_for :timezone, value: Time.zone.tzinfo.name, allows_nil: false
+ default_value_for :time_display_relative, value: true, allows_nil: false
+ default_value_for :time_format_in_24h, value: false, allows_nil: false
+
class << self
def notes_filters
{
diff --git a/app/models/user_status.rb b/app/models/user_status.rb
index 2bbb0c59ac1..6ced4f56823 100644
--- a/app/models/user_status.rb
+++ b/app/models/user_status.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class UserStatus < ActiveRecord::Base
+class UserStatus < ApplicationRecord
include CacheMarkdownField
self.primary_key = :user_id
diff --git a/app/models/user_synced_attributes_metadata.rb b/app/models/user_synced_attributes_metadata.rb
index 7115262942d..5aacf11b1cb 100644
--- a/app/models/user_synced_attributes_metadata.rb
+++ b/app/models/user_synced_attributes_metadata.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class UserSyncedAttributesMetadata < ActiveRecord::Base
+class UserSyncedAttributesMetadata < ApplicationRecord
belongs_to :user
validates :user, presence: true
diff --git a/app/models/users_star_project.rb b/app/models/users_star_project.rb
index bdaf58ae1c1..9be6bd2e6f3 100644
--- a/app/models/users_star_project.rb
+++ b/app/models/users_star_project.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class UsersStarProject < ActiveRecord::Base
+class UsersStarProject < ApplicationRecord
belongs_to :project, counter_cache: :star_count, touch: true
belongs_to :user
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index b1d6d461928..cd4c7895587 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -28,16 +28,17 @@ class WikiPage
def self.group_by_directory(pages)
return [] if pages.blank?
- pages.sort_by { |page| [page.directory, page.slug] }
- .group_by(&:directory)
- .map do |dir, pages|
- if dir.present?
- WikiDirectory.new(dir, pages)
- else
- pages
- end
+ pages.each_with_object([]) do |page, grouped_pages|
+ next grouped_pages << page unless page.directory.present?
+
+ directory = grouped_pages.find do |obj|
+ obj.is_a?(WikiDirectory) && obj.slug == page.directory
end
- .flatten
+
+ next directory.pages << page if directory
+
+ grouped_pages << WikiDirectory.new(page.directory, [page])
+ end
end
def self.unhyphenize(name)
@@ -132,7 +133,7 @@ class WikiPage
# The GitLab Commit instance for this page.
def version
- return nil unless persisted?
+ return unless persisted?
@version ||= @page.version
end
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index 72de04203a6..5dd2279ef99 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -22,6 +22,13 @@ class BasePolicy < DeclarativePolicy::Base
Gitlab::CurrentSettings.current_application_settings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC)
end
- # This is prevented in some cases in `gitlab-ee`
+ condition(:external_authorization_enabled, scope: :global, score: 0) do
+ ::Gitlab::ExternalAuthorization.perform_check?
+ end
+
+ rule { external_authorization_enabled & ~full_private_access }.policy do
+ prevent :read_cross_project
+ end
+
rule { default }.enable :read_cross_project
end
diff --git a/app/policies/ci/pipeline_policy.rb b/app/policies/ci/pipeline_policy.rb
index 2c90b8a73cd..662c29a0973 100644
--- a/app/policies/ci/pipeline_policy.rb
+++ b/app/policies/ci/pipeline_policy.rb
@@ -14,6 +14,10 @@ module Ci
@subject.external?
end
+ condition(:triggerer_of_pipeline) do
+ @subject.triggered_by?(@user)
+ end
+
# Disallow users without permissions from accessing internal pipelines
rule { ~can?(:read_build) & ~external_pipeline }.policy do
prevent :read_pipeline
@@ -29,6 +33,14 @@ module Ci
enable :destroy_pipeline
end
+ rule { can?(:admin_pipeline) }.policy do
+ enable :read_pipeline_variable
+ end
+
+ rule { can?(:update_pipeline) & triggerer_of_pipeline }.policy do
+ enable :read_pipeline_variable
+ end
+
def ref_protected?(user, project, tag, ref)
access = ::Gitlab::UserAccess.new(user, project: project)
diff --git a/app/policies/clusters/cluster_policy.rb b/app/policies/clusters/cluster_policy.rb
index d6d590687e2..316bd39f7a3 100644
--- a/app/policies/clusters/cluster_policy.rb
+++ b/app/policies/clusters/cluster_policy.rb
@@ -6,5 +6,6 @@ module Clusters
delegate { cluster.group }
delegate { cluster.project }
+ delegate { cluster.instance }
end
end
diff --git a/app/policies/clusters/instance_policy.rb b/app/policies/clusters/instance_policy.rb
new file mode 100644
index 00000000000..e1045c85e6d
--- /dev/null
+++ b/app/policies/clusters/instance_policy.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Clusters
+ class InstancePolicy < BasePolicy
+ include ClusterableActions
+
+ condition(:has_clusters, scope: :subject) { clusterable_has_clusters? }
+ condition(:can_have_multiple_clusters) { multiple_clusters_available? }
+ condition(:instance_clusters_enabled) { Instance.enabled? }
+
+ rule { admin & instance_clusters_enabled }.policy do
+ enable :read_cluster
+ enable :add_cluster
+ enable :create_cluster
+ enable :update_cluster
+ enable :admin_cluster
+ end
+
+ rule { ~can_have_multiple_clusters & has_clusters }.prevent :add_cluster
+ end
+end
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index 16c58730878..e85397422e6 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -44,7 +44,6 @@ class GlobalPolicy < BasePolicy
prevent :access_api
prevent :access_git
prevent :receive_notifications
- prevent :use_quick_actions
end
rule { required_terms_not_accepted }.policy do
@@ -68,6 +67,10 @@ class GlobalPolicy < BasePolicy
enable :read_users_list
end
+ rule { ~anonymous }.policy do
+ enable :read_instance_metadata
+ end
+
rule { admin }.policy do
enable :read_custom_attribute
enable :update_custom_attribute
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index c25766a5af8..ea86858181d 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -26,7 +26,7 @@ class GroupPolicy < BasePolicy
condition(:can_change_parent_share_with_group_lock) { can?(:change_share_with_group_lock, @subject.parent) }
condition(:has_projects) do
- GroupProjectsFinder.new(group: @subject, current_user: @user, options: { include_subgroups: true }).execute.any?
+ GroupProjectsFinder.new(group: @subject, current_user: @user, options: { include_subgroups: true, only_owned: true }).execute.any?
end
condition(:has_clusters, scope: :subject) { clusterable_has_clusters? }
@@ -35,6 +35,14 @@ class GroupPolicy < BasePolicy
with_options scope: :subject, score: 0
condition(:request_access_enabled) { @subject.request_access_enabled }
+ condition(:create_projects_disabled) do
+ @subject.project_creation_level == ::Gitlab::Access::NO_ONE_PROJECT_ACCESS
+ end
+
+ condition(:developer_maintainer_access) do
+ @subject.project_creation_level == ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS
+ end
+
rule { public_group }.policy do
enable :read_group
enable :read_list
@@ -53,8 +61,9 @@ class GroupPolicy < BasePolicy
rule { admin }.enable :read_group
rule { has_projects }.policy do
- enable :read_group
+ enable :read_list
enable :read_label
+ enable :read_group
end
rule { has_access }.enable :read_namespace
@@ -114,9 +123,16 @@ class GroupPolicy < BasePolicy
rule { ~can_have_multiple_clusters & has_clusters }.prevent :add_cluster
+ rule { developer & developer_maintainer_access }.enable :create_projects
+ rule { create_projects_disabled }.prevent :create_projects
+
def access_level
return GroupMember::NO_ACCESS if @user.nil?
- @access_level ||= @subject.max_member_access_for_user(@user)
+ @access_level ||= lookup_access_level!
+ end
+
+ def lookup_access_level!
+ @subject.max_member_access_for_user(@user)
end
end
diff --git a/app/policies/identity_provider_policy.rb b/app/policies/identity_provider_policy.rb
new file mode 100644
index 00000000000..d34cdd5bdd4
--- /dev/null
+++ b/app/policies/identity_provider_policy.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class IdentityProviderPolicy < BasePolicy
+ desc "Provider is SAML or CAS3"
+ condition(:protected_provider, scope: :subject, score: 0) { %w(saml cas3).include?(@subject.to_s) }
+
+ rule { anonymous }.prevent_all
+
+ rule { default }.policy do
+ enable :unlink
+ enable :link
+ end
+
+ rule { protected_provider }.prevent(:unlink)
+end
diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb
index ecb2797d1d9..537319addc2 100644
--- a/app/policies/issuable_policy.rb
+++ b/app/policies/issuable_policy.rb
@@ -17,6 +17,7 @@ class IssuablePolicy < BasePolicy
enable :reopen_issue
enable :read_merge_request
enable :update_merge_request
+ enable :reopen_merge_request
end
rule { locked & ~is_project_member }.policy do
diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb
index a2950951d03..a3692857ff4 100644
--- a/app/policies/merge_request_policy.rb
+++ b/app/policies/merge_request_policy.rb
@@ -1,4 +1,7 @@
# frozen_string_literal: true
class MergeRequestPolicy < IssuablePolicy
+ rule { locked }.policy do
+ prevent :reopen_merge_request
+ end
end
diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb
index 2b5cca76c20..40dd49b4afd 100644
--- a/app/policies/personal_snippet_policy.rb
+++ b/app/policies/personal_snippet_policy.rb
@@ -7,7 +7,7 @@ class PersonalSnippetPolicy < BasePolicy
rule { public_snippet }.policy do
enable :read_personal_snippet
- enable :comment_personal_snippet
+ enable :create_note
end
rule { is_author }.policy do
@@ -15,7 +15,7 @@ class PersonalSnippetPolicy < BasePolicy
enable :update_personal_snippet
enable :destroy_personal_snippet
enable :admin_personal_snippet
- enable :comment_personal_snippet
+ enable :create_note
end
rule { ~anonymous }.enable :create_personal_snippet
@@ -23,15 +23,12 @@ class PersonalSnippetPolicy < BasePolicy
rule { internal_snippet & ~external_user }.policy do
enable :read_personal_snippet
- enable :comment_personal_snippet
+ enable :create_note
end
- rule { anonymous }.prevent :comment_personal_snippet
+ rule { anonymous }.prevent :create_note
- rule { can?(:comment_personal_snippet) }.policy do
- enable :create_note
- enable :award_emoji
- end
+ rule { can?(:create_note) }.enable :award_emoji
rule { full_private_access }.enable :read_personal_snippet
end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 95dd8b2795e..3218c04b219 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -89,6 +89,15 @@ class ProjectPolicy < BasePolicy
::Gitlab::CurrentSettings.current_application_settings.mirror_available
end
+ with_scope :subject
+ condition(:classification_label_authorized, score: 32) do
+ ::Gitlab::ExternalAuthorization.access_allowed?(
+ @user,
+ @subject.external_authorization_classification_label,
+ @subject.full_path
+ )
+ end
+
# We aren't checking `:read_issue` or `:read_merge_request` in this case
# because it could be possible for a user to see an issuable-iid
# (`:read_issue_iid` or `:read_merge_request_iid`) but then wouldn't be
@@ -187,6 +196,7 @@ class ProjectPolicy < BasePolicy
rule { can?(:reporter_access) }.policy do
enable :download_code
+ enable :read_statistics
enable :download_wiki_code
enable :fork_project
enable :create_project_snippet
@@ -203,6 +213,7 @@ class ProjectPolicy < BasePolicy
enable :read_deployment
enable :read_merge_request
enable :read_sentry_issue
+ enable :read_prometheus
end
# We define `:public_user_access` separately because there are cases in gitlab-ee
@@ -231,6 +242,7 @@ class ProjectPolicy < BasePolicy
enable :admin_merge_request
enable :admin_milestone
enable :update_merge_request
+ enable :reopen_merge_request
enable :create_commit_status
enable :update_commit_status
enable :create_build
@@ -278,6 +290,8 @@ class ProjectPolicy < BasePolicy
enable :admin_cluster
enable :create_environment_terminal
enable :destroy_release
+ enable :destroy_artifacts
+ enable :daily_statistics
end
rule { (mirror_available & can?(:admin_project)) | admin }.enable :admin_remote_mirror
@@ -299,6 +313,8 @@ class ProjectPolicy < BasePolicy
rule { issues_disabled }.policy do
prevent(*create_read_update_admin_destroy(:issue))
+ prevent(*create_read_update_admin_destroy(:board))
+ prevent(*create_read_update_admin_destroy(:list))
end
rule { merge_requests_disabled | repository_disabled }.policy do
@@ -410,6 +426,25 @@ class ProjectPolicy < BasePolicy
rule { ~can_have_multiple_clusters & has_clusters }.prevent :add_cluster
+ rule { ~can?(:read_cross_project) & ~classification_label_authorized }.policy do
+ # Preventing access here still allows the projects to be listed. Listing
+ # projects doesn't check the `:read_project` ability. But instead counts
+ # on the `project_authorizations` table.
+ #
+ # All other actions should explicitly check read project, which would
+ # trigger the `classification_label_authorized` condition.
+ #
+ # `:read_project_for_iids` is not prevented by this condition, as it is
+ # used for cross-project reference checks.
+ prevent :guest_access
+ prevent :public_access
+ prevent :public_user_access
+ prevent :reporter_access
+ prevent :developer_access
+ prevent :maintainer_access
+ prevent :owner_access
+ end
+
private
def team_member?
@@ -453,6 +488,10 @@ class ProjectPolicy < BasePolicy
def team_access_level
return -1 if @user.nil?
+ lookup_access_level!
+ end
+
+ def lookup_access_level!
# NOTE: max_member_access has its own cache
project.team.max_member_access(@user.id)
end
@@ -462,7 +501,7 @@ class ProjectPolicy < BasePolicy
when ProjectFeature::DISABLED
false
when ProjectFeature::PRIVATE
- guest? || admin?
+ admin? || team_access_level >= ProjectFeature.required_minimum_access_level(feature)
else
true
end
diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb
index 6323c1b3389..c5675ef3ea3 100644
--- a/app/presenters/blob_presenter.rb
+++ b/app/presenters/blob_presenter.rb
@@ -13,4 +13,8 @@ class BlobPresenter < Gitlab::View::Presenter::Simple
plain: plain
)
end
+
+ def web_url
+ Gitlab::Routing.url_helpers.project_blob_url(blob.repository.project, File.join(blob.commit_id, blob.path))
+ end
end
diff --git a/app/presenters/blobs/unfold_presenter.rb b/app/presenters/blobs/unfold_presenter.rb
new file mode 100644
index 00000000000..7b13db3bb74
--- /dev/null
+++ b/app/presenters/blobs/unfold_presenter.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require 'gt_one_coercion'
+
+module Blobs
+ class UnfoldPresenter < BlobPresenter
+ include Virtus.model
+ include Gitlab::Utils::StrongMemoize
+
+ attribute :full, Boolean, default: false
+ attribute :since, GtOneCoercion
+ attribute :to, GtOneCoercion
+ attribute :bottom, Boolean
+ attribute :unfold, Boolean, default: true
+ attribute :offset, Integer
+ attribute :indent, Integer, default: 0
+
+ def initialize(blob, params)
+ @subject = blob
+ @all_lines = highlight.lines
+ super(params)
+
+ if full?
+ self.attributes = { since: 1, to: @all_lines.size, bottom: false, unfold: false, offset: 0, indent: 0 }
+ end
+ end
+
+ # Converts a String array to Gitlab::Diff::Line array, with match line added
+ def diff_lines
+ diff_lines = lines.map do |line|
+ Gitlab::Diff::Line.new(line, nil, nil, nil, nil, rich_text: line)
+ end
+
+ add_match_line(diff_lines)
+
+ diff_lines
+ end
+
+ def lines
+ strong_memoize(:lines) do
+ lines = @all_lines
+ lines = lines[since - 1..to - 1] unless full?
+ lines.map(&:html_safe)
+ end
+ end
+
+ def match_line_text
+ return '' if bottom?
+
+ lines_length = lines.length - 1
+ line = [since, lines_length].join(',')
+ "@@ -#{line}+#{line} @@"
+ end
+
+ private
+
+ def add_match_line(diff_lines)
+ return unless unfold?
+
+ if bottom? && to < @all_lines.size
+ old_pos = to - offset
+ new_pos = to
+ elsif since != 1
+ old_pos = new_pos = since
+ end
+
+ # Match line is not needed when it reaches the top limit or bottom limit of the file.
+ return unless new_pos
+
+ match_line = Gitlab::Diff::Line.new(match_line_text, 'match', nil, old_pos, new_pos)
+
+ bottom? ? diff_lines.push(match_line) : diff_lines.unshift(match_line)
+ end
+ end
+end
diff --git a/app/presenters/ci/bridge_presenter.rb b/app/presenters/ci/bridge_presenter.rb
new file mode 100644
index 00000000000..ee11cffe355
--- /dev/null
+++ b/app/presenters/ci/bridge_presenter.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Ci
+ class BridgePresenter < CommitStatusPresenter
+ def detailed_status
+ @detailed_status ||= subject.detailed_status(user)
+ end
+ end
+end
diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb
index d60281c8a0b..471b6d3b726 100644
--- a/app/presenters/ci/build_runner_presenter.rb
+++ b/app/presenters/ci/build_runner_presenter.rb
@@ -25,17 +25,19 @@ module Ci
end
def git_depth
- strong_memoize(:git_depth) do
- git_depth = variables&.find { |variable| variable[:key] == 'GIT_DEPTH' }&.dig(:value)
- git_depth.to_i
- end
+ if git_depth_variable
+ git_depth_variable[:value]
+ else
+ project.default_git_depth
+ end.to_i
end
def refspecs
specs = []
+ specs << refspec_for_merge_request_ref if merge_request_ref?
if git_depth > 0
- specs << refspec_for_branch(ref) if branch? || merge_request?
+ specs << refspec_for_branch(ref) if branch? || legacy_detached_merge_request_pipeline?
specs << refspec_for_tag(ref) if tag?
else
specs << refspec_for_branch
@@ -83,5 +85,15 @@ module Ci
def refspec_for_tag(ref = '*')
"+#{Gitlab::Git::TAG_REF_PREFIX}#{ref}:#{RUNNER_REMOTE_TAG_PREFIX}#{ref}"
end
+
+ def refspec_for_merge_request_ref
+ "+#{ref}:#{ref}"
+ end
+
+ def git_depth_variable
+ strong_memoize(:git_depth_variable) do
+ variables&.find { |variable| variable[:key] == 'GIT_DEPTH' }
+ end
+ end
end
end
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
index 57daf04efc6..358473d0a74 100644
--- a/app/presenters/ci/pipeline_presenter.rb
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -3,6 +3,7 @@
module Ci
class PipelinePresenter < Gitlab::View::Presenter::Delegated
include Gitlab::Utils::StrongMemoize
+ include ActionView::Helpers::UrlHelper
# We use a class method here instead of a constant, allowing EE to redefine
# the returned `Hash` more easily.
@@ -32,5 +33,49 @@ module Ci
"Pipeline is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}"
end
end
+
+ def ref_text
+ if pipeline.detached_merge_request_pipeline?
+ _("for %{link_to_merge_request} with %{link_to_merge_request_source_branch}").html_safe % { link_to_merge_request: link_to_merge_request, link_to_merge_request_source_branch: link_to_merge_request_source_branch }
+ elsif pipeline.merge_request_pipeline?
+ _("for %{link_to_merge_request} with %{link_to_merge_request_source_branch} into %{link_to_merge_request_target_branch}").html_safe % { link_to_merge_request: link_to_merge_request, link_to_merge_request_source_branch: link_to_merge_request_source_branch, link_to_merge_request_target_branch: link_to_merge_request_target_branch }
+ elsif pipeline.ref
+ if pipeline.ref_exists?
+ _("for %{link_to_pipeline_ref}").html_safe % { link_to_pipeline_ref: link_to_pipeline_ref }
+ else
+ _("for %{ref}").html_safe % { ref: content_tag(:span, pipeline.ref, class: 'ref-name') }
+ end
+ end
+ end
+
+ def link_to_pipeline_ref
+ link_to(pipeline.ref,
+ project_commits_path(pipeline.project, pipeline.ref),
+ class: "ref-name")
+ end
+
+ def link_to_merge_request
+ return unless merge_request_presenter
+
+ link_to(merge_request_presenter.to_reference,
+ project_merge_request_path(merge_request_presenter.project, merge_request_presenter),
+ class: 'mr-iid')
+ end
+
+ def link_to_merge_request_source_branch
+ merge_request_presenter&.source_branch_link
+ end
+
+ def link_to_merge_request_target_branch
+ merge_request_presenter&.target_branch_link
+ end
+
+ private
+
+ def merge_request_presenter
+ return unless pipeline.triggered_by_merge_request?
+
+ @merge_request_presenter ||= pipeline.merge_request.present(current_user: current_user)
+ end
end
end
diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb
index d94d9118eee..34bdf156623 100644
--- a/app/presenters/clusterable_presenter.rb
+++ b/app/presenters/clusterable_presenter.rb
@@ -44,6 +44,10 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated
raise NotImplementedError
end
+ def update_applications_cluster_path(cluster, application)
+ 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 7a5b68f9a4b..1634d2479a0 100644
--- a/app/presenters/clusters/cluster_presenter.rb
+++ b/app/presenters/clusters/cluster_presenter.rb
@@ -22,10 +22,6 @@ module Clusters
"https://console.cloud.google.com/kubernetes/clusters/details/#{provider.zone}/#{name}" if gcp?
end
- def can_toggle_cluster?
- can?(current_user, :update_cluster, cluster) && created?
- end
-
def can_read_cluster?
can?(current_user, :read_cluster, cluster)
end
@@ -35,6 +31,8 @@ module Clusters
s_("ClusterIntegration|Project cluster")
elsif cluster.group_type?
s_("ClusterIntegration|Group cluster")
+ elsif cluster.instance_type?
+ s_("ClusterIntegration|Instance cluster")
end
end
@@ -43,11 +41,17 @@ module Clusters
project_cluster_path(project, cluster)
elsif cluster.group_type?
group_cluster_path(group, cluster)
+ elsif cluster.instance_type?
+ admin_cluster_path(cluster)
else
raise NotImplementedError
end
end
+ def read_only_kubernetes_platform_fields?
+ !cluster.provided_by_user?
+ end
+
private
def clusterable
diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb
index 0cd77da6303..28a25c8b7a3 100644
--- a/app/presenters/commit_status_presenter.rb
+++ b/app/presenters/commit_status_presenter.rb
@@ -11,7 +11,8 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated
runner_unsupported: 'Your runner is outdated, please upgrade your runner',
stale_schedule: 'Delayed job could not be executed by some reason, please try again',
job_execution_timeout: 'The script exceeded the maximum execution time set for the job',
- archived_failure: 'The job is archived and cannot be run'
+ archived_failure: 'The job is archived and cannot be run',
+ unmet_prerequisites: 'The job failed to complete prerequisite tasks'
}.freeze
private_constant :CALLOUT_FAILURE_MESSAGES
diff --git a/app/presenters/group_clusterable_presenter.rb b/app/presenters/group_clusterable_presenter.rb
index ef6bbc0d109..f5b0bb64487 100644
--- a/app/presenters/group_clusterable_presenter.rb
+++ b/app/presenters/group_clusterable_presenter.rb
@@ -14,6 +14,11 @@ class GroupClusterablePresenter < ClusterablePresenter
install_applications_group_cluster_path(clusterable, cluster, application)
end
+ override :update_applications_cluster_path
+ def update_applications_cluster_path(cluster, application)
+ update_applications_group_cluster_path(clusterable, cluster, application)
+ end
+
override :cluster_path
def cluster_path(cluster, params = {})
group_cluster_path(clusterable, cluster, params)
diff --git a/app/presenters/instance_clusterable_presenter.rb b/app/presenters/instance_clusterable_presenter.rb
new file mode 100644
index 00000000000..f8bbe5216f1
--- /dev/null
+++ b/app/presenters/instance_clusterable_presenter.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+class InstanceClusterablePresenter < ClusterablePresenter
+ extend ::Gitlab::Utils::Override
+ include ActionView::Helpers::UrlHelper
+
+ def self.fabricate(clusterable, **attributes)
+ attributes_with_presenter_class = attributes.merge(presenter_class: InstanceClusterablePresenter)
+
+ Gitlab::View::Presenter::Factory
+ .new(clusterable, attributes_with_presenter_class)
+ .fabricate!
+ end
+
+ override :index_path
+ def index_path
+ admin_clusters_path
+ end
+
+ override :new_path
+ def new_path
+ new_admin_cluster_path
+ end
+
+ override :cluster_status_cluster_path
+ def cluster_status_cluster_path(cluster, params = {})
+ cluster_status_admin_cluster_path(cluster, params)
+ end
+
+ override :install_applications_cluster_path
+ def install_applications_cluster_path(cluster, application)
+ install_applications_admin_cluster_path(cluster, application)
+ end
+
+ override :update_applications_cluster_path
+ def update_applications_cluster_path(cluster, application)
+ update_applications_admin_cluster_path(cluster, application)
+ end
+
+ override :cluster_path
+ def cluster_path(cluster, params = {})
+ admin_cluster_path(cluster, params)
+ end
+
+ override :create_user_clusters_path
+ def create_user_clusters_path
+ create_user_admin_clusters_path
+ end
+
+ override :create_gcp_clusters_path
+ def create_gcp_clusters_path
+ create_gcp_admin_clusters_path
+ end
+
+ override :empty_state_help_text
+ def empty_state_help_text
+ s_('ClusterIntegration|Adding an integration will share the cluster across all projects.')
+ end
+
+ override :sidebar_text
+ def sidebar_text
+ s_('ClusterIntegration|Adding a Kubernetes cluster will automatically share the cluster across all projects. Use review apps, deploy your applications, and easily run your pipelines for all projects using the same cluster.')
+ end
+
+ override :learn_more_link
+ def learn_more_link
+ link_to(s_('ClusterIntegration|Learn more about instance Kubernetes clusters'), help_page_path('user/instance/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
+ end
+end
diff --git a/app/presenters/issue_presenter.rb b/app/presenters/issue_presenter.rb
index c12a202efbc..c9dc0dbf443 100644
--- a/app/presenters/issue_presenter.rb
+++ b/app/presenters/issue_presenter.rb
@@ -4,6 +4,16 @@ class IssuePresenter < Gitlab::View::Presenter::Delegated
presents :issue
def web_url
- Gitlab::UrlBuilder.build(issue)
+ url_builder.url
+ end
+
+ def issue_path
+ url_builder.issue_path(issue)
+ end
+
+ private
+
+ def url_builder
+ @url_builder ||= Gitlab::UrlBuilder.new(issue)
end
end
diff --git a/app/presenters/label_presenter.rb b/app/presenters/label_presenter.rb
new file mode 100644
index 00000000000..1077bf543d9
--- /dev/null
+++ b/app/presenters/label_presenter.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+class LabelPresenter < Gitlab::View::Presenter::Delegated
+ presents :label
+
+ def edit_path
+ case label
+ when GroupLabel then edit_group_label_path(label.group, label)
+ when ProjectLabel then edit_project_label_path(label.project, label)
+ end
+ end
+
+ def destroy_path
+ case label
+ when GroupLabel then group_label_path(label.group, label)
+ when ProjectLabel then project_label_path(label.project, label)
+ end
+ end
+
+ def filter_path(type: :issue)
+ case context_subject
+ when Group
+ send("#{type.to_s.pluralize}_group_path", # rubocop:disable GitlabSecurity/PublicSend
+ context_subject,
+ label_name: [label.name])
+ when Project
+ send("namespace_project_#{type.to_s.pluralize}_path", # rubocop:disable GitlabSecurity/PublicSend
+ context_subject.namespace,
+ context_subject,
+ label_name: [label.name])
+ end
+ end
+
+ def can_subscribe_to_label_in_different_levels?
+ issuable_subject.is_a?(Project) && label.is_a?(GroupLabel)
+ end
+
+ def project_label?
+ label.is_a?(ProjectLabel)
+ end
+
+ def subject_name
+ label.subject.name
+ end
+
+ private
+
+ def context_subject
+ issuable_subject || label.try(:subject)
+ end
+end
diff --git a/app/presenters/member_presenter.rb b/app/presenters/member_presenter.rb
index 9e9b6973b8e..2561c3f0244 100644
--- a/app/presenters/member_presenter.rb
+++ b/app/presenters/member_presenter.rb
@@ -32,6 +32,11 @@ class MemberPresenter < Gitlab::View::Presenter::Delegated
request? && can_update?
end
+ # This functionality is only available in EE.
+ def can_override?
+ false
+ end
+
private
def admin_member_permission
diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb
index c59e73f824c..9c44ed711a6 100644
--- a/app/presenters/merge_request_presenter.rb
+++ b/app/presenters/merge_request_presenter.rb
@@ -13,7 +13,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
def ci_status
if pipeline
status = pipeline.status
- status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings?
+ status = "success-with-warnings" if pipeline.success? && pipeline.has_warnings?
status || "preparing"
else
@@ -22,9 +22,9 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
end
end
- def cancel_merge_when_pipeline_succeeds_path
- if can_cancel_merge_when_pipeline_succeeds?(current_user)
- cancel_merge_when_pipeline_succeeds_project_merge_request_path(project, merge_request)
+ def cancel_auto_merge_path
+ if can_cancel_auto_merge?(current_user)
+ cancel_auto_merge_project_merge_request_path(project, merge_request)
end
end
@@ -50,7 +50,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
if user_can_fork_project? && cached_can_be_reverted?
continue_params = {
to: merge_request_path(merge_request),
- notice: "#{edit_in_new_fork_notice} Try to cherry-pick this commit again.",
+ notice: _('%{edit_in_new_fork_notice} Try to cherry-pick this commit again.') % { edit_in_new_fork_notice: edit_in_new_fork_notice },
notice_now: edit_in_new_fork_notice_now
}
@@ -64,7 +64,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
if user_can_fork_project? && can_be_cherry_picked?
continue_params = {
to: merge_request_path(merge_request),
- notice: "#{edit_in_new_fork_notice} Try to revert this commit again.",
+ notice: _('%{edit_in_new_fork_notice} Try to revert this commit again.') % { edit_in_new_fork_notice: edit_in_new_fork_notice },
notice_now: edit_in_new_fork_notice_now
}
@@ -98,6 +98,18 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
end
end
+ def target_branch_path
+ if target_branch_exists?
+ project_branch_path(project, target_branch)
+ end
+ end
+
+ def source_branch_commits_path
+ if source_branch_exists?
+ project_commits_path(source_project, source_branch)
+ end
+ end
+
def source_branch_path
if source_branch_exists?
project_branch_path(source_project, source_branch)
@@ -144,8 +156,11 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
).assignable_issues
path = assign_related_issues_project_merge_request_path(project, merge_request)
if issues.present?
- pluralize_this_issue = issues.count > 1 ? "these issues" : "this issue"
- link_to "Assign yourself to #{pluralize_this_issue}", path, method: :post
+ if issues.count > 1
+ link_to _('Assign yourself to these issues'), path, method: :post
+ else
+ link_to _('Assign yourself to this issue'), path, method: :post
+ end
end
# rubocop: enable CodeReuse/ServiceClass
end
@@ -197,6 +212,26 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
help_page_path('user/project/merge_requests/resolve_conflicts.md')
end
+ def merge_request_pipelines_docs_path
+ help_page_path('ci/merge_request_pipelines/index.md')
+ end
+
+ def source_branch_link
+ if source_branch_exists?
+ link_to(source_branch, source_branch_commits_path, class: 'ref-name')
+ else
+ content_tag(:span, source_branch, class: 'ref-name')
+ end
+ end
+
+ def target_branch_link
+ if target_branch_exists?
+ link_to(target_branch, target_branch_commits_path, class: 'ref-name')
+ else
+ content_tag(:span, target_branch, class: 'ref-name')
+ end
+ end
+
private
def cached_can_be_reverted?
diff --git a/app/presenters/project_clusterable_presenter.rb b/app/presenters/project_clusterable_presenter.rb
index 63e69b91b11..8661ee02b68 100644
--- a/app/presenters/project_clusterable_presenter.rb
+++ b/app/presenters/project_clusterable_presenter.rb
@@ -14,6 +14,11 @@ class ProjectClusterablePresenter < ClusterablePresenter
install_applications_project_cluster_path(clusterable, cluster, application)
end
+ override :update_applications_cluster_path
+ def update_applications_cluster_path(cluster, application)
+ update_applications_project_cluster_path(clusterable, cluster, application)
+ end
+
override :cluster_path
def cluster_path(cluster, params = {})
project_cluster_path(clusterable, cluster, params)
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index 4cac90c2567..9afbaf035c7 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -37,16 +37,12 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
autodevops_anchor_data(show_auto_devops_callout: show_auto_devops_callout),
kubernetes_cluster_anchor_data,
gitlab_ci_anchor_data
- ].compact.reject(&:is_link)
+ ].compact.reject(&:is_link).sort_by.with_index { |item, idx| [item.class_modifier ? 0 : 1, idx] }
end
def empty_repo_statistics_anchors
[
- license_anchor_data,
- commits_anchor_data,
- branches_anchor_data,
- tags_anchor_data,
- files_anchor_data
+ license_anchor_data
].compact.select { |item| item.is_link }
end
@@ -55,9 +51,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
new_file_anchor_data,
readme_anchor_data,
changelog_anchor_data,
- contribution_guide_anchor_data,
- autodevops_anchor_data,
- kubernetes_cluster_anchor_data
+ contribution_guide_anchor_data
].compact.reject { |item| item.is_link }
end
@@ -265,7 +259,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
if current_user && can?(current_user, :admin_pipeline, project) && repository.gitlab_ci_yml.blank? && !show_auto_devops_callout
if auto_devops_enabled?
AnchorData.new(false,
- statistic_icon('doc-text') + _('Auto DevOps enabled'),
+ statistic_icon('settings') + _('Auto DevOps enabled'),
project_settings_ci_cd_path(project, anchor: 'autodevops-settings'),
'default')
else
@@ -315,6 +309,10 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
project.tag_list.take(MAX_TOPICS_TO_SHOW) # rubocop: disable CodeReuse/ActiveRecord
end
+ def topics_not_shown
+ project.tag_list - topics_to_show
+ end
+
def count_of_extra_topics_not_shown
if project.tag_list.count > MAX_TOPICS_TO_SHOW
project.tag_list.count - MAX_TOPICS_TO_SHOW
diff --git a/app/presenters/tree_entry_presenter.rb b/app/presenters/tree_entry_presenter.rb
new file mode 100644
index 00000000000..7bb10cd1455
--- /dev/null
+++ b/app/presenters/tree_entry_presenter.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class TreeEntryPresenter < Gitlab::View::Presenter::Delegated
+ presents :tree
+
+ def web_url
+ Gitlab::Routing.url_helpers.project_tree_url(tree.repository.project, File.join(tree.commit_id, tree.path))
+ end
+end
diff --git a/app/serializers/acts_as_taggable_on/tag_entity.rb b/app/serializers/acts_as_taggable_on/tag_entity.rb
new file mode 100644
index 00000000000..d4e4b69f8fa
--- /dev/null
+++ b/app/serializers/acts_as_taggable_on/tag_entity.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class ActsAsTaggableOn::TagEntity < Grape::Entity
+ expose :id
+ expose :name
+end
diff --git a/app/serializers/acts_as_taggable_on/tag_serializer.rb b/app/serializers/acts_as_taggable_on/tag_serializer.rb
new file mode 100644
index 00000000000..87f53606aa1
--- /dev/null
+++ b/app/serializers/acts_as_taggable_on/tag_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ActsAsTaggableOn::TagSerializer < BaseSerializer
+ entity ActsAsTaggableOn::TagEntity
+end
diff --git a/app/serializers/analytics_stage_entity.rb b/app/serializers/analytics_stage_entity.rb
index ae7c20c3bba..8bc6da5aeeb 100644
--- a/app/serializers/analytics_stage_entity.rb
+++ b/app/serializers/analytics_stage_entity.rb
@@ -9,7 +9,8 @@ class AnalyticsStageEntity < Grape::Entity
expose :description
expose :median, as: :value do |stage|
- # median returns a BatchLoader instance which we first have to unwrap by using to_i
- !stage.median.to_i.zero? ? distance_of_time_in_words(stage.median) : nil
+ # median returns a BatchLoader instance which we first have to unwrap by using to_f
+ # we use to_f to make sure results below 1 are presented to the end-user
+ stage.median.to_f.nonzero? ? distance_of_time_in_words(stage.median) : nil
end
end
diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb
index 9ddce0d2c80..67e44ee9d10 100644
--- a/app/serializers/build_details_entity.rb
+++ b/app/serializers/build_details_entity.rb
@@ -8,16 +8,18 @@ class BuildDetailsEntity < JobEntity
expose :stuck?, as: :stuck
expose :user, using: UserEntity
expose :runner, using: RunnerEntity
+ expose :metadata, using: BuildMetadataEntity
expose :pipeline, using: PipelineEntity
expose :deployment_status, if: -> (*) { build.starts_environment? } do
expose :deployment_status, as: :status
-
- expose :persisted_environment, as: :environment, with: EnvironmentEntity
+ expose :persisted_environment, as: :environment do |build, options|
+ options.merge(deployment_details: false).yield_self do |opts|
+ EnvironmentEntity.represent(build.persisted_environment, opts)
+ end
+ end
end
- expose :metadata, using: BuildMetadataEntity
-
expose :artifact, if: -> (*) { can?(current_user, :read_build, build) } do
expose :download_path, if: -> (*) { build.artifacts? } do |build|
download_project_job_artifacts_path(project, build)
@@ -40,11 +42,18 @@ class BuildDetailsEntity < JobEntity
end
end
+ expose :report_artifacts,
+ as: :reports,
+ using: JobArtifactReportEntity,
+ if: -> (*) { can?(current_user, :read_build, build) }
+
expose :erased_by, if: -> (*) { build.erased? }, using: UserEntity
expose :erase_path, if: -> (*) { build.erasable? && can?(current_user, :erase_build, build) } do |build|
erase_project_job_path(project, build)
end
+ expose :failure_reason, if: -> (*) { build.failed? }
+
expose :terminal_path, if: -> (*) { can_create_build_terminal? } do |build|
terminal_project_job_path(project, build)
end
diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb
index 02df1480828..2a916b13f52 100644
--- a/app/serializers/cluster_application_entity.rb
+++ b/app/serializers/cluster_application_entity.rb
@@ -6,7 +6,9 @@ class ClusterApplicationEntity < Grape::Entity
expose :status_reason
expose :version
expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) }
+ expose :external_hostname, if: -> (e, _) { e.respond_to?(:external_hostname) }
expose :hostname, if: -> (e, _) { e.respond_to?(:hostname) }
expose :email, if: -> (e, _) { e.respond_to?(:email) }
expose :update_available?, as: :update_available, if: -> (e, _) { e.respond_to?(:update_available?) }
+ expose :can_uninstall?, as: :can_uninstall
end
diff --git a/app/serializers/concerns/user_status_tooltip.rb b/app/serializers/concerns/user_status_tooltip.rb
index aa6e67e3351..a81e377691e 100644
--- a/app/serializers/concerns/user_status_tooltip.rb
+++ b/app/serializers/concerns/user_status_tooltip.rb
@@ -3,7 +3,7 @@
module UserStatusTooltip
extend ActiveSupport::Concern
include ActionView::Helpers::TagHelper
- include ActionView::Context
+ include ::Gitlab::ActionViewOutput::Context
include EmojiHelper
include UsersHelper
@@ -11,7 +11,7 @@ module UserStatusTooltip
expose :user_status_if_loaded, as: :status_tooltip_html
def user_status_if_loaded
- return nil unless object.association(:status).loaded?
+ return unless object.association(:status).loaded?
user_status(object)
end
diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb
index 34ae06278c8..943c707218d 100644
--- a/app/serializers/deployment_entity.rb
+++ b/app/serializers/deployment_entity.rb
@@ -20,16 +20,39 @@ class DeploymentEntity < Grape::Entity
expose :created_at
expose :tag
expose :last?
-
expose :user, using: UserEntity
- expose :commit, using: CommitEntity
- expose :deployable, using: JobEntity
- expose :manual_actions, using: JobEntity, if: -> (*) { can_create_deployment? }
- expose :scheduled_actions, using: JobEntity, if: -> (*) { can_create_deployment? }
+
+ expose :deployable do |deployment, opts|
+ deployment.deployable.yield_self do |deployable|
+ if include_details?
+ JobEntity.represent(deployable, opts)
+ elsif can_read_deployables?
+ { name: deployable.name,
+ build_path: project_job_path(deployable.project, deployable) }
+ end
+ end
+ end
+
+ 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? }
private
+ def include_details?
+ options.fetch(:deployment_details, true)
+ end
+
def can_create_deployment?
can?(request.current_user, :create_deployment, request.project)
end
+
+ def can_read_deployables?
+ ##
+ # We intentionally do not check `:read_build, deployment.deployable`
+ # because it triggers a policy evaluation that involves multiple
+ # Gitaly calls that might not be cached.
+ #
+ can?(request.current_user, :read_build, request.project)
+ end
end
diff --git a/app/serializers/detailed_status_entity.rb b/app/serializers/detailed_status_entity.rb
index da994d78286..4f23ef0ed82 100644
--- a/app/serializers/detailed_status_entity.rb
+++ b/app/serializers/detailed_status_entity.rb
@@ -9,16 +9,14 @@ class DetailedStatusEntity < Grape::Entity
expose :details_path
expose :illustration do |status|
- begin
- illustration = {
- image: ActionController::Base.helpers.image_path(status.illustration[:image])
- }
- illustration = status.illustration.merge(illustration)
+ illustration = {
+ image: ActionController::Base.helpers.image_path(status.illustration[:image])
+ }
+ illustration = status.illustration.merge(illustration)
- illustration
- rescue NotImplementedError
- # ignored
- end
+ illustration
+ rescue NotImplementedError
+ # ignored
end
expose :favicon do |status|
diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb
index ede9e04b722..d8630165e49 100644
--- a/app/serializers/diff_file_base_entity.rb
+++ b/app/serializers/diff_file_base_entity.rb
@@ -27,9 +27,13 @@ class DiffFileBaseEntity < Grape::Entity
next unless merge_request.source_project
- project_edit_blob_path(merge_request.source_project,
- tree_join(merge_request.source_branch, diff_file.new_path),
- options)
+ if Feature.enabled?(:web_ide_default)
+ ide_edit_path(merge_request.source_project, merge_request.source_branch, diff_file.new_path)
+ else
+ project_edit_blob_path(merge_request.source_project,
+ tree_join(merge_request.source_branch, diff_file.new_path),
+ options)
+ end
end
expose :old_path_html do |diff_file|
diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb
index 01ee7af37ed..2a5121a2266 100644
--- a/app/serializers/diff_file_entity.rb
+++ b/app/serializers/diff_file_entity.rb
@@ -7,7 +7,7 @@ class DiffFileEntity < DiffFileBaseEntity
expose :added_lines
expose :removed_lines
- expose :load_collapsed_diff_url, if: -> (diff_file, options) { diff_file.viewer.collapsed? && options[:merge_request] } do |diff_file|
+ expose :load_collapsed_diff_url, if: -> (diff_file, options) { options[:merge_request] } do |diff_file|
merge_request = options[:merge_request]
project = merge_request.target_project
@@ -57,6 +57,10 @@ class DiffFileEntity < DiffFileBaseEntity
diff_file.diff_lines_for_serializer
end
+ expose :is_fully_expanded do |diff_file|
+ diff_file.fully_expanded?
+ end
+
# Used for parallel diffs
expose :parallel_diff_lines, using: DiffLineParallelEntity, if: -> (diff_file, _) { diff_file.text? }
end
diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb
index 4a7d13915dd..8258135da4e 100644
--- a/app/serializers/environment_entity.rb
+++ b/app/serializers/environment_entity.rb
@@ -8,10 +8,11 @@ class EnvironmentEntity < Grape::Entity
expose :state
expose :external_url
expose :environment_type
+ expose :name_without_type
expose :last_deployment, using: DeploymentEntity
expose :stop_action_available?, as: :has_stop_action
- expose :metrics_path, if: -> (environment, _) { environment.has_metrics? } do |environment|
+ expose :metrics_path, if: -> (*) { environment.has_metrics? } do |environment|
metrics_project_environment_path(environment.project, environment)
end
diff --git a/app/serializers/group_variable_entity.rb b/app/serializers/group_variable_entity.rb
index 0edab4a3092..19c5fa26f34 100644
--- a/app/serializers/group_variable_entity.rb
+++ b/app/serializers/group_variable_entity.rb
@@ -6,4 +6,5 @@ class GroupVariableEntity < Grape::Entity
expose :value
expose :protected?, as: :protected
+ expose :masked?, as: :masked
end
diff --git a/app/serializers/issuable_sidebar_extras_entity.rb b/app/serializers/issuable_sidebar_extras_entity.rb
index d60253564e1..fb35b7522c5 100644
--- a/app/serializers/issuable_sidebar_extras_entity.rb
+++ b/app/serializers/issuable_sidebar_extras_entity.rb
@@ -11,4 +11,6 @@ class IssuableSidebarExtrasEntity < Grape::Entity
expose :subscribed do |issuable|
issuable.subscribed?(request.current_user, issuable.project)
end
+
+ expose :assignees, using: API::Entities::UserBasic
end
diff --git a/app/serializers/issue_board_entity.rb b/app/serializers/issue_board_entity.rb
index f7719447b92..2e1d7fb3f87 100644
--- a/app/serializers/issue_board_entity.rb
+++ b/app/serializers/issue_board_entity.rb
@@ -2,6 +2,7 @@
class IssueBoardEntity < Grape::Entity
include RequestAwareEntity
+ include TimeTrackableEntity
expose :id
expose :iid
@@ -11,6 +12,7 @@ class IssueBoardEntity < Grape::Entity
expose :due_date
expose :project_id
expose :relative_position
+ expose :time_estimate
expose :project do |issue|
API::Entities::Project.represent issue.project, only: [:id, :path]
diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb
index c3f7d4651fb..36e601f45c5 100644
--- a/app/serializers/issue_entity.rb
+++ b/app/serializers/issue_entity.rb
@@ -42,6 +42,14 @@ class IssueEntity < IssuableEntity
end
expose :preview_note_path do |issue|
- preview_markdown_path(issue.project, quick_actions_target_type: 'Issue', quick_actions_target_id: issue.iid)
+ preview_markdown_path(issue.project, target_type: 'Issue', target_id: issue.iid)
+ end
+
+ expose :confidential_issues_docs_path, if: -> (issue) { issue.confidential? } do |issue|
+ help_page_path('user/project/issues/confidential_issues.md')
+ end
+
+ expose :locked_discussion_docs_path, if: -> (issue) { issue.discussion_locked? } do |issue|
+ help_page_path('user/discussions/index.md', anchor: 'lock-discussions')
end
end
diff --git a/app/serializers/issue_sidebar_extras_entity.rb b/app/serializers/issue_sidebar_extras_entity.rb
index 7b6e860140b..dee891a50b7 100644
--- a/app/serializers/issue_sidebar_extras_entity.rb
+++ b/app/serializers/issue_sidebar_extras_entity.rb
@@ -1,5 +1,4 @@
# frozen_string_literal: true
class IssueSidebarExtrasEntity < IssuableSidebarExtrasEntity
- expose :assignees, using: API::Entities::UserBasic
end
diff --git a/app/serializers/job_artifact_report_entity.rb b/app/serializers/job_artifact_report_entity.rb
new file mode 100644
index 00000000000..4280351a6b0
--- /dev/null
+++ b/app/serializers/job_artifact_report_entity.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class JobArtifactReportEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :file_type
+ expose :file_format
+ expose :size
+
+ expose :download_path do |artifact|
+ download_project_job_artifacts_path(artifact.job.project, artifact.job, file_type: artifact.file_format)
+ end
+end
diff --git a/app/serializers/merge_request_assignee_entity.rb b/app/serializers/merge_request_assignee_entity.rb
new file mode 100644
index 00000000000..6849c62e759
--- /dev/null
+++ b/app/serializers/merge_request_assignee_entity.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class MergeRequestAssigneeEntity < ::API::Entities::UserBasic
+ expose :can_merge do |assignee, options|
+ options[:merge_request]&.can_be_merged_by?(assignee)
+ end
+end
diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb
index 178e72f4f0a..973e971b4c0 100644
--- a/app/serializers/merge_request_basic_entity.rb
+++ b/app/serializers/merge_request_basic_entity.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
class MergeRequestBasicEntity < Grape::Entity
- expose :assignee_id
expose :merge_status
expose :merge_error
expose :state
@@ -9,7 +8,7 @@ class MergeRequestBasicEntity < Grape::Entity
expose :rebase_in_progress?, as: :rebase_in_progress
expose :milestone, using: API::Entities::Milestone
expose :labels, using: LabelEntity
- expose :assignee, using: API::Entities::UserBasic
+ expose :assignees, using: API::Entities::UserBasic
expose :task_status, :task_status_short
expose :lock_version, :lock_version
end
diff --git a/app/serializers/merge_request_for_pipeline_entity.rb b/app/serializers/merge_request_for_pipeline_entity.rb
new file mode 100644
index 00000000000..17a5c4ebbf9
--- /dev/null
+++ b/app/serializers/merge_request_for_pipeline_entity.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class MergeRequestForPipelineEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :iid
+
+ expose :path do |merge_request|
+ project_merge_request_path(merge_request.project, merge_request)
+ end
+
+ expose :title
+ expose :source_branch
+ expose :source_branch_commits_path, as: :source_branch_path
+ expose :target_branch
+ expose :target_branch_commits_path, as: :target_branch_path
+end
diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb
index 4cf84336aa4..6f589351670 100644
--- a/app/serializers/merge_request_serializer.rb
+++ b/app/serializers/merge_request_serializer.rb
@@ -8,9 +8,9 @@ class MergeRequestSerializer < BaseSerializer
entity =
case opts[:serializer]
when 'sidebar'
- MergeRequestSidebarBasicEntity
+ IssuableSidebarBasicEntity
when 'sidebar_extras'
- IssuableSidebarExtrasEntity
+ MergeRequestSidebarExtrasEntity
when 'basic'
MergeRequestBasicEntity
else
diff --git a/app/serializers/merge_request_sidebar_basic_entity.rb b/app/serializers/merge_request_sidebar_basic_entity.rb
deleted file mode 100644
index 0ae7298a7c1..00000000000
--- a/app/serializers/merge_request_sidebar_basic_entity.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-class MergeRequestSidebarBasicEntity < IssuableSidebarBasicEntity
- expose :assignee, if: lambda { |issuable| issuable.assignee } do
- expose :assignee, merge: true, using: API::Entities::UserBasic
-
- expose :can_merge do |issuable|
- issuable.can_be_merged_by?(issuable.assignee)
- end
- end
-end
diff --git a/app/serializers/merge_request_sidebar_extras_entity.rb b/app/serializers/merge_request_sidebar_extras_entity.rb
new file mode 100644
index 00000000000..7276509c363
--- /dev/null
+++ b/app/serializers/merge_request_sidebar_extras_entity.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class MergeRequestSidebarExtrasEntity < IssuableSidebarExtrasEntity
+ expose :assignees do |merge_request|
+ MergeRequestAssigneeEntity.represent(merge_request.assignees, merge_request: merge_request)
+ end
+end
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index 2142ceb6122..a428930dbbf 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -9,7 +9,11 @@ class MergeRequestWidgetEntity < IssuableEntity
expose :merge_params
expose :merge_status
expose :merge_user_id
- expose :merge_when_pipeline_succeeds
+ expose :auto_merge_enabled
+ 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
+ end
expose :source_branch
expose :source_branch_protected do |merge_request|
merge_request.source_project.present? && ProtectedBranch.protected?(merge_request.source_project, merge_request.source_branch)
@@ -20,6 +24,7 @@ class MergeRequestWidgetEntity < IssuableEntity
end
expose :squash
expose :target_branch
+ expose :target_branch_sha
expose :target_project_id
expose :target_project_full_path do |merge_request|
merge_request.project&.full_path
@@ -181,8 +186,8 @@ class MergeRequestWidgetEntity < IssuableEntity
presenter(merge_request).remove_wip_path
end
- expose :cancel_merge_when_pipeline_succeeds_path do |merge_request|
- presenter(merge_request).cancel_merge_when_pipeline_succeeds_path
+ expose :cancel_auto_merge_path do |merge_request|
+ presenter(merge_request).cancel_auto_merge_path
end
expose :create_issue_to_resolve_discussions_path do |merge_request|
@@ -234,7 +239,7 @@ class MergeRequestWidgetEntity < IssuableEntity
end
expose :preview_note_path do |merge_request|
- preview_markdown_path(merge_request.project, quick_actions_target_type: 'MergeRequest', quick_actions_target_id: merge_request.iid)
+ preview_markdown_path(merge_request.project, target_type: 'MergeRequest', target_id: merge_request.iid)
end
expose :merge_commit_path do |merge_request|
@@ -255,6 +260,10 @@ class MergeRequestWidgetEntity < IssuableEntity
presenter(merge_request).conflicts_docs_path
end
+ expose :merge_request_pipelines_docs_path do |merge_request|
+ presenter(merge_request).merge_request_pipelines_docs_path
+ end
+
private
delegate :current_user, to: :request
diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb
index d78ad4af4dc..dfef4364965 100644
--- a/app/serializers/pipeline_details_entity.rb
+++ b/app/serializers/pipeline_details_entity.rb
@@ -1,8 +1,11 @@
# frozen_string_literal: true
class PipelineDetailsEntity < PipelineEntity
+ expose :flags do
+ expose :latest?, as: :latest
+ end
+
expose :details do
- expose :ordered_stages, as: :stages, using: StageEntity
expose :artifacts, using: BuildArtifactEntity
expose :manual_actions, using: BuildActionEntity
expose :scheduled_actions, using: BuildActionEntity
diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb
index 29b1a6c244b..ec2698ecbe3 100644
--- a/app/serializers/pipeline_entity.rb
+++ b/app/serializers/pipeline_entity.rb
@@ -4,6 +4,7 @@ class PipelineEntity < Grape::Entity
include RequestAwareEntity
expose :id
+ expose :iid
expose :user, using: UserEntity
expose :active?, as: :active
@@ -20,22 +21,28 @@ class PipelineEntity < Grape::Entity
end
expose :flags do
- expose :latest?, as: :latest
expose :stuck?, as: :stuck
expose :auto_devops_source?, as: :auto_devops
- expose :merge_request?, as: :merge_request
+ expose :merge_request_event?, as: :merge_request
expose :has_yaml_errors?, as: :yaml_errors
expose :can_retry?, as: :retryable
expose :can_cancel?, as: :cancelable
expose :failure_reason?, as: :failure_reason
+ expose :detached_merge_request_pipeline?, as: :detached_merge_request_pipeline
+ expose :merge_request_pipeline?, as: :merge_request_pipeline
end
expose :details do
expose :detailed_status, as: :status, with: DetailedStatusEntity
+ expose :ordered_stages, as: :stages, using: StageEntity
expose :duration
expose :finished_at
end
+ expose :merge_request, if: -> (*) { has_presentable_merge_request? }, with: MergeRequestForPipelineEntity do |pipeline|
+ pipeline.merge_request.present(current_user: request.current_user)
+ end
+
expose :ref do
expose :name do |pipeline|
pipeline.ref
@@ -49,10 +56,12 @@ class PipelineEntity < Grape::Entity
expose :tag?, as: :tag
expose :branch?, as: :branch
- expose :merge_request?, as: :merge_request
+ expose :merge_request_event?, as: :merge_request
end
expose :commit, using: CommitEntity
+ expose :source_sha, if: -> (pipeline, _) { pipeline.merge_request_pipeline? }
+ expose :target_sha, if: -> (pipeline, _) { pipeline.merge_request_pipeline? }
expose :yaml_errors, if: -> (pipeline, _) { pipeline.has_yaml_errors? }
expose :failure_reason, if: -> (pipeline, _) { pipeline.failure_reason? } do |pipeline|
@@ -81,6 +90,11 @@ class PipelineEntity < Grape::Entity
pipeline.cancelable?
end
+ def has_presentable_merge_request?
+ pipeline.triggered_by_merge_request? &&
+ can?(request.current_user, :read_merge_request, pipeline.merge_request)
+ end
+
def detailed_status
pipeline.detailed_status(request.current_user)
end
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index 7451433a841..95d73c6422d 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -7,22 +7,7 @@ class PipelineSerializer < BaseSerializer
# rubocop: disable CodeReuse/ActiveRecord
def represent(resource, opts = {})
if resource.is_a?(ActiveRecord::Relation)
- resource = resource.preload([
- :stages,
- :retryable_builds,
- :cancelable_statuses,
- :trigger_requests,
- :manual_actions,
- :scheduled_actions,
- :artifacts,
- {
- pending_builds: :project,
- project: [:route, { namespace: :route }],
- artifacts: {
- project: [:route, { namespace: :route }]
- }
- }
- ])
+ resource = resource.preload(preloaded_relations)
end
if paginated?
@@ -50,4 +35,26 @@ class PipelineSerializer < BaseSerializer
data = represent(resource, { only: [{ details: [:stages] }], preload: true })
data.dig(:details, :stages) || []
end
+
+ private
+
+ def preloaded_relations
+ [
+ :stages,
+ :retryable_builds,
+ :cancelable_statuses,
+ :trigger_requests,
+ :manual_actions,
+ :scheduled_actions,
+ :artifacts,
+ :merge_request,
+ {
+ pending_builds: :project,
+ project: [:route, { namespace: :route }],
+ artifacts: {
+ project: [:route, { namespace: :route }]
+ }
+ }
+ ]
+ end
end
diff --git a/app/serializers/projects/serverless/service_entity.rb b/app/serializers/projects/serverless/service_entity.rb
index c98dc1a1c4a..a46f8af1466 100644
--- a/app/serializers/projects/serverless/service_entity.rb
+++ b/app/serializers/projects/serverless/service_entity.rb
@@ -32,6 +32,13 @@ module Projects
service.dig('podcount')
end
+ expose :metrics_url do |service|
+ project_serverless_metrics_path(
+ request.project,
+ service.dig('environment_scope'),
+ service.dig('metadata', 'name')) + ".json"
+ end
+
expose :created_at do |service|
service.dig('metadata', 'creationTimestamp')
end
diff --git a/app/serializers/suggestion_entity.rb b/app/serializers/suggestion_entity.rb
index 4d0d4da10be..2dd62e19e29 100644
--- a/app/serializers/suggestion_entity.rb
+++ b/app/serializers/suggestion_entity.rb
@@ -3,6 +3,8 @@
class SuggestionEntity < API::Entities::Suggestion
include RequestAwareEntity
+ unexpose :from_line, :to_line, :from_content, :to_content
+ expose :diff_lines, using: DiffLineEntity
expose :current_user do
expose :can_apply do |suggestion|
Ability.allowed?(current_user, :apply_suggestion, suggestion)
diff --git a/app/serializers/suggestion_serializer.rb b/app/serializers/suggestion_serializer.rb
new file mode 100644
index 00000000000..010344f9fcd
--- /dev/null
+++ b/app/serializers/suggestion_serializer.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class SuggestionSerializer < BaseSerializer
+ entity SuggestionEntity
+
+ def represent_diff(resource)
+ represent(resource, { only: [:diff_lines] })
+ end
+end
diff --git a/app/serializers/test_case_entity.rb b/app/serializers/test_case_entity.rb
index ec60055ba5b..5c915c1302c 100644
--- a/app/serializers/test_case_entity.rb
+++ b/app/serializers/test_case_entity.rb
@@ -3,6 +3,7 @@
class TestCaseEntity < Grape::Entity
expose :status
expose :name
+ expose :classname
expose :execution_time
expose :system_output
expose :stack_trace
diff --git a/app/serializers/variable_entity.rb b/app/serializers/variable_entity.rb
index 85cf367fe51..4d48e13cfca 100644
--- a/app/serializers/variable_entity.rb
+++ b/app/serializers/variable_entity.rb
@@ -6,4 +6,5 @@ class VariableEntity < Grape::Entity
expose :value
expose :protected?, as: :protected
+ expose :masked?, as: :masked
end
diff --git a/app/services/after_branch_delete_service.rb b/app/services/after_branch_delete_service.rb
deleted file mode 100644
index e7eb74d3e7d..00000000000
--- a/app/services/after_branch_delete_service.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-##
-# Branch can be deleted either by DeleteBranchService
-# or by GitPushService.
-#
-class AfterBranchDeleteService < BaseService
- attr_reader :branch_name
-
- def execute(branch_name)
- @branch_name = branch_name
-
- stop_environments
- end
-
- private
-
- def stop_environments
- Ci::StopEnvironmentsService
- .new(project, current_user)
- .execute(branch_name)
- end
-end
diff --git a/app/services/application_settings/update_service.rb b/app/services/application_settings/update_service.rb
index 2e4643ed668..7eeaf8aade1 100644
--- a/app/services/application_settings/update_service.rb
+++ b/app/services/application_settings/update_service.rb
@@ -2,9 +2,17 @@
module ApplicationSettings
class UpdateService < ApplicationSettings::BaseService
+ include ValidatesClassificationLabel
+
attr_reader :params, :application_setting
def execute
+ validate_classification_label(application_setting, :external_authorization_service_default_label)
+
+ if application_setting.errors.any?
+ return false
+ end
+
update_terms(@params.delete(:terms))
if params.key?(:performance_bar_allowed_group_path)
@@ -38,7 +46,7 @@ module ApplicationSettings
def performance_bar_allowed_group_id
performance_bar_enabled = !params.key?(:performance_bar_enabled) || params.delete(:performance_bar_enabled)
group_full_path = params.delete(:performance_bar_allowed_group_path)
- return nil unless Gitlab::Utils.to_boolean(performance_bar_enabled)
+ return unless Gitlab::Utils.to_boolean(performance_bar_enabled)
Group.find_by_full_path(group_full_path)&.id if group_full_path.present?
end
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index e95ba09c006..707caee482c 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -116,7 +116,7 @@ module Auth
build_can_pull?(requested_project) || user_can_pull?(requested_project) || deploy_token_can_pull?(requested_project)
when 'push'
build_can_push?(requested_project) || user_can_push?(requested_project)
- when '*'
+ when '*', 'delete'
user_can_admin?(requested_project)
else
false
diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb
new file mode 100644
index 00000000000..058105db3a4
--- /dev/null
+++ b/app/services/auto_merge/base_service.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module AutoMerge
+ class BaseService < ::BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ def execute(merge_request)
+ merge_request.merge_params.merge!(params)
+ merge_request.auto_merge_enabled = true
+ merge_request.merge_user = current_user
+ merge_request.auto_merge_strategy = strategy
+
+ return :failed unless merge_request.save
+
+ yield if block_given?
+
+ strategy.to_sym
+ end
+
+ def cancel(merge_request)
+ if cancel_auto_merge(merge_request)
+ yield if block_given?
+
+ success
+ else
+ error("Can't cancel the automatic merge", 406)
+ end
+ end
+
+ private
+
+ def strategy
+ strong_memoize(:strategy) do
+ self.class.name.demodulize.remove('Service').underscore
+ end
+ end
+
+ def cancel_auto_merge(merge_request)
+ merge_request.auto_merge_enabled = false
+ merge_request.merge_user = nil
+
+ merge_request.merge_params&.except!(
+ 'should_remove_source_branch',
+ 'commit_message',
+ 'squash_commit_message',
+ 'auto_merge_strategy'
+ )
+
+ merge_request.save
+ end
+ end
+end
diff --git a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb
new file mode 100644
index 00000000000..c41073a73e9
--- /dev/null
+++ b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module AutoMerge
+ class MergeWhenPipelineSucceedsService < AutoMerge::BaseService
+ def execute(merge_request)
+ super do
+ if merge_request.saved_change_to_auto_merge_enabled?
+ SystemNoteService.merge_when_pipeline_succeeds(merge_request, project, current_user, merge_request.diff_head_commit)
+ end
+ end
+ end
+
+ def process(merge_request)
+ 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)
+ end
+
+ def cancel(merge_request)
+ super do
+ SystemNoteService.cancel_merge_when_pipeline_succeeds(merge_request, @project, @current_user)
+ end
+ end
+
+ def available_for?(merge_request)
+ merge_request.actual_head_pipeline&.active?
+ end
+ end
+end
diff --git a/app/services/auto_merge_service.rb b/app/services/auto_merge_service.rb
new file mode 100644
index 00000000000..a3a780ff388
--- /dev/null
+++ b/app/services/auto_merge_service.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+class AutoMergeService < BaseService
+ STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS = 'merge_when_pipeline_succeeds'.freeze
+ STRATEGIES = [STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS].freeze
+
+ class << self
+ def all_strategies
+ STRATEGIES
+ end
+
+ def get_service_class(strategy)
+ return unless all_strategies.include?(strategy)
+
+ "::AutoMerge::#{strategy.camelize}Service".constantize
+ end
+ end
+
+ def execute(merge_request, strategy)
+ service = get_service_instance(strategy)
+
+ return :failed unless service&.available_for?(merge_request)
+
+ service.execute(merge_request)
+ end
+
+ def process(merge_request)
+ return unless merge_request.auto_merge_enabled?
+
+ get_service_instance(merge_request.auto_merge_strategy).process(merge_request)
+ end
+
+ def cancel(merge_request)
+ return error("Can't cancel the automatic merge", 406) unless merge_request.auto_merge_enabled?
+
+ get_service_instance(merge_request.auto_merge_strategy).cancel(merge_request)
+ end
+
+ def available_strategies(merge_request)
+ self.class.all_strategies.select do |strategy|
+ get_service_instance(strategy).available_for?(merge_request)
+ end
+ end
+
+ private
+
+ def get_service_instance(strategy)
+ self.class.get_service_class(strategy)&.new(project, current_user, params)
+ end
+end
diff --git a/app/services/boards/visits/latest_service.rb b/app/services/boards/visits/latest_service.rb
index 9e4c77a6317..d13e25b4f12 100644
--- a/app/services/boards/visits/latest_service.rb
+++ b/app/services/boards/visits/latest_service.rb
@@ -4,13 +4,15 @@ module Boards
module Visits
class LatestService < Boards::BaseService
def execute
- return nil unless current_user
+ return unless current_user
- if parent.is_a?(Group)
- BoardGroupRecentVisit.latest(current_user, parent)
- else
- BoardProjectRecentVisit.latest(current_user, parent)
- end
+ recent_visit_model.latest(current_user, parent, count: params[:count])
+ end
+
+ private
+
+ def recent_visit_model
+ parent.is_a?(Group) ? BoardGroupRecentVisit : BoardProjectRecentVisit
end
end
end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 35a0efcd0a1..c17712355af 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -25,7 +25,9 @@ module Ci
origin_ref: params[:ref],
checkout_sha: params[:checkout_sha],
after_sha: params[:after],
- before_sha: params[:before],
+ before_sha: params[:before], # The base SHA of the source branch (i.e merge_request.diff_base_sha).
+ source_sha: params[:source_sha], # The HEAD SHA of the source branch (i.e merge_request.diff_head_sha).
+ target_sha: params[:target_sha], # The HEAD SHA of the target branch.
trigger_request: trigger_request,
schedule: schedule,
merge_request: merge_request,
@@ -35,7 +37,7 @@ module Ci
variables_attributes: params[:variables_attributes],
project: project,
current_user: current_user,
- push_options: params[:push_options],
+ push_options: params[:push_options] || {},
chat_data: params[:chat_data],
**extra_options(options))
@@ -53,6 +55,10 @@ module Ci
end
end
+ # If pipeline is not persisted, try to recover IID
+ pipeline.reset_project_iid unless pipeline.persisted? ||
+ Feature.disabled?(:ci_pipeline_rewind_iid, project, default_enabled: true)
+
pipeline
end
@@ -98,17 +104,11 @@ module Ci
end
def schedule_head_pipeline_update
- related_merge_requests.each do |merge_request|
+ pipeline.all_merge_requests.opened.each do |merge_request|
UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id)
end
end
- # rubocop: disable CodeReuse/ActiveRecord
- def related_merge_requests
- pipeline.project.source_of_merge_requests.opened.where(source_branch: pipeline.ref)
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def extra_options(options = {})
# In Ruby 2.4, even when options is empty, f(**options) doesn't work when f
# doesn't have any parameters. We reproduce the Ruby 2.5 behavior by
diff --git a/app/services/ci/destroy_pipeline_service.rb b/app/services/ci/destroy_pipeline_service.rb
index 5c4a34043c1..9aea20c45f7 100644
--- a/app/services/ci/destroy_pipeline_service.rb
+++ b/app/services/ci/destroy_pipeline_service.rb
@@ -5,6 +5,8 @@ module Ci
def execute(pipeline)
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :destroy_pipeline, pipeline)
+ Ci::ExpirePipelineCacheService.new.execute(pipeline, delete: true)
+
pipeline.destroy!
end
end
diff --git a/app/services/ci/expire_pipeline_cache_service.rb b/app/services/ci/expire_pipeline_cache_service.rb
new file mode 100644
index 00000000000..d8d38128af6
--- /dev/null
+++ b/app/services/ci/expire_pipeline_cache_service.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+module Ci
+ class ExpirePipelineCacheService
+ def execute(pipeline, delete: false)
+ store = Gitlab::EtagCaching::Store.new
+
+ update_etag_cache(pipeline, store)
+
+ if delete
+ Gitlab::Cache::Ci::ProjectPipelineStatus.new(pipeline.project).delete_from_cache
+ else
+ Gitlab::Cache::Ci::ProjectPipelineStatus.update_for_pipeline(pipeline)
+ end
+ end
+
+ private
+
+ def project_pipelines_path(project)
+ Gitlab::Routing.url_helpers.project_pipelines_path(project, format: :json)
+ end
+
+ def project_pipeline_path(project, pipeline)
+ Gitlab::Routing.url_helpers.project_pipeline_path(project, pipeline, format: :json)
+ end
+
+ def commit_pipelines_path(project, commit)
+ Gitlab::Routing.url_helpers.pipelines_project_commit_path(project, commit.id, format: :json)
+ end
+
+ def new_merge_request_pipelines_path(project)
+ Gitlab::Routing.url_helpers.project_new_merge_request_path(project, format: :json)
+ end
+
+ def each_pipelines_merge_request_path(pipeline)
+ pipeline.all_merge_requests.each do |merge_request|
+ path = Gitlab::Routing.url_helpers.pipelines_project_merge_request_path(merge_request.target_project, merge_request, format: :json)
+
+ yield(path)
+ end
+ end
+
+ # Updates ETag caches of a pipeline.
+ #
+ # This logic resides in a separate method so that EE can more easily extend
+ # it.
+ #
+ # @param [Ci::Pipeline] pipeline
+ # @param [Gitlab::EtagCaching::Store] store
+ def update_etag_cache(pipeline, store)
+ project = pipeline.project
+
+ store.touch(project_pipelines_path(project))
+ store.touch(project_pipeline_path(project, pipeline))
+ store.touch(commit_pipelines_path(project, pipeline.commit)) unless pipeline.commit.nil?
+ store.touch(new_merge_request_pipelines_path(project))
+ each_pipelines_merge_request_path(pipeline) do |path|
+ store.touch(path)
+ end
+ end
+ end
+end
diff --git a/app/services/ci/pipeline_schedule_service.rb b/app/services/ci/pipeline_schedule_service.rb
new file mode 100644
index 00000000000..387d0351490
--- /dev/null
+++ b/app/services/ci/pipeline_schedule_service.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Ci
+ class PipelineScheduleService < BaseService
+ def execute(schedule)
+ # Ensure `next_run_at` is set properly before creating a pipeline.
+ # Otherwise, multiple pipelines could be created in a short interval.
+ schedule.schedule_next_run!
+
+ RunPipelineScheduleWorker.perform_async(schedule.id, schedule.owner.id)
+ end
+ end
+end
diff --git a/app/services/ci/play_manual_stage_service.rb b/app/services/ci/play_manual_stage_service.rb
new file mode 100644
index 00000000000..2497fc52e6b
--- /dev/null
+++ b/app/services/ci/play_manual_stage_service.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Ci
+ class PlayManualStageService < BaseService
+ def initialize(project, current_user, params)
+ super
+
+ @pipeline = params[:pipeline]
+ end
+
+ def execute(stage)
+ stage.builds.manual.each do |build|
+ next unless build.playable?
+
+ build.play(current_user)
+ rescue Gitlab::Access::AccessDeniedError
+ logger.error(message: 'Unable to play manual action', build_id: build.id)
+ end
+ end
+
+ private
+
+ attr_reader :pipeline, :current_user
+
+ def logger
+ Gitlab::AppLogger
+ end
+ end
+end
diff --git a/app/services/ci/prepare_build_service.rb b/app/services/ci/prepare_build_service.rb
new file mode 100644
index 00000000000..3722faeb020
--- /dev/null
+++ b/app/services/ci/prepare_build_service.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Ci
+ class PrepareBuildService
+ attr_reader :build
+
+ def initialize(build)
+ @build = build
+ end
+
+ def execute
+ prerequisites.each(&:complete!)
+
+ build.enqueue!
+ rescue => e
+ Gitlab::Sentry.track_acceptable_exception(e, extra: { build_id: build.id })
+
+ build.drop(:unmet_prerequisites)
+ end
+
+ private
+
+ def prerequisites
+ build.prerequisites
+ end
+ end
+end
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index 6707a1363d0..dedab98b56d 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -6,7 +6,7 @@ module Ci
class RegisterJobService
attr_reader :runner
- JOB_QUEUE_DURATION_SECONDS_BUCKETS = [1, 3, 10, 30].freeze
+ JOB_QUEUE_DURATION_SECONDS_BUCKETS = [1, 3, 10, 30, 60, 300].freeze
JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET = 5.freeze
Result = Struct.new(:build, :valid?)
@@ -36,6 +36,11 @@ module Ci
builds = builds.with_any_tags
end
+ # pick builds that older than specified age
+ if params.key?(:job_age)
+ builds = builds.queued_before(params[:job_age].seconds.ago)
+ end
+
builds.each do |build|
next unless runner.can_pick?(build)
diff --git a/app/services/ci/stop_environments_service.rb b/app/services/ci/stop_environments_service.rb
index 973ae5ce5aa..d9a800791f2 100644
--- a/app/services/ci/stop_environments_service.rb
+++ b/app/services/ci/stop_environments_service.rb
@@ -9,12 +9,11 @@ module Ci
return unless @ref.present?
- environments.each do |environment|
- next unless environment.stop_action_available?
- next unless can?(current_user, :stop_environment, environment)
+ environments.each { |environment| stop(environment) }
+ end
- environment.stop_with_action!(current_user)
- end
+ def execute_for_merge_request(merge_request)
+ merge_request.environments.each { |environment| stop(environment) }
end
private
@@ -24,5 +23,12 @@ module Ci
.new(project, current_user, ref: @ref, recently_updated: true)
.execute
end
+
+ def stop(environment)
+ return unless environment.stop_action_available?
+ return unless can?(current_user, :stop_environment, environment)
+
+ environment.stop_with_action!(current_user)
+ end
end
end
diff --git a/app/services/clusters/applications/base_helm_service.rb b/app/services/clusters/applications/base_helm_service.rb
index 8a71730d5ec..3e7f55f0c63 100644
--- a/app/services/clusters/applications/base_helm_service.rb
+++ b/app/services/clusters/applications/base_helm_service.rb
@@ -13,19 +13,37 @@ module Clusters
def log_error(error)
meta = {
- exception: error.class.name,
error_code: error.respond_to?(:error_code) ? error.error_code : nil,
service: self.class.name,
app_id: app.id,
+ app_name: app.name,
project_ids: app.cluster.project_ids,
- group_ids: app.cluster.group_ids,
- message: error.message
+ group_ids: app.cluster.group_ids
}
- logger.error(meta)
+ 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)
end
+ def log_event(event)
+ meta = {
+ service: self.class.name,
+ app_id: app.id,
+ app_name: app.name,
+ project_ids: app.cluster.project_ids,
+ group_ids: app.cluster.group_ids,
+ event: event
+ }
+
+ logger.info(meta)
+ end
+
def logger
@logger ||= Gitlab::Kubernetes::Logger.build
end
@@ -46,6 +64,10 @@ module Clusters
@install_command ||= app.install_command
end
+ def update_command
+ @update_command ||= app.update_command
+ end
+
def upgrade_command(new_values = "")
app.upgrade_command(new_values)
end
diff --git a/app/services/clusters/applications/base_service.rb b/app/services/clusters/applications/base_service.rb
new file mode 100644
index 00000000000..a9feb60be6e
--- /dev/null
+++ b/app/services/clusters/applications/base_service.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class BaseService
+ InvalidApplicationError = Class.new(StandardError)
+
+ attr_reader :cluster, :current_user, :params
+
+ def initialize(cluster, user, params = {})
+ @cluster = cluster
+ @current_user = user
+ @params = params.dup
+ end
+
+ def execute(request)
+ instantiate_application.tap do |application|
+ if application.has_attribute?(:hostname)
+ application.hostname = params[:hostname]
+ end
+
+ if application.has_attribute?(:email)
+ application.email = params[:email]
+ end
+
+ if application.respond_to?(:oauth_application)
+ application.oauth_application = create_oauth_application(application, request)
+ end
+
+ worker = worker_class(application)
+
+ application.make_scheduled!
+
+ worker.perform_async(application.name, application.id)
+ end
+ end
+
+ protected
+
+ def worker_class(application)
+ raise NotImplementedError
+ end
+
+ def builder
+ raise NotImplementedError
+ end
+
+ def project_builders
+ raise NotImplementedError
+ end
+
+ def instantiate_application
+ raise_invalid_application_error if invalid_application?
+
+ builder || raise(InvalidApplicationError, "invalid application: #{application_name}")
+ end
+
+ def raise_invalid_application_error
+ raise(InvalidApplicationError, "invalid application: #{application_name}")
+ end
+
+ def invalid_application?
+ unknown_application? || (!cluster.project_type? && project_only_application?)
+ end
+
+ def unknown_application?
+ Clusters::Cluster::APPLICATIONS.keys.exclude?(application_name)
+ end
+
+ # These applications will need extra configuration to enable them to work
+ # with groups of projects
+ def project_only_application?
+ Clusters::Cluster::PROJECT_ONLY_APPLICATIONS.include?(application_name)
+ end
+
+ def application_name
+ params[:application]
+ end
+
+ def create_oauth_application(application, request)
+ oauth_application_params = {
+ name: params[:application],
+ redirect_uri: application.callback_url,
+ scopes: application.oauth_scopes,
+ owner: current_user
+ }
+
+ ::Applications::CreateService.new(current_user, oauth_application_params).execute(request)
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/applications/check_ingress_ip_address_service.rb b/app/services/clusters/applications/check_ingress_ip_address_service.rb
index 0ec06e776a7..e254a0358a0 100644
--- a/app/services/clusters/applications/check_ingress_ip_address_service.rb
+++ b/app/services/clusters/applications/check_ingress_ip_address_service.rb
@@ -11,9 +11,13 @@ module Clusters
def execute
return if app.external_ip
+ return if app.external_hostname
return unless try_obtain_lease
- app.update!(external_ip: ingress_ip) if ingress_ip
+ app.external_ip = ingress_ip if ingress_ip
+ app.external_hostname = ingress_hostname if ingress_hostname
+
+ app.save! if app.changed?
end
private
@@ -25,12 +29,16 @@ module Clusters
end
def ingress_ip
- service.status.loadBalancer.ingress&.first&.ip
+ ingress_service&.ip
+ end
+
+ def ingress_hostname
+ ingress_service&.hostname
end
- def service
+ def ingress_service
strong_memoize(:ingress_service) do
- app.ingress_service
+ app.ingress_service.status.loadBalancer.ingress&.first
end
end
end
diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb
index c592d608b89..3c6803d24e6 100644
--- a/app/services/clusters/applications/check_installation_progress_service.rb
+++ b/app/services/clusters/applications/check_installation_progress_service.rb
@@ -37,7 +37,7 @@ module Clusters
end
def check_timeout
- if timeouted?
+ if timed_out?
begin
app.make_errored!("Operation timed out. Check pod logs for #{pod_name} for more details.")
end
@@ -51,8 +51,8 @@ module Clusters
install_command.pod_name
end
- def timeouted?
- Time.now.utc - app.updated_at.to_time.utc > ClusterWaitForAppInstallationWorker::TIMEOUT
+ def timed_out?
+ Time.now.utc - app.updated_at.utc > ClusterWaitForAppInstallationWorker::TIMEOUT
end
def remove_installation_pod
diff --git a/app/services/clusters/applications/check_uninstall_progress_service.rb b/app/services/clusters/applications/check_uninstall_progress_service.rb
new file mode 100644
index 00000000000..8786d295d6a
--- /dev/null
+++ b/app/services/clusters/applications/check_uninstall_progress_service.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class CheckUninstallProgressService < BaseHelmService
+ def execute
+ return unless app.uninstalling?
+
+ case installation_phase
+ when Gitlab::Kubernetes::Pod::SUCCEEDED
+ on_success
+ when Gitlab::Kubernetes::Pod::FAILED
+ on_failed
+ else
+ check_timeout
+ end
+ rescue Kubeclient::HttpError => e
+ log_error(e)
+
+ app.make_errored!(_('Kubernetes error: %{error_code}') % { error_code: e.error_code })
+ end
+
+ private
+
+ def on_success
+ app.destroy!
+ rescue StandardError => e
+ app.make_errored!(_('Application uninstalled but failed to destroy: %{error_message}') % { error_message: e.message })
+ ensure
+ remove_installation_pod
+ end
+
+ def on_failed
+ app.make_errored!(_('Operation failed. Check pod logs for %{pod_name} for more details.') % { pod_name: pod_name })
+ end
+
+ def check_timeout
+ if timed_out?
+ app.make_errored!(_('Operation timed out. Check pod logs for %{pod_name} for more details.') % { pod_name: pod_name })
+ else
+ WaitForUninstallAppWorker.perform_in(WaitForUninstallAppWorker::INTERVAL, app.name, app.id)
+ end
+ end
+
+ def pod_name
+ app.uninstall_command.pod_name
+ end
+
+ def timed_out?
+ Time.now.utc - app.updated_at.utc > WaitForUninstallAppWorker::TIMEOUT
+ end
+
+ def remove_installation_pod
+ helm_api.delete_pod!(pod_name)
+ end
+
+ def installation_phase
+ helm_api.status(pod_name)
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/applications/create_service.rb b/app/services/clusters/applications/create_service.rb
index 92c2c1b9834..f723c42c049 100644
--- a/app/services/clusters/applications/create_service.rb
+++ b/app/services/clusters/applications/create_service.rb
@@ -2,81 +2,16 @@
module Clusters
module Applications
- class CreateService
- InvalidApplicationError = Class.new(StandardError)
-
- attr_reader :cluster, :current_user, :params
-
- def initialize(cluster, user, params = {})
- @cluster = cluster
- @current_user = user
- @params = params.dup
- end
-
- def execute(request)
- create_application.tap do |application|
- if application.has_attribute?(:hostname)
- application.hostname = params[:hostname]
- end
-
- if application.has_attribute?(:email)
- application.email = params[:email]
- end
-
- if application.respond_to?(:oauth_application)
- application.oauth_application = create_oauth_application(application, request)
- end
-
- application.save!
-
- Clusters::Applications::ScheduleInstallationService.new(application).execute
- end
- end
-
+ class CreateService < Clusters::Applications::BaseService
private
- def create_application
- builder.call(@cluster)
+ def worker_class(application)
+ application.updateable? ? ClusterUpgradeAppWorker : ClusterInstallAppWorker
end
def builder
- builders[application_name] || raise(InvalidApplicationError, "invalid application: #{application_name}")
- end
-
- def builders
- {
- "helm" => -> (cluster) { cluster.application_helm || cluster.build_application_helm },
- "ingress" => -> (cluster) { cluster.application_ingress || cluster.build_application_ingress },
- "cert_manager" => -> (cluster) { cluster.application_cert_manager || cluster.build_application_cert_manager }
- }.tap do |hash|
- hash.merge!(project_builders) if cluster.project_type?
- end
- end
-
- # These applications will need extra configuration to enable them to work
- # with groups of projects
- def project_builders
- {
- "prometheus" => -> (cluster) { cluster.application_prometheus || cluster.build_application_prometheus },
- "runner" => -> (cluster) { cluster.application_runner || cluster.build_application_runner },
- "jupyter" => -> (cluster) { cluster.application_jupyter || cluster.build_application_jupyter },
- "knative" => -> (cluster) { cluster.application_knative || cluster.build_application_knative }
- }
- end
-
- def application_name
- params[:application]
- end
-
- def create_oauth_application(application, request)
- oauth_application_params = {
- name: params[:application],
- redirect_uri: application.callback_url,
- scopes: 'api read_user openid',
- owner: current_user
- }
-
- ::Applications::CreateService.new(current_user, oauth_application_params).execute(request)
+ cluster.public_send(:"application_#{application_name}") || # rubocop:disable GitlabSecurity/PublicSend
+ cluster.public_send(:"build_application_#{application_name}") # rubocop:disable GitlabSecurity/PublicSend
end
end
end
diff --git a/app/services/clusters/applications/destroy_service.rb b/app/services/clusters/applications/destroy_service.rb
new file mode 100644
index 00000000000..f3a4c4f754a
--- /dev/null
+++ b/app/services/clusters/applications/destroy_service.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class DestroyService < ::Clusters::Applications::BaseService
+ def execute(_request)
+ instantiate_application.tap do |application|
+ break unless application.can_uninstall?
+
+ application.make_scheduled!
+
+ Clusters::Applications::UninstallWorker.perform_async(application.name, application.id)
+ end
+ end
+
+ private
+
+ def builder
+ cluster.public_send(:"application_#{application_name}") # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/applications/install_service.rb b/app/services/clusters/applications/install_service.rb
index 5a65dc4ef59..dffb4ce65ab 100644
--- a/app/services/clusters/applications/install_service.rb
+++ b/app/services/clusters/applications/install_service.rb
@@ -6,19 +6,26 @@ module Clusters
def execute
return unless app.scheduled?
- begin
- app.make_installing!
- helm_api.install(install_command)
+ app.make_installing!
- ClusterWaitForAppInstallationWorker.perform_in(
- ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id)
- rescue Kubeclient::HttpError => e
- log_error(e)
- app.make_errored!("Kubernetes error: #{e.error_code}")
- rescue StandardError => e
- log_error(e)
- app.make_errored!("Can't start installation process.")
- end
+ install
+ end
+
+ private
+
+ def install
+ log_event(:begin_install)
+ helm_api.install(install_command)
+
+ log_event(:schedule_wait_for_installation)
+ ClusterWaitForAppInstallationWorker.perform_in(
+ ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id)
+ rescue Kubeclient::HttpError => e
+ log_error(e)
+ app.make_errored!(_('Kubernetes error: %{error_code}') % { error_code: e.error_code })
+ rescue StandardError => e
+ log_error(e)
+ app.make_errored!(_('Failed to install.'))
end
end
end
diff --git a/app/services/clusters/applications/patch_service.rb b/app/services/clusters/applications/patch_service.rb
new file mode 100644
index 00000000000..fbea18bae6b
--- /dev/null
+++ b/app/services/clusters/applications/patch_service.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class PatchService < BaseHelmService
+ def execute
+ return unless app.scheduled?
+
+ app.make_updating!
+
+ patch
+ end
+
+ private
+
+ def patch
+ log_event(:begin_patch)
+ helm_api.update(update_command)
+
+ log_event(:schedule_wait_for_patch)
+ ClusterWaitForAppInstallationWorker.perform_in(
+ ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id)
+ rescue Kubeclient::HttpError => e
+ log_error(e)
+ app.make_errored!(_('Kubernetes error: %{error_code}') % { error_code: e.error_code })
+ rescue StandardError => e
+ log_error(e)
+ app.make_errored!(_('Failed to update.'))
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/applications/schedule_installation_service.rb b/app/services/clusters/applications/schedule_installation_service.rb
deleted file mode 100644
index 15c93f1e79b..00000000000
--- a/app/services/clusters/applications/schedule_installation_service.rb
+++ /dev/null
@@ -1,31 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Applications
- class ScheduleInstallationService
- attr_reader :application
-
- def initialize(application)
- @application = application
- end
-
- def execute
- application.updateable? ? schedule_upgrade : schedule_install
- end
-
- private
-
- def schedule_upgrade
- application.make_scheduled!
-
- ClusterUpgradeAppWorker.perform_async(application.name, application.id)
- end
-
- def schedule_install
- application.make_scheduled!
-
- ClusterInstallAppWorker.perform_async(application.name, application.id)
- end
- end
- end
-end
diff --git a/app/services/clusters/applications/uninstall_service.rb b/app/services/clusters/applications/uninstall_service.rb
new file mode 100644
index 00000000000..50c8d806c14
--- /dev/null
+++ b/app/services/clusters/applications/uninstall_service.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class UninstallService < BaseHelmService
+ def execute
+ return unless app.scheduled?
+
+ app.make_uninstalling!
+ uninstall
+ end
+
+ private
+
+ def uninstall
+ helm_api.uninstall(app.uninstall_command)
+
+ Clusters::Applications::WaitForUninstallAppWorker.perform_in(
+ Clusters::Applications::WaitForUninstallAppWorker::INTERVAL, app.name, app.id)
+ rescue Kubeclient::HttpError => e
+ log_error(e)
+ app.make_errored!("Kubernetes error: #{e.error_code}")
+ rescue StandardError => e
+ log_error(e)
+ app.make_errored!('Failed to uninstall.')
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/applications/update_service.rb b/app/services/clusters/applications/update_service.rb
new file mode 100644
index 00000000000..0fa937da865
--- /dev/null
+++ b/app/services/clusters/applications/update_service.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class UpdateService < Clusters::Applications::BaseService
+ private
+
+ def worker_class(application)
+ ClusterPatchAppWorker
+ end
+
+ def builder
+ cluster.public_send(:"application_#{application_name}") # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/applications/upgrade_service.rb b/app/services/clusters/applications/upgrade_service.rb
index a0ece1d2635..ac68e64af38 100644
--- a/app/services/clusters/applications/upgrade_service.rb
+++ b/app/services/clusters/applications/upgrade_service.rb
@@ -6,22 +6,28 @@ module Clusters
def execute
return unless app.scheduled?
- begin
- app.make_updating!
+ app.make_updating!
- # install_command works with upgrades too
- # as it basically does `helm upgrade --install`
- helm_api.update(install_command)
+ upgrade
+ end
+
+ private
+
+ def upgrade
+ # install_command works with upgrades too
+ # as it basically does `helm upgrade --install`
+ log_event(:begin_upgrade)
+ helm_api.update(install_command)
- ClusterWaitForAppInstallationWorker.perform_in(
- ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id)
- rescue Kubeclient::HttpError => e
- log_error(e)
- app.make_update_errored!("Kubernetes error: #{e.error_code}")
- rescue StandardError => e
- log_error(e)
- app.make_update_errored!("Can't start upgrade process.")
- end
+ log_event(:schedule_wait_for_upgrade)
+ ClusterWaitForAppInstallationWorker.perform_in(
+ ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id)
+ rescue Kubeclient::HttpError => e
+ log_error(e)
+ app.make_errored!(_('Kubernetes error: %{error_code}') % { error_code: e.error_code })
+ rescue StandardError => e
+ log_error(e)
+ app.make_errored!(_('Failed to upgrade.'))
end
end
end
diff --git a/app/services/clusters/build_service.rb b/app/services/clusters/build_service.rb
index 8de73831164..b1ac5549e30 100644
--- a/app/services/clusters/build_service.rb
+++ b/app/services/clusters/build_service.rb
@@ -12,6 +12,8 @@ module Clusters
cluster.cluster_type = :project_type
when ::Group
cluster.cluster_type = :group_type
+ when Instance
+ cluster.cluster_type = :instance_type
else
raise NotImplementedError
end
diff --git a/app/services/clusters/create_service.rb b/app/services/clusters/create_service.rb
index 5a9da053780..886e484caaf 100644
--- a/app/services/clusters/create_service.rb
+++ b/app/services/clusters/create_service.rb
@@ -38,6 +38,8 @@ module Clusters
{ cluster_type: :project_type, projects: [clusterable] }
when ::Group
{ cluster_type: :group_type, groups: [clusterable] }
+ when Instance
+ { cluster_type: :instance_type }
else
raise NotImplementedError
end
diff --git a/app/services/clusters/refresh_service.rb b/app/services/clusters/refresh_service.rb
index 7c82b98a33f..3752a306793 100644
--- a/app/services/clusters/refresh_service.rb
+++ b/app/services/clusters/refresh_service.rb
@@ -21,7 +21,7 @@ module Clusters
private_class_method :projects_with_missing_kubernetes_namespaces_for_cluster
def self.clusters_with_missing_kubernetes_namespaces_for_project(project)
- project.all_clusters.missing_kubernetes_namespace(project.kubernetes_namespaces)
+ project.clusters.managed.missing_kubernetes_namespace(project.kubernetes_namespaces)
end
private_class_method :clusters_with_missing_kubernetes_namespaces_for_project
diff --git a/app/services/commits/create_service.rb b/app/services/commits/create_service.rb
index 34593e12bd5..bb34a3d3352 100644
--- a/app/services/commits/create_service.rb
+++ b/app/services/commits/create_service.rb
@@ -11,6 +11,7 @@ module Commits
@start_project = params[:start_project] || @project
@start_branch = params[:start_branch]
@branch_name = params[:branch_name]
+ @force = params[:force] || false
end
def execute
@@ -42,10 +43,14 @@ module Commits
@start_branch != @branch_name || @start_project != @project
end
+ def force?
+ !!@force
+ end
+
def validate!
validate_permissions!
validate_on_branch!
- validate_branch_existance!
+ validate_branch_existence!
validate_new_branch_name! if different_branch?
end
@@ -64,14 +69,14 @@ module Commits
end
end
- def validate_branch_existance!
- if !project.empty_repo? && different_branch? && repository.branch_exists?(@branch_name)
+ def validate_branch_existence!
+ if !project.empty_repo? && different_branch? && repository.branch_exists?(@branch_name) && !force?
raise_error("A branch called '#{@branch_name}' already exists. Switch to that branch in order to make changes")
end
end
def validate_new_branch_name!
- result = ValidateNewBranchService.new(project, current_user).execute(@branch_name)
+ result = ValidateNewBranchService.new(project, current_user).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/compare_service.rb b/app/services/compare_service.rb
index 3adf8a0c1a1..3f0aedfbfb2 100644
--- a/app/services/compare_service.rb
+++ b/app/services/compare_service.rb
@@ -3,7 +3,7 @@
require 'securerandom'
# Compare 2 refs for one repo or between repositories
-# and return Gitlab::Git::Compare object that responds to commits and diffs
+# and return Compare object that responds to commits and diffs
class CompareService
attr_reader :start_project, :start_ref_name
@@ -15,7 +15,7 @@ class CompareService
def execute(target_project, target_ref, base_sha: nil, straight: false)
raw_compare = target_project.repository.compare_source_branch(target_ref, start_project.repository, start_ref_name, straight: straight)
- return unless raw_compare
+ return unless raw_compare && raw_compare.base && raw_compare.head
Compare.new(raw_compare,
target_project,
diff --git a/app/services/concerns/suggestible.rb b/app/services/concerns/suggestible.rb
new file mode 100644
index 00000000000..0cba9bf1b8a
--- /dev/null
+++ b/app/services/concerns/suggestible.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Suggestible
+ extend ActiveSupport::Concern
+ include Gitlab::Utils::StrongMemoize
+
+ # This translates into limiting suggestion changes to `suggestion:-100+100`.
+ MAX_LINES_CONTEXT = 100.freeze
+
+ def diff_lines
+ strong_memoize(:diff_lines) do
+ Gitlab::Diff::SuggestionDiff.new(self).diff_lines
+ end
+ end
+
+ def fetch_from_content
+ diff_file.new_blob_lines_between(from_line, to_line).join
+ end
+
+ def from_line
+ real_above = [lines_above, MAX_LINES_CONTEXT].min
+ [target_line - real_above, 1].max
+ end
+
+ def to_line
+ real_below = [lines_below, MAX_LINES_CONTEXT].min
+ target_line + real_below
+ end
+
+ def diff_file
+ raise NotImplementedError
+ end
+
+ def target_line
+ raise NotImplementedError
+ end
+end
diff --git a/app/services/concerns/users/participable_service.rb b/app/services/concerns/users/participable_service.rb
index 6713b6617ae..1c828234f1b 100644
--- a/app/services/concerns/users/participable_service.rb
+++ b/app/services/concerns/users/participable_service.rb
@@ -28,19 +28,35 @@ module Users
end
def groups
- current_user.authorized_groups.sort_by(&:path).map do |group|
- group_as_hash(group)
+ group_counts = GroupMember
+ .of_groups(current_user.authorized_groups)
+ .non_request
+ .count_users_by_group_id
+
+ current_user.authorized_groups.with_route.sort_by(&:path).map do |group|
+ group_as_hash(group, group_counts)
end
end
private
def user_as_hash(user)
- { type: user.class.name, username: user.username, name: user.name, avatar_url: user.avatar_url }
+ {
+ type: user.class.name,
+ username: user.username,
+ name: user.name,
+ avatar_url: user.avatar_url
+ }
end
- def group_as_hash(group)
- { type: group.class.name, username: group.full_path, name: group.full_name, avatar_url: group.avatar_url, count: group.users.count }
+ def group_as_hash(group, group_counts)
+ {
+ type: group.class.name,
+ username: group.full_path,
+ name: group.full_name,
+ avatar_url: group.avatar_url,
+ count: group_counts.fetch(group.id, 0)
+ }
end
end
end
diff --git a/app/services/concerns/validates_classification_label.rb b/app/services/concerns/validates_classification_label.rb
new file mode 100644
index 00000000000..ebcf5c24ff8
--- /dev/null
+++ b/app/services/concerns/validates_classification_label.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module ValidatesClassificationLabel
+ def validate_classification_label(record, attribute_name)
+ return unless ::Gitlab::ExternalAuthorization.enabled?
+ return unless classification_label_change?(record, attribute_name)
+
+ new_label = params[attribute_name].presence
+ new_label ||= ::Gitlab::CurrentSettings.current_application_settings
+ .external_authorization_service_default_label
+
+ unless ::Gitlab::ExternalAuthorization.access_allowed?(current_user, new_label)
+ reason = rejection_reason_for_label(new_label)
+ message = s_('ClassificationLabelUnavailable|is unavailable: %{reason}') % { reason: reason }
+ record.errors.add(attribute_name, message)
+ end
+ end
+
+ def rejection_reason_for_label(label)
+ reason_from_service = ::Gitlab::ExternalAuthorization.rejection_reason(current_user, label).presence
+ reason_from_service || _("Access to '%{classification_label}' not allowed") % { classification_label: label }
+ end
+
+ def classification_label_change?(record, attribute_name)
+ params.key?(attribute_name) || record.new_record?
+ end
+end
diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb
index 8322a3d74f4..fd41ce54486 100644
--- a/app/services/delete_branch_service.rb
+++ b/app/services/delete_branch_service.rb
@@ -6,37 +6,25 @@ class DeleteBranchService < BaseService
branch = repository.find_branch(branch_name)
unless current_user.can?(:push_code, project)
- return error('You dont have push access to repo', 405)
+ return ServiceResponse.error(
+ message: 'You dont have push access to repo',
+ http_status: 405)
end
unless branch
- return error('No such branch', 404)
+ return ServiceResponse.error(
+ message: 'No such branch',
+ http_status: 404)
end
if repository.rm_branch(current_user, branch_name)
- success('Branch was deleted')
+ ServiceResponse.success(message: 'Branch was deleted')
else
- error('Failed to remove branch')
+ ServiceResponse.error(
+ message: 'Failed to remove branch',
+ http_status: 400)
end
rescue Gitlab::Git::PreReceiveError => ex
- error(ex.message)
- end
-
- def error(message, return_code = 400)
- super(message).merge(return_code: return_code)
- end
-
- def success(message)
- super().merge(message: message)
- end
-
- def build_push_data(branch)
- Gitlab::DataBuilder::Push.build(
- project,
- current_user,
- branch.dereferenced_target.sha,
- Gitlab::Git::BLANK_SHA,
- "#{Gitlab::Git::BRANCH_REF_PREFIX}#{branch.name}",
- [])
+ ServiceResponse.error(message: ex.message, http_status: 400)
end
end
diff --git a/app/services/error_tracking/list_issues_service.rb b/app/services/error_tracking/list_issues_service.rb
index a6c6bec9598..86ab21fa865 100644
--- a/app/services/error_tracking/list_issues_service.rb
+++ b/app/services/error_tracking/list_issues_service.rb
@@ -18,7 +18,7 @@ module ErrorTracking
end
if result[:error].present?
- return error(result[:error], :bad_request)
+ return error(result[:error], http_status_from_error_type(result[:error_type]))
end
success(issues: result[:issues])
@@ -30,6 +30,15 @@ module ErrorTracking
private
+ def http_status_from_error_type(error_type)
+ case error_type
+ when ErrorTracking::ProjectErrorTrackingSetting::SENTRY_API_ERROR_TYPE_MISSING_KEYS
+ :internal_server_error
+ else
+ :bad_request
+ end
+ end
+
def project_error_tracking_setting
project.error_tracking_setting
end
diff --git a/app/services/error_tracking/list_projects_service.rb b/app/services/error_tracking/list_projects_service.rb
index c6e8be0f2be..8d08f0cda94 100644
--- a/app/services/error_tracking/list_projects_service.rb
+++ b/app/services/error_tracking/list_projects_service.rb
@@ -15,8 +15,8 @@ module ErrorTracking
result = setting.list_sentry_projects
rescue Sentry::Client::Error => e
return error(e.message, :bad_request)
- rescue Sentry::Client::SentryError => e
- return error(e.message, :unprocessable_entity)
+ rescue Sentry::Client::MissingKeysError => e
+ return error(e.message, :internal_server_error)
end
success(projects: result[:projects])
@@ -28,8 +28,8 @@ module ErrorTracking
(project.error_tracking_setting || project.build_error_tracking_setting).tap do |setting|
setting.api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from(
api_host: params[:api_host],
- organization_slug: nil,
- project_slug: nil
+ organization_slug: 'org',
+ project_slug: 'proj'
)
setting.token = params[:token]
diff --git a/app/services/files/delete_service.rb b/app/services/files/delete_service.rb
index 0ec1f79d396..f47eb4fccd4 100644
--- a/app/services/files/delete_service.rb
+++ b/app/services/files/delete_service.rb
@@ -20,7 +20,7 @@ module Files
super
if file_has_changed?(@file_path, @last_commit_sha)
- raise FileChangedError, "You are attempting to delete a file that has been previously updated."
+ raise FileChangedError, _("You are attempting to delete a file that has been previously updated.")
end
end
end
diff --git a/app/services/files/multi_service.rb b/app/services/files/multi_service.rb
index 927634c2159..c1bc26c330a 100644
--- a/app/services/files/multi_service.rb
+++ b/app/services/files/multi_service.rb
@@ -46,7 +46,8 @@ module Files
author_email: @author_email,
author_name: @author_name,
start_project: @start_project,
- start_branch_name: @start_branch
+ start_branch_name: @start_branch,
+ force: force?
)
rescue ArgumentError => e
raise_error(e)
diff --git a/app/services/files/update_service.rb b/app/services/files/update_service.rb
index 2b3e96e6c53..54ab07da680 100644
--- a/app/services/files/update_service.rb
+++ b/app/services/files/update_service.rb
@@ -19,7 +19,7 @@ module Files
super
if file_has_changed?(@file_path, @last_commit_sha)
- raise FileChangedError, "You are attempting to update a file that has changed since you started editing it."
+ raise FileChangedError, _('You are attempting to update a file that has changed since you started editing it.')
end
end
end
diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb
new file mode 100644
index 00000000000..d30df34e54b
--- /dev/null
+++ b/app/services/git/base_hooks_service.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+module Git
+ class BaseHooksService < ::BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ # The N most recent commits to process in a single push payload.
+ PROCESS_COMMIT_LIMIT = 100
+
+ def execute
+ project.repository.after_create if project.empty_repo?
+
+ create_events
+ create_pipelines
+ execute_project_hooks
+
+ # Not a hook, but it needs access to the list of changed commits
+ enqueue_invalidate_cache
+
+ update_remote_mirrors
+
+ push_data
+ end
+
+ private
+
+ def hook_name
+ raise NotImplementedError, "Please implement #{self.class}##{__method__}"
+ end
+
+ def commits
+ raise NotImplementedError, "Please implement #{self.class}##{__method__}"
+ end
+
+ def limited_commits
+ commits.last(PROCESS_COMMIT_LIMIT)
+ end
+
+ def commits_count
+ commits.count
+ end
+
+ def event_message
+ nil
+ end
+
+ def invalidated_file_types
+ []
+ end
+
+ def create_events
+ EventCreateService.new.push(project, current_user, push_data)
+ end
+
+ def create_pipelines
+ return unless params.fetch(:create_pipelines, true)
+
+ Ci::CreatePipelineService
+ .new(project, current_user, push_data)
+ .execute(:push, pipeline_options)
+ end
+
+ def execute_project_hooks
+ project.execute_hooks(push_data, hook_name)
+ project.execute_services(push_data, hook_name)
+ end
+
+ def enqueue_invalidate_cache
+ ProjectCacheWorker.perform_async(
+ project.id,
+ invalidated_file_types,
+ [:commit_count, :repository_size]
+ )
+ end
+
+ def push_data
+ @push_data ||= Gitlab::DataBuilder::Push.build(
+ project: project,
+ user: current_user,
+ oldrev: params[:oldrev],
+ newrev: params[:newrev],
+ ref: params[:ref],
+ commits: limited_commits,
+ message: event_message,
+ commits_count: commits_count,
+ push_options: params[:push_options] || {}
+ )
+
+ # Dependent code may modify the push data, so return a duplicate each time
+ @push_data.dup
+ end
+
+ # to be overridden in EE
+ def pipeline_options
+ {}
+ end
+
+ def update_remote_mirrors
+ return unless project.has_remote_mirror?
+
+ project.mark_stuck_remote_mirrors_as_failed!
+ project.update_remote_mirrors
+ end
+ end
+end
diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb
new file mode 100644
index 00000000000..d21a6bb1b9a
--- /dev/null
+++ b/app/services/git/branch_hooks_service.rb
@@ -0,0 +1,144 @@
+# frozen_string_literal: true
+
+module Git
+ class BranchHooksService < ::Git::BaseHooksService
+ def execute
+ execute_branch_hooks
+
+ super.tap do
+ enqueue_update_gpg_signatures
+ end
+ end
+
+ private
+
+ def hook_name
+ :push_hooks
+ end
+
+ def commits
+ strong_memoize(:commits) do
+ if creating_default_branch?
+ # The most recent PROCESS_COMMIT_LIMIT commits in the default branch
+ offset = [count_commits_in_branch - PROCESS_COMMIT_LIMIT, 0].max
+ project.repository.commits(params[:newrev], offset: offset, limit: PROCESS_COMMIT_LIMIT)
+ elsif creating_branch?
+ # Use the pushed commits that aren't reachable by the default branch
+ # as a heuristic. This may include more commits than are actually
+ # pushed, but that shouldn't matter because we check for existing
+ # cross-references later.
+ project.repository.commits_between(project.default_branch, params[:newrev])
+ elsif updating_branch?
+ project.repository.commits_between(params[:oldrev], params[:newrev])
+ else # removing branch
+ []
+ end
+ end
+ end
+
+ def commits_count
+ return count_commits_in_branch if creating_default_branch?
+
+ super
+ end
+
+ def invalidated_file_types
+ return super unless default_branch? && !creating_branch?
+
+ paths = limited_commits.each_with_object(Set.new) do |commit, set|
+ commit.raw_deltas.each do |diff|
+ set << diff.new_path
+ end
+ end
+
+ Gitlab::FileDetector.types_in_paths(paths)
+ end
+
+ def execute_branch_hooks
+ project.repository.after_push_commit(branch_name)
+
+ branch_create_hooks if creating_branch?
+ branch_update_hooks if updating_branch?
+ branch_change_hooks if creating_branch? || updating_branch?
+ branch_remove_hooks if removing_branch?
+ end
+
+ def branch_create_hooks
+ project.repository.after_create_branch
+ project.after_create_default_branch if default_branch?
+ end
+
+ def branch_update_hooks
+ # Update the bare repositories info/attributes file using the contents of
+ # the default branch's .gitattributes file
+ project.repository.copy_gitattributes(params[:ref]) if default_branch?
+ end
+
+ def branch_change_hooks
+ enqueue_process_commit_messages
+ end
+
+ def branch_remove_hooks
+ project.repository.after_remove_branch
+ end
+
+ # Schedules processing of commit messages
+ def enqueue_process_commit_messages
+ # don't process commits for the initial push to the default branch
+ return if creating_default_branch?
+
+ limited_commits.each do |commit|
+ next unless commit.matches_cross_reference_regex?
+
+ ProcessCommitWorker.perform_async(
+ project.id,
+ current_user.id,
+ commit.to_hash,
+ default_branch?
+ )
+ end
+ end
+
+ def enqueue_update_gpg_signatures
+ unsigned = GpgSignature.unsigned_commit_shas(limited_commits.map(&:sha))
+ return if unsigned.empty?
+
+ signable = Gitlab::Git::Commit.shas_with_signatures(project.repository, unsigned)
+ return if signable.empty?
+
+ CreateGpgSignatureWorker.perform_async(signable, project.id)
+ end
+
+ def creating_branch?
+ Gitlab::Git.blank_ref?(params[:oldrev])
+ end
+
+ def updating_branch?
+ !creating_branch? && !removing_branch?
+ end
+
+ def removing_branch?
+ Gitlab::Git.blank_ref?(params[:newrev])
+ end
+
+ def creating_default_branch?
+ creating_branch? && default_branch?
+ end
+
+ def count_commits_in_branch
+ strong_memoize(:count_commits_in_branch) do
+ project.repository.commit_count_for_ref(params[:ref])
+ end
+ end
+
+ def default_branch?
+ strong_memoize(:default_branch) do
+ [nil, branch_name].include?(project.default_branch)
+ end
+ end
+
+ def branch_name
+ strong_memoize(:branch_name) { Gitlab::Git.ref_name(params[:ref]) }
+ end
+ end
+end
diff --git a/app/services/git/branch_push_service.rb b/app/services/git/branch_push_service.rb
new file mode 100644
index 00000000000..c4910180787
--- /dev/null
+++ b/app/services/git/branch_push_service.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+module Git
+ class BranchPushService < ::BaseService
+ include Gitlab::Access
+ include Gitlab::Utils::StrongMemoize
+
+ # This method will be called after each git update
+ # and only if the provided user and project are present in GitLab.
+ #
+ # All callbacks for post receive action should be placed here.
+ #
+ # Next, this method:
+ # 1. Creates the push event
+ # 2. Updates merge requests
+ # 3. Recognizes cross-references from commit messages
+ # 4. Executes the project's webhooks
+ # 5. Executes the project's services
+ # 6. Checks if the project's main language has changed
+ #
+ def execute
+ return unless Gitlab::Git.branch_ref?(params[:ref])
+
+ enqueue_update_mrs
+ enqueue_detect_repository_languages
+
+ execute_related_hooks
+ perform_housekeeping
+
+ stop_environments
+
+ true
+ end
+
+ # Update merge requests that may be affected by this push. A new branch
+ # could cause the last commit of a merge request to change.
+ def enqueue_update_mrs
+ UpdateMergeRequestsWorker.perform_async(
+ project.id,
+ current_user.id,
+ params[:oldrev],
+ params[:newrev],
+ params[:ref]
+ )
+ end
+
+ def enqueue_detect_repository_languages
+ return unless default_branch?
+
+ DetectRepositoryLanguagesWorker.perform_async(project.id)
+ end
+
+ # Only stop environments if the ref is a branch that is being deleted
+ def stop_environments
+ return unless removing_branch?
+
+ Ci::StopEnvironmentsService.new(project, current_user).execute(branch_name)
+ end
+
+ def update_remote_mirrors
+ return unless project.has_remote_mirror?
+
+ project.mark_stuck_remote_mirrors_as_failed!
+ project.update_remote_mirrors
+ end
+
+ def execute_related_hooks
+ BranchHooksService.new(project, current_user, params).execute
+ end
+
+ def perform_housekeeping
+ housekeeping = Projects::HousekeepingService.new(project)
+ housekeeping.increment!
+ housekeeping.execute if housekeeping.needed?
+ rescue Projects::HousekeepingService::LeaseTaken
+ end
+
+ def removing_branch?
+ Gitlab::Git.blank_ref?(params[:newrev])
+ end
+
+ def branch_name
+ strong_memoize(:branch_name) { Gitlab::Git.ref_name(params[:ref]) }
+ end
+
+ def default_branch?
+ strong_memoize(:default_branch) do
+ [nil, branch_name].include?(project.default_branch)
+ end
+ end
+ end
+end
diff --git a/app/services/git/tag_hooks_service.rb b/app/services/git/tag_hooks_service.rb
new file mode 100644
index 00000000000..18eb780579f
--- /dev/null
+++ b/app/services/git/tag_hooks_service.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Git
+ class TagHooksService < ::Git::BaseHooksService
+ private
+
+ def hook_name
+ :tag_push_hooks
+ end
+
+ def commits
+ [tag_commit].compact
+ end
+
+ def event_message
+ tag&.message
+ end
+
+ def tag
+ strong_memoize(:tag) do
+ next if Gitlab::Git.blank_ref?(params[:newrev])
+
+ tag_name = Gitlab::Git.ref_name(params[:ref])
+ tag = project.repository.find_tag(tag_name)
+
+ tag if tag && tag.target == params[:newrev]
+ end
+ end
+
+ def tag_commit
+ strong_memoize(:tag_commit) do
+ project.commit(tag.dereferenced_target) if tag
+ end
+ end
+ end
+end
diff --git a/app/services/git/tag_push_service.rb b/app/services/git/tag_push_service.rb
new file mode 100644
index 00000000000..ee4166dccd0
--- /dev/null
+++ b/app/services/git/tag_push_service.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Git
+ class TagPushService < ::BaseService
+ def execute
+ return unless Gitlab::Git.tag_ref?(params[:ref])
+
+ project.repository.before_push_tag
+ TagHooksService.new(project, current_user, params).execute
+
+ true
+ end
+ end
+end
diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb
deleted file mode 100644
index f387c749a21..00000000000
--- a/app/services/git_push_service.rb
+++ /dev/null
@@ -1,240 +0,0 @@
-# frozen_string_literal: true
-
-class GitPushService < BaseService
- attr_accessor :push_data, :push_commits
- include Gitlab::Access
- include Gitlab::Utils::StrongMemoize
-
- # The N most recent commits to process in a single push payload.
- PROCESS_COMMIT_LIMIT = 100
-
- # This method will be called after each git update
- # and only if the provided user and project are present in GitLab.
- #
- # All callbacks for post receive action should be placed here.
- #
- # Next, this method:
- # 1. Creates the push event
- # 2. Updates merge requests
- # 3. Recognizes cross-references from commit messages
- # 4. Executes the project's webhooks
- # 5. Executes the project's services
- # 6. Checks if the project's main language has changed
- #
- def execute
- project.repository.after_create if project.empty_repo?
- project.repository.after_push_commit(branch_name)
-
- if push_remove_branch?
- project.repository.after_remove_branch
- @push_commits = []
- elsif push_to_new_branch?
- project.repository.after_create_branch
-
- # Re-find the pushed commits.
- if default_branch?
- # Initial push to the default branch. Take the full history of that branch as "newly pushed".
- process_default_branch
- else
- # Use the pushed commits that aren't reachable by the default branch
- # as a heuristic. This may include more commits than are actually pushed, but
- # that shouldn't matter because we check for existing cross-references later.
- @push_commits = project.repository.commits_between(project.default_branch, params[:newrev])
-
- # don't process commits for the initial push to the default branch
- process_commit_messages
- end
- elsif push_to_existing_branch?
- # Collect data for this git push
- @push_commits = project.repository.commits_between(params[:oldrev], params[:newrev])
-
- process_commit_messages
-
- # Update the bare repositories info/attributes file using the contents of the default branches
- # .gitattributes file
- update_gitattributes if default_branch?
- end
-
- execute_related_hooks
- perform_housekeeping
-
- update_remote_mirrors
- update_caches
-
- update_signatures
- end
-
- def update_gitattributes
- project.repository.copy_gitattributes(params[:ref])
- end
-
- def update_caches
- if default_branch?
- if push_to_new_branch?
- # If this is the initial push into the default branch, the file type caches
- # will already be reset as a result of `Project#change_head`.
- types = []
- else
- paths = Set.new
-
- last_pushed_commits.each do |commit|
- commit.raw_deltas.each do |diff|
- paths << diff.new_path
- end
- end
-
- types = Gitlab::FileDetector.types_in_paths(paths.to_a)
- end
-
- DetectRepositoryLanguagesWorker.perform_async(@project.id, current_user.id)
- else
- types = []
- end
-
- ProjectCacheWorker.perform_async(project.id, types, [:commit_count, :repository_size])
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def update_signatures
- commit_shas = last_pushed_commits.map(&:sha)
-
- return if commit_shas.empty?
-
- shas_with_cached_signatures = GpgSignature.where(commit_sha: commit_shas).pluck(:commit_sha)
- commit_shas -= shas_with_cached_signatures
-
- return if commit_shas.empty?
-
- commit_shas = Gitlab::Git::Commit.shas_with_signatures(project.repository, commit_shas)
-
- CreateGpgSignatureWorker.perform_async(commit_shas, project.id)
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- # Schedules processing of commit messages.
- def process_commit_messages
- default = default_branch?
-
- last_pushed_commits.each do |commit|
- if commit.matches_cross_reference_regex?
- ProcessCommitWorker
- .perform_async(project.id, current_user.id, commit.to_hash, default)
- end
- end
- end
-
- protected
-
- def update_remote_mirrors
- return unless project.has_remote_mirror?
-
- project.mark_stuck_remote_mirrors_as_failed!
- project.update_remote_mirrors
- end
-
- def execute_related_hooks
- # Update merge requests that may be affected by this push. A new branch
- # could cause the last commit of a merge request to change.
- #
- UpdateMergeRequestsWorker
- .perform_async(project.id, current_user.id, params[:oldrev], params[:newrev], params[:ref])
-
- EventCreateService.new.push(project, current_user, build_push_data)
- Ci::CreatePipelineService.new(project, current_user, build_push_data).execute(:push, pipeline_options)
-
- project.execute_hooks(build_push_data.dup, :push_hooks)
- project.execute_services(build_push_data.dup, :push_hooks)
-
- if push_remove_branch?
- AfterBranchDeleteService
- .new(project, current_user)
- .execute(branch_name)
- end
- end
-
- def perform_housekeeping
- housekeeping = Projects::HousekeepingService.new(project)
- housekeeping.increment!
- housekeeping.execute if housekeeping.needed?
- rescue Projects::HousekeepingService::LeaseTaken
- end
-
- def process_default_branch
- offset = [push_commits_count_for_ref - PROCESS_COMMIT_LIMIT, 0].max
- @push_commits = project.repository.commits(params[:newrev], offset: offset, limit: PROCESS_COMMIT_LIMIT)
-
- project.after_create_default_branch
- end
-
- def build_push_data
- @push_data ||= Gitlab::DataBuilder::Push.build(
- project,
- current_user,
- params[:oldrev],
- params[:newrev],
- params[:ref],
- @push_commits,
- commits_count: commits_count,
- push_options: params[:push_options] || [])
- end
-
- def push_to_existing_branch?
- # Return if this is not a push to a branch (e.g. new commits)
- branch_ref? && !Gitlab::Git.blank_ref?(params[:oldrev])
- end
-
- def push_to_new_branch?
- strong_memoize(:push_to_new_branch) do
- branch_ref? && Gitlab::Git.blank_ref?(params[:oldrev])
- end
- end
-
- def push_remove_branch?
- strong_memoize(:push_remove_branch) do
- branch_ref? && Gitlab::Git.blank_ref?(params[:newrev])
- end
- end
-
- def default_branch?
- branch_ref? &&
- (branch_name == project.default_branch || project.default_branch.nil?)
- end
-
- def commit_user(commit)
- commit.author || current_user
- end
-
- def branch_name
- strong_memoize(:branch_name) do
- Gitlab::Git.ref_name(params[:ref])
- end
- end
-
- def branch_ref?
- strong_memoize(:branch_ref) do
- Gitlab::Git.branch_ref?(params[:ref])
- end
- end
-
- def commits_count
- return push_commits_count_for_ref if default_branch? && push_to_new_branch?
-
- Array(@push_commits).size
- end
-
- def push_commits_count_for_ref
- strong_memoize(:push_commits_count_for_ref) do
- project.repository.commit_count_for_ref(params[:ref])
- end
- end
-
- def last_pushed_commits
- @last_pushed_commits ||= @push_commits.last(PROCESS_COMMIT_LIMIT)
- end
-
- private
-
- def pipeline_options
- {} # to be overridden in EE
- end
-end
diff --git a/app/services/git_tag_push_service.rb b/app/services/git_tag_push_service.rb
deleted file mode 100644
index e39b3603c6c..00000000000
--- a/app/services/git_tag_push_service.rb
+++ /dev/null
@@ -1,66 +0,0 @@
-# frozen_string_literal: true
-
-class GitTagPushService < BaseService
- attr_accessor :push_data
-
- def execute
- project.repository.after_create if project.empty_repo?
- project.repository.before_push_tag
-
- @push_data = build_push_data
-
- EventCreateService.new.push(project, current_user, push_data)
- Ci::CreatePipelineService.new(project, current_user, push_data).execute(:push, pipeline_options)
-
- SystemHooksService.new.execute_hooks(build_system_push_data, :tag_push_hooks)
- project.execute_hooks(push_data.dup, :tag_push_hooks)
- project.execute_services(push_data.dup, :tag_push_hooks)
-
- ProjectCacheWorker.perform_async(project.id, [], [:commit_count, :repository_size])
-
- true
- end
-
- private
-
- def build_push_data
- commits = []
- message = nil
-
- unless Gitlab::Git.blank_ref?(params[:newrev])
- tag_name = Gitlab::Git.ref_name(params[:ref])
- tag = project.repository.find_tag(tag_name)
-
- if tag && tag.target == params[:newrev]
- commit = project.commit(tag.dereferenced_target)
- commits = [commit].compact
- message = tag.message
- end
- end
-
- Gitlab::DataBuilder::Push.build(
- project,
- current_user,
- params[:oldrev],
- params[:newrev],
- params[:ref],
- commits,
- message,
- push_options: params[:push_options] || [])
- end
-
- def build_system_push_data
- Gitlab::DataBuilder::Push.build(
- project,
- current_user,
- params[:oldrev],
- params[:newrev],
- params[:ref],
- [],
- '')
- end
-
- def pipeline_options
- {} # to be overridden in EE
- end
-end
diff --git a/app/services/groups/auto_devops_service.rb b/app/services/groups/auto_devops_service.rb
new file mode 100644
index 00000000000..1925e0cc0ea
--- /dev/null
+++ b/app/services/groups/auto_devops_service.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Groups
+ class AutoDevopsService < Groups::BaseService
+ def execute
+ raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_group, group)
+
+ group.update(auto_devops_enabled: auto_devops_enabled)
+ end
+
+ private
+
+ def auto_devops_enabled
+ params[:auto_devops_enabled]
+ end
+ end
+end
diff --git a/app/services/groups/base_service.rb b/app/services/groups/base_service.rb
index 8c8acce5ca5..019cd047ae9 100644
--- a/app/services/groups/base_service.rb
+++ b/app/services/groups/base_service.rb
@@ -7,5 +7,11 @@ module Groups
def initialize(group, user, params = {})
@group, @current_user, @params = group, user, params.dup
end
+
+ private
+
+ def remove_unallowed_params
+ # overridden in EE
+ end
end
end
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index 99ead467f74..e9659f5489a 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -8,6 +8,8 @@ module Groups
end
def execute
+ remove_unallowed_params
+
@group = Group.new(params)
after_build_hook(@group, params)
@@ -44,13 +46,13 @@ module Groups
if @group.subgroup?
unless can?(current_user, :create_subgroup, @group.parent)
@group.parent = nil
- @group.errors.add(:parent_id, 'You don’t have permission to create a subgroup in this group.')
+ @group.errors.add(:parent_id, s_('CreateGroup|You don’t have permission to create a subgroup in this group.'))
return false
end
else
unless can?(current_user, :create_group)
- @group.errors.add(:base, 'You don’t have permission to create groups.')
+ @group.errors.add(:base, s_('CreateGroup|You don’t have permission to create groups.'))
return false
end
@@ -60,12 +62,16 @@ module Groups
end
def can_use_visibility_level?
- unless Gitlab::VisibilityLevel.allowed_for?(current_user, params[:visibility_level])
+ unless Gitlab::VisibilityLevel.allowed_for?(current_user, visibility_level)
deny_visibility_level(@group)
return false
end
true
end
+
+ def visibility_level
+ params[:visibility].present? ? Gitlab::VisibilityLevel.level_value(params[:visibility]) : params[:visibility_level]
+ end
end
end
diff --git a/app/services/groups/destroy_service.rb b/app/services/groups/destroy_service.rb
index 641111aeadc..654fe84e3dc 100644
--- a/app/services/groups/destroy_service.rb
+++ b/app/services/groups/destroy_service.rb
@@ -20,7 +20,7 @@ module Groups
end
# reload the relation to prevent triggering destroy hooks on the projects again
- group.projects.reload
+ group.projects.reset
group.children.each do |group|
# This needs to be synchronous since the namespace gets destroyed below
diff --git a/app/services/groups/nested_create_service.rb b/app/services/groups/nested_create_service.rb
index f01f5656296..01bd685712b 100644
--- a/app/services/groups/nested_create_service.rb
+++ b/app/services/groups/nested_create_service.rb
@@ -12,7 +12,7 @@ module Groups
end
def execute
- return nil unless group_path
+ return unless group_path
if namespace = namespace_or_group(group_path)
return namespace
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index f64e327416a..98e7c311572 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -3,11 +3,11 @@
module Groups
class TransferService < Groups::BaseService
ERROR_MESSAGES = {
- database_not_supported: 'Database is not supported.',
- namespace_with_same_path: 'The parent group already has a subgroup with the same path.',
- group_is_already_root: 'Group is already a root group.',
- same_parent_as_current: 'Group is already associated to the parent group.',
- invalid_policies: "You don't have enough permissions."
+ database_not_supported: s_('TransferGroup|Database is not supported.'),
+ namespace_with_same_path: s_('TransferGroup|The parent group already has a subgroup with the same path.'),
+ group_is_already_root: s_('TransferGroup|Group is already a root group.'),
+ same_parent_as_current: s_('TransferGroup|Group is already associated to the parent group.'),
+ invalid_policies: s_("TransferGroup|You don't have enough permissions.")
}.freeze
TransferError = Class.new(StandardError)
@@ -26,7 +26,7 @@ module Groups
rescue TransferError, ActiveRecord::RecordInvalid, Gitlab::UpdatePathError => e
@group.errors.clear
- @error = "Transfer failed: " + e.message
+ @error = s_("TransferGroup|Transfer failed: %{error_message}") % { error_message: e.message }
false
end
@@ -35,7 +35,10 @@ module Groups
def proceed_to_transfer
Group.transaction do
update_group_attributes
+ ensure_ownership
end
+
+ true
end
def ensure_allowed_transfer
@@ -95,6 +98,13 @@ module Groups
end
# rubocop: enable CodeReuse/ActiveRecord
+ def ensure_ownership
+ return if @new_parent_group
+ return unless @group.owners.empty?
+
+ @group.add_owner(current_user)
+ end
+
def raise_transfer_error(message)
raise TransferError, ERROR_MESSAGES[message]
end
diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb
index 787445180f0..73e1e00dc33 100644
--- a/app/services/groups/update_service.rb
+++ b/app/services/groups/update_service.rb
@@ -6,6 +6,7 @@ module Groups
def execute
reject_parent_id!
+ remove_unallowed_params
return false unless valid_visibility_level_change?(group, params[:visibility_level])
diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb
index a2533683da9..a322a306ba4 100644
--- a/app/services/import/github_service.rb
+++ b/app/services/import/github_service.rb
@@ -7,7 +7,7 @@ module Import
def execute(access_params, provider)
unless authorized?
- return error('This namespace has already been taken! Please choose another one.', :unprocessable_entity)
+ return error(_('This namespace has already been taken! Please choose another one.'), :unprocessable_entity)
end
project = Gitlab::LegacyGithubImport::ProjectCreator
diff --git a/app/services/issuable/clone/content_rewriter.rb b/app/services/issuable/clone/content_rewriter.rb
index e1e0b75085d..00d7078859d 100644
--- a/app/services/issuable/clone/content_rewriter.rb
+++ b/app/services/issuable/clone/content_rewriter.rb
@@ -28,6 +28,7 @@ module Issuable
new_params = {
project: new_entity.project, noteable: new_entity,
note: rewrite_content(new_note.note),
+ note_html: nil,
created_at: note.created_at,
updated_at: note.updated_at
}
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index ef991eaf234..26132f1824a 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -34,14 +34,20 @@ class IssuableBaseService < BaseService
end
def filter_assignee(issuable)
- return unless params[:assignee_id].present?
+ return if params[:assignee_ids].blank?
- assignee_id = params[:assignee_id]
+ unless issuable.allows_multiple_assignees?
+ params[:assignee_ids] = params[:assignee_ids].first(1)
+ end
+
+ assignee_ids = params[:assignee_ids].select { |assignee_id| assignee_can_read?(issuable, assignee_id) }
- if assignee_id.to_s == IssuableFinder::NONE
- params[:assignee_id] = ""
+ if params[:assignee_ids].map(&:to_s) == [IssuableFinder::NONE]
+ params[:assignee_ids] = []
+ elsif assignee_ids.any?
+ params[:assignee_ids] = assignee_ids
else
- params.delete(:assignee_id) unless assignee_can_read?(issuable, assignee_id)
+ params.delete(:assignee_ids)
end
end
@@ -70,26 +76,28 @@ class IssuableBaseService < BaseService
end
def filter_labels
- filter_labels_in_param(:add_label_ids)
- filter_labels_in_param(:remove_label_ids)
- filter_labels_in_param(:label_ids)
- find_or_create_label_ids
+ params[:add_label_ids] = labels_service.filter_labels_ids_in_param(:add_label_ids) if params[:add_label_ids]
+ params[:remove_label_ids] = labels_service.filter_labels_ids_in_param(:remove_label_ids) if params[:remove_label_ids]
+
+ if params[:label_ids]
+ params[:label_ids] = labels_service.filter_labels_ids_in_param(:label_ids)
+ elsif params[:labels]
+ params[:label_ids] = labels_service.find_or_create_by_titles.map(&:id)
+ end
end
- # rubocop: disable CodeReuse/ActiveRecord
def filter_labels_in_param(key)
return if params[key].to_a.empty?
- params[key] = available_labels.where(id: params[key]).pluck(:id)
+ params[key] = available_labels.id_in(params[key]).pluck_primary_key
end
- # rubocop: enable CodeReuse/ActiveRecord
def find_or_create_label_ids
labels = params.delete(:labels)
return unless labels
- params[:label_ids] = labels.split(",").map do |label_name|
+ params[:label_ids] = labels.map do |label_name|
label = Labels::FindOrCreateService.new(
current_user,
parent,
@@ -101,12 +109,17 @@ class IssuableBaseService < BaseService
end.compact
end
- def process_label_ids(attributes, existing_label_ids: nil)
+ def labels_service
+ @labels_service ||= ::Labels::AvailableLabelsService.new(current_user, parent, params)
+ end
+
+ def process_label_ids(attributes, existing_label_ids: nil, extra_label_ids: [])
label_ids = attributes.delete(:label_ids)
add_label_ids = attributes.delete(:add_label_ids)
remove_label_ids = attributes.delete(:remove_label_ids)
new_label_ids = existing_label_ids || label_ids || []
+ new_label_ids |= extra_label_ids
if add_label_ids.blank? && remove_label_ids.blank?
new_label_ids = label_ids if label_ids
@@ -115,11 +128,7 @@ class IssuableBaseService < BaseService
new_label_ids -= remove_label_ids if remove_label_ids
end
- new_label_ids
- end
-
- def available_labels
- @available_labels ||= LabelsFinder.new(current_user, project_id: @project.id, include_ancestor_groups: true).execute
+ new_label_ids.uniq
end
def handle_quick_actions_on_create(issuable)
@@ -145,7 +154,7 @@ class IssuableBaseService < BaseService
params.delete(:state_event)
params[:author] ||= current_user
- params[:label_ids] = issuable.label_ids.to_a + process_label_ids(params)
+ params[:label_ids] = process_label_ids(params, extra_label_ids: issuable.label_ids.to_a)
issuable.assign_attributes(params)
@@ -349,7 +358,7 @@ class IssuableBaseService < BaseService
end
def has_changes?(issuable, old_labels: [], old_assignees: [])
- valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
+ valid_attrs = [:title, :description, :assignee_ids, :milestone_id, :target_branch]
attrs_changed = valid_attrs.any? do |attr|
issuable.previous_changes.include?(attr.to_s)
@@ -387,4 +396,10 @@ class IssuableBaseService < BaseService
def parent
project
end
+
+ # we need to check this because milestone from milestone_id param is displayed on "new" page
+ # where private project milestone could leak without this check
+ def ensure_milestone_available(issuable)
+ issuable.milestone_id = nil unless issuable.milestone_available?
+ end
end
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index ef08adf4f92..48ed5afbc2a 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -20,7 +20,7 @@ module Issues
private
def create_assignee_note(issue, old_assignees)
- SystemNoteService.change_issue_assignees(
+ SystemNoteService.change_issuable_assignees(
issue, issue.project, current_user, old_assignees)
end
@@ -31,26 +31,6 @@ module Issues
issue.project.execute_services(issue_data, hooks_scope)
end
- # rubocop: disable CodeReuse/ActiveRecord
- def filter_assignee(issuable)
- return if params[:assignee_ids].blank?
-
- unless issuable.allows_multiple_assignees?
- params[:assignee_ids] = params[:assignee_ids].take(1)
- end
-
- assignee_ids = params[:assignee_ids].select { |assignee_id| assignee_can_read?(issuable, assignee_id) }
-
- if params[:assignee_ids].map(&:to_s) == [IssuableFinder::NONE]
- params[:assignee_ids] = []
- elsif assignee_ids.any?
- params[:assignee_ids] = assignee_ids
- else
- params.delete(:assignee_ids)
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def update_project_counter_caches?(issue)
super || issue.confidential_changed?
end
diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb
index 3fb2c2b3007..61615ac2058 100644
--- a/app/services/issues/build_service.rb
+++ b/app/services/issues/build_service.rb
@@ -6,7 +6,9 @@ module Issues
def execute
filter_resolve_discussion_params
- @issue = project.issues.new(issue_params)
+ @issue = project.issues.new(issue_params).tap do |issue|
+ ensure_milestone_available(issue)
+ end
end
def issue_params_with_info_from_discussions
diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb
index e5cc12e6082..805721212ba 100644
--- a/app/services/issues/close_service.rb
+++ b/app/services/issues/close_service.rb
@@ -7,7 +7,7 @@ module Issues
return issue unless can?(current_user, :update_issue, issue)
close_issue(issue,
- commit: commit,
+ closed_via: commit,
notifications: notifications,
system_note: system_note)
end
@@ -17,9 +17,9 @@ module Issues
#
# The code calling this method is responsible for ensuring that a user is
# allowed to close the given issue.
- def close_issue(issue, commit: nil, notifications: true, system_note: true)
+ def close_issue(issue, closed_via: nil, notifications: true, system_note: true)
if project.jira_tracker? && project.jira_service.active && issue.is_a?(ExternalIssue)
- project.jira_service.close_issue(commit, issue)
+ project.jira_service.close_issue(closed_via, issue)
todo_service.close_issue(issue, current_user)
return issue
end
@@ -27,8 +27,11 @@ module Issues
if project.issues_enabled? && issue.close
issue.update(closed_by: current_user)
event_service.close_issue(issue, current_user)
- create_note(issue, commit) if system_note
- notification_service.async.close_issue(issue, current_user) if notifications
+ create_note(issue, closed_via) if system_note
+
+ closed_via = _("commit %{commit_id}") % { commit_id: closed_via.id } if closed_via.is_a?(Commit)
+
+ notification_service.async.close_issue(issue, current_user, closed_via: closed_via) if notifications
todo_service.close_issue(issue, current_user)
execute_hooks(issue, 'close')
invalidate_cache_counts(issue, users: issue.assignees)
diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb
index 41b6a96b005..334fadadb6f 100644
--- a/app/services/issues/move_service.rb
+++ b/app/services/issues/move_service.rb
@@ -8,11 +8,11 @@ module Issues
@target_project = target_project
unless issue.can_move?(current_user, @target_project)
- raise MoveError, 'Cannot move issue due to insufficient permissions!'
+ raise MoveError, s_('MoveIssue|Cannot move issue due to insufficient permissions!')
end
if @project == @target_project
- raise MoveError, 'Cannot move issue to project it originates from!'
+ raise MoveError, s_('MoveIssue|Cannot move issue to project it originates from!')
end
super
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index cec5b5734c0..cb2337d29d4 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -39,7 +39,7 @@ module Issues
if issue.assignees != old_assignees
create_assignee_note(issue, old_assignees)
notification_service.async.reassigned_issue(issue, current_user, old_assignees)
- todo_service.reassigned_issue(issue, current_user, old_assignees)
+ todo_service.reassigned_issuable(issue, current_user, old_assignees)
end
if issue.previous_changes.include?('confidential')
diff --git a/app/services/labels/available_labels_service.rb b/app/services/labels/available_labels_service.rb
new file mode 100644
index 00000000000..fe477d96970
--- /dev/null
+++ b/app/services/labels/available_labels_service.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+module Labels
+ class AvailableLabelsService
+ attr_reader :current_user, :parent, :params
+
+ def initialize(current_user, parent, params)
+ @current_user = current_user
+ @parent = parent
+ @params = params
+ end
+
+ def find_or_create_by_titles
+ labels = params.delete(:labels)
+
+ return [] unless labels
+
+ labels = labels.split(',') if labels.is_a?(String)
+
+ labels.map do |label_name|
+ label = Labels::FindOrCreateService.new(
+ current_user,
+ parent,
+ include_ancestor_groups: true,
+ title: label_name.strip,
+ available_labels: available_labels
+ ).execute
+
+ label
+ end.compact
+ end
+
+ def filter_labels_ids_in_param(key)
+ return [] if params[key].to_a.empty?
+
+ # rubocop:disable CodeReuse/ActiveRecord
+ available_labels.by_ids(params[key]).pluck(:id)
+ # rubocop:enable CodeReuse/ActiveRecord
+ end
+
+ private
+
+ def available_labels
+ @available_labels ||= LabelsFinder.new(current_user, finder_params).execute
+ end
+
+ def finder_params
+ params = { include_ancestor_groups: true }
+
+ case parent
+ when Group
+ params[:group_id] = parent.id
+ params[:only_group_labels] = true
+ when Project
+ params[:project_id] = parent.id
+ end
+
+ params
+ end
+ end
+end
diff --git a/app/services/lfs/file_transformer.rb b/app/services/lfs/file_transformer.rb
index 6ecf583cb6a..5239fe1b6e3 100644
--- a/app/services/lfs/file_transformer.rb
+++ b/app/services/lfs/file_transformer.rb
@@ -24,7 +24,7 @@ module Lfs
def new_file(file_path, file_content, encoding: nil)
if project.lfs_enabled? && lfs_file?(file_path)
- file_content = Base64.decode64(file_content) if encoding == 'base64'
+ file_content = parse_file_content(file_content, encoding: encoding)
lfs_pointer_file = Gitlab::Git::LfsPointerFile.new(file_content)
lfs_object = create_lfs_object!(lfs_pointer_file, file_content)
@@ -66,5 +66,12 @@ module Lfs
def link_lfs_object!(lfs_object)
project.lfs_objects << lfs_object
end
+
+ def parse_file_content(file_content, encoding: nil)
+ return file_content.read if file_content.respond_to?(:read)
+ return Base64.decode64(file_content) if encoding == 'base64'
+
+ file_content
+ end
end
end
diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb
index cf710fef52b..d6b17ec10be 100644
--- a/app/services/members/create_service.rb
+++ b/app/services/members/create_service.rb
@@ -5,11 +5,11 @@ module Members
DEFAULT_LIMIT = 100
def execute(source)
- return error('No users specified.') if params[:user_ids].blank?
+ return error(s_('AddMember|No users specified.')) if params[:user_ids].blank?
user_ids = params[:user_ids].split(',').uniq
- return error("Too many users specified (limit is #{user_limit})") if
+ return error(s_("AddMember|Too many users specified (limit is %{user_limit})") % { user_limit: user_limit }) if
user_limit && user_ids.size > user_limit
members = source.add_users(
@@ -23,7 +23,16 @@ module Members
members.each do |member|
if member.errors.any?
- errors << "#{member.user.username}: #{member.errors.full_messages.to_sentence}"
+ current_error =
+ # Invited users may not have an associated user
+ if member.user.present?
+ "#{member.user.username}: "
+ else
+ ""
+ end
+
+ current_error += member.errors.full_messages.to_sentence
+ errors << current_error
else
after_execute(member: member)
end
diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb
index f9717a9426b..c8d5e563cd8 100644
--- a/app/services/members/destroy_service.rb
+++ b/app/services/members/destroy_service.rb
@@ -45,7 +45,7 @@ module Members
def delete_subgroup_members(member)
groups = member.group.descendants
- GroupMember.in_groups(groups).with_user(member.user).each do |group_member|
+ GroupMember.of_groups(groups).with_user(member.user).each do |group_member|
self.class.new(current_user).execute(group_member, skip_authorization: @skip_auth, skip_subresources: true)
end
end
diff --git a/app/services/merge_requests/add_todo_when_build_fails_service.rb b/app/services/merge_requests/add_todo_when_build_fails_service.rb
index 79c43b8e7d5..d3ef892875b 100644
--- a/app/services/merge_requests/add_todo_when_build_fails_service.rb
+++ b/app/services/merge_requests/add_todo_when_build_fails_service.rb
@@ -7,7 +7,7 @@ module MergeRequests
def execute(commit_status)
return if commit_status.allow_failure? || commit_status.retried?
- commit_status_merge_requests(commit_status) do |merge_request|
+ pipeline_merge_requests(commit_status.pipeline) do |merge_request|
todo_service.merge_request_build_failed(merge_request)
end
end
@@ -16,7 +16,7 @@ module MergeRequests
# build is retried
#
def close(commit_status)
- commit_status_merge_requests(commit_status) do |merge_request|
+ pipeline_merge_requests(commit_status.pipeline) do |merge_request|
todo_service.merge_request_build_retried(merge_request)
end
end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index ac51fee0b3f..2cfed62ce49 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -24,6 +24,11 @@ module MergeRequests
end
end
+ def cleanup_environments(merge_request)
+ Ci::StopEnvironmentsService.new(merge_request.source_project, current_user)
+ .execute_for_merge_request(merge_request)
+ end
+
private
def handle_wip_event(merge_request)
@@ -49,28 +54,18 @@ module MergeRequests
MergeRequestMetricsService.new(merge_request.metrics)
end
- def create_assignee_note(merge_request)
- SystemNoteService.change_assignee(
- merge_request, merge_request.project, current_user, merge_request.assignee)
+ def create_assignee_note(merge_request, old_assignees)
+ SystemNoteService.change_issuable_assignees(
+ merge_request, merge_request.project, current_user, old_assignees)
end
- def create_merge_request_pipeline(merge_request, user)
- return unless Feature.enabled?(:ci_merge_request_pipeline,
- merge_request.source_project,
- default_enabled: true)
-
- ##
- # UpdateMergeRequestsWorker could be retried by an exception.
- # MR pipelines should not be recreated in such case.
- return if merge_request.merge_request_pipeline_exists?
- return if merge_request.has_no_commits?
-
- Ci::CreatePipelineService
- .new(merge_request.source_project, user, ref: merge_request.source_branch)
- .execute(:merge_request,
- ignore_skip_ci: true,
- save_on_errors: false,
- merge_request: merge_request)
+ def create_pipeline_for(merge_request, user)
+ MergeRequests::CreatePipelineService.new(project, user).execute(merge_request)
+ end
+
+ def can_use_merge_request_ref?(merge_request)
+ Feature.enabled?(:ci_use_merge_request_ref, project, default_enabled: true) &&
+ !merge_request.for_fork?
end
# Returns all origin and fork merge requests from `@project` satisfying passed arguments.
@@ -85,22 +80,11 @@ module MergeRequests
# rubocop: enable CodeReuse/ActiveRecord
def pipeline_merge_requests(pipeline)
- merge_requests_for(pipeline.ref).each do |merge_request|
+ pipeline.all_merge_requests.opened.each do |merge_request|
next unless pipeline == merge_request.head_pipeline
yield merge_request
end
end
-
- def commit_status_merge_requests(commit_status)
- merge_requests_for(commit_status.ref).each do |merge_request|
- pipeline = merge_request.head_pipeline
-
- next unless pipeline
- next unless pipeline.sha == commit_status.sha
-
- yield merge_request
- end
- end
end
end
diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb
index 48419da98ad..109c964e577 100644
--- a/app/services/merge_requests/build_service.rb
+++ b/app/services/merge_requests/build_service.rb
@@ -19,6 +19,7 @@ module MergeRequests
merge_request.target_project = find_target_project
merge_request.target_branch = find_target_branch
merge_request.can_be_created = projects_and_branches_valid?
+ ensure_milestone_available(merge_request)
# compare branches only if branches are valid, otherwise
# compare_branches may raise an error
diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb
index 04527bb9713..b0f6166ea1c 100644
--- a/app/services/merge_requests/close_service.rb
+++ b/app/services/merge_requests/close_service.rb
@@ -17,6 +17,8 @@ module MergeRequests
execute_hooks(merge_request, 'close')
invalidate_cache_counts(merge_request, users: merge_request.assignees)
merge_request.update_project_counter_caches
+ cleanup_environments(merge_request)
+ cancel_auto_merge(merge_request)
end
merge_request
@@ -32,5 +34,9 @@ module MergeRequests
merge_request_metrics_service(merge_request).close(close_event)
end
end
+
+ def cancel_auto_merge(merge_request)
+ AutoMergeService.new(project, current_user).cancel(merge_request)
+ end
end
end
diff --git a/app/services/merge_requests/create_pipeline_service.rb b/app/services/merge_requests/create_pipeline_service.rb
new file mode 100644
index 00000000000..03246cc1920
--- /dev/null
+++ b/app/services/merge_requests/create_pipeline_service.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class CreatePipelineService < MergeRequests::BaseService
+ def execute(merge_request)
+ return unless can_create_pipeline_for?(merge_request)
+
+ create_detached_merge_request_pipeline(merge_request)
+ end
+
+ def create_detached_merge_request_pipeline(merge_request)
+ if can_use_merge_request_ref?(merge_request)
+ Ci::CreatePipelineService.new(merge_request.source_project, current_user,
+ ref: merge_request.ref_path)
+ .execute(:merge_request_event, merge_request: merge_request)
+ else
+ Ci::CreatePipelineService.new(merge_request.source_project, current_user,
+ ref: merge_request.source_branch)
+ .execute(:merge_request_event, merge_request: merge_request)
+ end
+ end
+
+ def can_create_pipeline_for?(merge_request)
+ ##
+ # UpdateMergeRequestsWorker could be retried by an exception.
+ # pipelines for merge request should not be recreated in such case.
+ return false if !allow_duplicate && merge_request.find_actual_head_pipeline&.triggered_by_merge_request?
+ return false if merge_request.has_no_commits?
+
+ true
+ end
+
+ def allow_duplicate
+ params[:allow_duplicate]
+ end
+ end
+end
diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb
index 02c2388c05c..06e46595b95 100644
--- a/app/services/merge_requests/create_service.rb
+++ b/app/services/merge_requests/create_service.rb
@@ -25,7 +25,7 @@ module MergeRequests
def after_create(issuable)
todo_service.new_merge_request(issuable, current_user)
issuable.cache_merge_request_closes_issues!(current_user)
- create_merge_request_pipeline(issuable, current_user)
+ create_pipeline_for(issuable, current_user)
issuable.update_head_pipeline
super
diff --git a/app/services/merge_requests/delete_non_latest_diffs_service.rb b/app/services/merge_requests/delete_non_latest_diffs_service.rb
index d5929446122..bdb7ec8a7c2 100644
--- a/app/services/merge_requests/delete_non_latest_diffs_service.rb
+++ b/app/services/merge_requests/delete_non_latest_diffs_service.rb
@@ -8,15 +8,13 @@ module MergeRequests
@merge_request = merge_request
end
- # rubocop: disable CodeReuse/ActiveRecord
def execute
diffs = @merge_request.non_latest_diffs.with_files
diffs.each_batch(of: BATCH_SIZE) do |relation, index|
- ids = relation.pluck(:id).map { |id| [id] }
+ ids = relation.pluck_primary_key.map { |id| [id] }
DeleteDiffFilesWorker.bulk_perform_in(index * 5.minutes, ids)
end
end
- # rubocop: enable CodeReuse/ActiveRecord
end
end
diff --git a/app/services/merge_requests/merge_base_service.rb b/app/services/merge_requests/merge_base_service.rb
new file mode 100644
index 00000000000..095bdca5472
--- /dev/null
+++ b/app/services/merge_requests/merge_base_service.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class MergeBaseService < MergeRequests::BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ MergeError = Class.new(StandardError)
+
+ attr_reader :merge_request
+
+ # Overridden in EE.
+ def hooks_validation_pass?(_merge_request)
+ true
+ end
+
+ # Overridden in EE.
+ def hooks_validation_error(_merge_request)
+ # No-op
+ end
+
+ def source
+ if merge_request.squash
+ squash_sha!
+ else
+ merge_request.diff_head_sha
+ end
+ end
+
+ private
+
+ # Overridden in EE.
+ def error_check!
+ # No-op
+ end
+
+ def raise_error(message)
+ raise MergeError, message
+ end
+
+ def handle_merge_error(*args)
+ # No-op
+ end
+
+ def commit_message
+ params[:commit_message] ||
+ merge_request.default_merge_commit_message
+ end
+
+ def squash_sha!
+ strong_memoize(:squash_sha) do
+ params[:merge_request] = merge_request
+ squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute
+
+ case squash_result[:status]
+ when :success
+ squash_result[:squash_sha]
+ when :error
+ raise ::MergeRequests::MergeService::MergeError, squash_result[:message]
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb
index 449997bcf07..d8a78001b79 100644
--- a/app/services/merge_requests/merge_service.rb
+++ b/app/services/merge_requests/merge_service.rb
@@ -7,13 +7,7 @@ module MergeRequests
# mark merge request as merged and execute all hooks and notifications
# Executed when you do merge via GitLab UI
#
- class MergeService < MergeRequests::BaseService
- include Gitlab::Utils::StrongMemoize
-
- MergeError = Class.new(StandardError)
-
- attr_reader :merge_request, :source
-
+ class MergeService < MergeRequests::MergeBaseService
delegate :merge_jid, :state, to: :@merge_request
def execute(merge_request)
@@ -24,7 +18,7 @@ module MergeRequests
@merge_request = merge_request
- error_check!
+ validate!
merge_request.in_locked_state do
if commit
@@ -38,22 +32,22 @@ module MergeRequests
handle_merge_error(log_message: e.message, save_message_on_model: true)
end
- def source
- if merge_request.squash
- squash_sha!
- else
- merge_request.diff_head_sha
- end
- end
+ private
- # Overridden in EE.
- def hooks_validation_pass?(_merge_request)
- true
+ def validate!
+ authorization_check!
+ error_check!
end
- private
+ def authorization_check!
+ unless @merge_request.can_be_merged_by?(current_user)
+ raise_error('You are not allowed to merge this merge request')
+ end
+ end
def error_check!
+ super
+
error =
if @merge_request.should_be_rebased?
'Only fast-forward merge is allowed for your project. Please update your source branch'
@@ -63,7 +57,7 @@ module MergeRequests
'No source for merge'
end
- raise MergeError, error if error
+ raise_error(error) if error
end
def commit
@@ -73,36 +67,20 @@ module MergeRequests
if commit_id
log_info("Git merge finished on JID #{merge_jid} commit #{commit_id}")
else
- raise MergeError, 'Conflicts detected during merge'
+ raise_error('Conflicts detected during merge')
end
merge_request.update!(merge_commit_sha: commit_id)
end
- def squash_sha!
- strong_memoize(:squash_sha) do
- params[:merge_request] = merge_request
- squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute
-
- case squash_result[:status]
- when :success
- squash_result[:squash_sha]
- when :error
- raise ::MergeRequests::MergeService::MergeError, squash_result[:message]
- end
- end
- end
-
def try_merge
- message = params[:commit_message] || merge_request.default_merge_commit_message
-
- repository.merge(current_user, source, merge_request, message)
+ repository.merge(current_user, source, merge_request, commit_message)
rescue Gitlab::Git::PreReceiveError => e
- handle_merge_error(log_message: e.message)
- raise MergeError, 'Something went wrong during merge pre-receive hook'
+ raise MergeError,
+ "Something went wrong during merge pre-receive hook. #{e.message}".strip
rescue => e
handle_merge_error(log_message: e.message)
- raise MergeError, 'Something went wrong during merge'
+ raise_error('Something went wrong during merge')
ensure
merge_request.update!(in_progress_merge_commit_sha: nil)
end
diff --git a/app/services/merge_requests/merge_to_ref_service.rb b/app/services/merge_requests/merge_to_ref_service.rb
new file mode 100644
index 00000000000..8670b9ccf3d
--- /dev/null
+++ b/app/services/merge_requests/merge_to_ref_service.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ # Performs the merge between source SHA and the target branch. Instead
+ # of writing the result to the MR target branch, it targets the `target_ref`.
+ #
+ # Ideally this should leave the `target_ref` state with the same state the
+ # target branch would have if we used the regular `MergeService`, but without
+ # every side-effect that comes with it (MR updates, mails, source branch
+ # deletion, etc). This service should be kept idempotent (i.e. can
+ # be executed regardless of the `target_ref` current state).
+ #
+ class MergeToRefService < MergeRequests::MergeBaseService
+ def execute(merge_request)
+ @merge_request = merge_request
+
+ validate!
+
+ commit_id = commit
+
+ raise_error('Conflicts detected during merge') unless commit_id
+
+ success(commit_id: commit_id)
+ rescue MergeError, ArgumentError => error
+ error(error.message)
+ end
+
+ private
+
+ def validate!
+ error_check!
+ end
+
+ def error_check!
+ super
+
+ error =
+ if !hooks_validation_pass?(merge_request)
+ hooks_validation_error(merge_request)
+ elsif source.blank?
+ 'No source for merge'
+ end
+
+ raise_error(error) if error
+ end
+
+ def target_ref
+ merge_request.merge_ref_path
+ end
+
+ def commit
+ repository.merge_to_ref(current_user, source, merge_request, target_ref, commit_message)
+ rescue Gitlab::Git::PreReceiveError => error
+ raise MergeError, error.message
+ end
+ end
+end
diff --git a/app/services/merge_requests/merge_when_pipeline_succeeds_service.rb b/app/services/merge_requests/merge_when_pipeline_succeeds_service.rb
deleted file mode 100644
index 973e5b64e88..00000000000
--- a/app/services/merge_requests/merge_when_pipeline_succeeds_service.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-# frozen_string_literal: true
-
-module MergeRequests
- class MergeWhenPipelineSucceedsService < MergeRequests::BaseService
- # Marks the passed `merge_request` to be merged when the pipeline succeeds or
- # updates the params for the automatic merge
- def execute(merge_request)
- merge_request.merge_params.merge!(params)
-
- # The service is also called when the merge params are updated.
- already_approved = merge_request.merge_when_pipeline_succeeds?
-
- unless already_approved
- merge_request.merge_when_pipeline_succeeds = true
- merge_request.merge_user = @current_user
-
- SystemNoteService.merge_when_pipeline_succeeds(merge_request, @project, @current_user, merge_request.diff_head_commit)
- end
-
- merge_request.save
- end
-
- # Triggers the automatic merge of merge_request once the pipeline succeeds
- def trigger(pipeline)
- return unless pipeline.success?
-
- pipeline_merge_requests(pipeline) do |merge_request|
- next unless merge_request.merge_when_pipeline_succeeds?
- next unless merge_request.mergeable?
-
- merge_request.merge_async(merge_request.merge_user_id, merge_request.merge_params)
- end
- end
-
- # Cancels the automatic merge
- def cancel(merge_request)
- if merge_request.merge_when_pipeline_succeeds? && merge_request.open?
- merge_request.reset_merge_when_pipeline_succeeds
- SystemNoteService.cancel_merge_when_pipeline_succeeds(merge_request, @project, @current_user)
-
- success
- else
- error("Can't cancel the automatic merge", 406)
- end
- end
- end
-end
diff --git a/app/services/merge_requests/mergeability_check_service.rb b/app/services/merge_requests/mergeability_check_service.rb
new file mode 100644
index 00000000000..ef833774e65
--- /dev/null
+++ b/app/services/merge_requests/mergeability_check_service.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class MergeabilityCheckService < ::BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ delegate :project, to: :@merge_request
+ delegate :repository, to: :project
+
+ def initialize(merge_request)
+ @merge_request = merge_request
+ end
+
+ # Updates the MR merge_status. Whenever it switches to a can_be_merged state,
+ # the merge-ref is refreshed.
+ #
+ # Returns a ServiceResponse indicating merge_status is/became can_be_merged
+ # and the merge-ref is synced. Success in case of being/becoming mergeable,
+ # error otherwise.
+ def execute
+ return ServiceResponse.error(message: 'Invalid argument') unless merge_request
+ return ServiceResponse.error(message: 'Unsupported operation') if Gitlab::Database.read_only?
+
+ update_merge_status
+
+ unless merge_request.can_be_merged?
+ return ServiceResponse.error(message: 'Merge request is not mergeable')
+ end
+
+ unless payload.fetch(:merge_ref_head)
+ return ServiceResponse.error(message: 'Merge ref was not found')
+ end
+
+ ServiceResponse.success(payload: payload)
+ end
+
+ private
+
+ attr_reader :merge_request
+
+ def payload
+ strong_memoize(:payload) do
+ {
+ merge_ref_head: merge_ref_head_payload
+ }
+ end
+ end
+
+ def merge_ref_head_payload
+ commit = merge_request.merge_ref_head
+
+ return unless commit
+
+ target_id, source_id = commit.parent_ids
+
+ {
+ commit_id: commit.id,
+ source_id: source_id,
+ target_id: target_id
+ }
+ end
+
+ def update_merge_status
+ return unless merge_request.recheck_merge_status?
+
+ if can_git_merge?
+ merge_to_ref && merge_request.mark_as_mergeable
+ else
+ merge_request.mark_as_unmergeable
+ end
+ end
+
+ def can_git_merge?
+ !merge_request.broken? && repository.can_be_merged?(merge_request.diff_head_sha, merge_request.target_branch)
+ end
+
+ def merge_to_ref
+ result = MergeRequests::MergeToRefService.new(project, merge_request.author).execute(merge_request)
+ result[:status] == :success
+ end
+ end
+end
diff --git a/app/services/merge_requests/migrate_external_diffs_service.rb b/app/services/merge_requests/migrate_external_diffs_service.rb
new file mode 100644
index 00000000000..16050244637
--- /dev/null
+++ b/app/services/merge_requests/migrate_external_diffs_service.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class MigrateExternalDiffsService < ::BaseService
+ MAX_JOBS = 1000.freeze
+
+ attr_reader :diff
+
+ def self.enqueue!
+ ids = MergeRequestDiff.ids_for_external_storage_migration(limit: MAX_JOBS)
+
+ MigrateExternalDiffsWorker.bulk_perform_async(ids.map { |id| [id] })
+ end
+
+ def initialize(merge_request_diff)
+ @diff = merge_request_diff
+ end
+
+ def execute
+ diff.migrate_files_to_external_storage!
+ end
+ end
+end
diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb
index f26e3bee06f..c13f7dd5088 100644
--- a/app/services/merge_requests/post_merge_service.rb
+++ b/app/services/merge_requests/post_merge_service.rb
@@ -18,6 +18,7 @@ module MergeRequests
invalidate_cache_counts(merge_request, users: merge_request.assignees)
merge_request.update_project_counter_caches
delete_non_latest_diffs(merge_request)
+ cleanup_environments(merge_request)
end
private
diff --git a/app/services/merge_requests/push_options_handler_service.rb b/app/services/merge_requests/push_options_handler_service.rb
new file mode 100644
index 00000000000..a24163331e8
--- /dev/null
+++ b/app/services/merge_requests/push_options_handler_service.rb
@@ -0,0 +1,162 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class PushOptionsHandlerService
+ LIMIT = 10
+
+ attr_reader :branches, :changes_by_branch, :current_user, :errors,
+ :project, :push_options, :target_project
+
+ def initialize(project, current_user, changes, push_options)
+ @project = project
+ @target_project = @project.default_merge_request_target
+ @current_user = current_user
+ @branches = get_branches(changes)
+ @push_options = push_options
+ @errors = []
+ end
+
+ def execute
+ validate_service
+ return self if errors.present?
+
+ branches.each do |branch|
+ execute_for_branch(branch)
+ rescue Gitlab::Access::AccessDeniedError
+ errors << 'User access was denied'
+ rescue StandardError => e
+ Gitlab::AppLogger.error(e)
+ errors << 'An unknown error occurred'
+ end
+
+ self
+ end
+
+ private
+
+ def get_branches(raw_changes)
+ Gitlab::ChangesList.new(raw_changes).map do |changes|
+ next unless Gitlab::Git.branch_ref?(changes[:ref])
+
+ # Deleted branch
+ next if Gitlab::Git.blank_ref?(changes[:newrev])
+
+ # Default branch
+ branch_name = Gitlab::Git.branch_name(changes[:ref])
+ next if branch_name == target_project.default_branch
+
+ branch_name
+ end.compact.uniq
+ end
+
+ def validate_service
+ errors << 'User is required' if current_user.nil?
+
+ unless target_project.merge_requests_enabled?
+ errors << "Merge requests are not enabled for project #{target_project.full_path}"
+ end
+
+ if branches.size > LIMIT
+ errors << "Too many branches pushed (#{branches.size} were pushed, limit is #{LIMIT})"
+ end
+
+ if push_options[:target] && !target_project.repository.branch_exists?(push_options[:target])
+ errors << "Branch #{push_options[:target]} does not exist"
+ end
+ end
+
+ # Returns a Hash of branch => MergeRequest
+ def merge_requests
+ @merge_requests ||= MergeRequest.from_project(target_project)
+ .opened
+ .from_source_branches(branches)
+ .index_by(&:source_branch)
+ end
+
+ def execute_for_branch(branch)
+ merge_request = merge_requests[branch]
+
+ if merge_request
+ update!(merge_request)
+ else
+ create!(branch)
+ end
+ end
+
+ def create!(branch)
+ unless push_options[:create]
+ errors << "A merge_request.create push option is required to create a merge request for branch #{branch}"
+ return
+ end
+
+ # Use BuildService to assign the standard attributes of a merge request
+ merge_request = ::MergeRequests::BuildService.new(
+ project,
+ current_user,
+ create_params(branch)
+ ).execute
+
+ unless merge_request.errors.present?
+ merge_request = ::MergeRequests::CreateService.new(
+ project,
+ current_user,
+ merge_request.attributes.merge(assignees: merge_request.assignees)
+ ).execute
+ end
+
+ collect_errors_from_merge_request(merge_request) unless merge_request.persisted?
+ end
+
+ def update!(merge_request)
+ merge_request = ::MergeRequests::UpdateService.new(
+ target_project,
+ current_user,
+ update_params
+ ).execute(merge_request)
+
+ collect_errors_from_merge_request(merge_request) unless merge_request.valid?
+ end
+
+ def create_params(branch)
+ params = {
+ assignees: [current_user],
+ source_branch: branch,
+ source_project: project,
+ target_branch: push_options[:target] || target_project.default_branch,
+ target_project: target_project
+ }
+
+ if push_options.key?(:merge_when_pipeline_succeeds)
+ params.merge!(
+ merge_when_pipeline_succeeds: push_options[:merge_when_pipeline_succeeds],
+ merge_user: current_user
+ )
+ end
+
+ params
+ end
+
+ def update_params
+ params = {}
+
+ if push_options.key?(:merge_when_pipeline_succeeds)
+ params.merge!(
+ merge_when_pipeline_succeeds: push_options[:merge_when_pipeline_succeeds],
+ merge_user: current_user
+ )
+ end
+
+ if push_options.key?(:target)
+ params[:target_branch] = push_options[:target]
+ end
+
+ params
+ end
+
+ def collect_errors_from_merge_request(merge_request)
+ merge_request.errors.full_messages.each do |error|
+ errors << error
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb
index 31b3ebf311e..4b9921c28ba 100644
--- a/app/services/merge_requests/rebase_service.rb
+++ b/app/services/merge_requests/rebase_service.rb
@@ -20,17 +20,7 @@ module MergeRequests
return false
end
- log_prefix = "#{self.class.name} info (#{merge_request.to_reference(full: true)}):"
-
- Gitlab::GitLogger.info("#{log_prefix} rebase started")
-
- rebase_sha = repository.rebase(current_user, merge_request)
-
- Gitlab::GitLogger.info("#{log_prefix} rebased to #{rebase_sha}")
-
- merge_request.update(rebase_commit_sha: rebase_sha)
-
- Gitlab::GitLogger.info("#{log_prefix} rebase SHA saved: #{rebase_sha}")
+ repository.rebase(current_user, merge_request)
true
rescue => e
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index f712b8863cd..08130a531ee 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -14,13 +14,17 @@ module MergeRequests
private
def refresh_merge_requests!
+ # n + 1: https://gitlab.com/gitlab-org/gitlab-ce/issues/60289
Gitlab::GitalyClient.allow_n_plus_1_calls(&method(:find_new_commits))
+
# Be sure to close outstanding MRs before reloading them to avoid generating an
# empty diff during a manual merge
close_upon_missing_source_branch_ref
post_merge_manually_merged
reload_merge_requests
- reset_merge_when_pipeline_succeeds
+ outdate_suggestions
+ refresh_pipelines_on_merge_requests
+ cancel_auto_merge
mark_pending_todos_done
cache_merge_requests_closing_issues
@@ -106,8 +110,6 @@ module MergeRequests
end
merge_request.mark_as_unchecked
- create_merge_request_pipeline(merge_request, current_user)
- UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id)
end
# Upcoming method calls need the refreshed version of
@@ -125,8 +127,25 @@ module MergeRequests
merge_request.source_branch == @push.branch_name
end
- def reset_merge_when_pipeline_succeeds
- merge_requests_for_source_branch.each(&:reset_merge_when_pipeline_succeeds)
+ def outdate_suggestions
+ outdate_service = Suggestions::OutdateService.new
+
+ merge_requests_for_source_branch.each do |merge_request|
+ outdate_service.execute(merge_request)
+ end
+ end
+
+ def refresh_pipelines_on_merge_requests
+ merge_requests_for_source_branch.each do |merge_request|
+ create_pipeline_for(merge_request, current_user)
+ UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id)
+ end
+ end
+
+ def cancel_auto_merge
+ merge_requests_for_source_branch.each do |merge_request|
+ AutoMergeService.new(project, current_user).cancel(merge_request)
+ end
end
def mark_pending_todos_done
diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb
index f6cbe769ef4..f87005bcb6c 100644
--- a/app/services/merge_requests/reopen_service.rb
+++ b/app/services/merge_requests/reopen_service.rb
@@ -3,7 +3,7 @@
module MergeRequests
class ReopenService < MergeRequests::BaseService
def execute(merge_request)
- return merge_request unless can?(current_user, :update_merge_request, merge_request)
+ return merge_request unless can?(current_user, :reopen_merge_request, merge_request)
if merge_request.reopen
create_event(merge_request)
diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb
index 9d1a5d5e6d4..88ca3b4f5a8 100644
--- a/app/services/merge_requests/squash_service.rb
+++ b/app/services/merge_requests/squash_service.rb
@@ -10,10 +10,10 @@ module MergeRequests
end
if merge_request.squash_in_progress?
- return error('Squash task canceled: another squash is already in progress.')
+ return error(s_('MergeRequests|Squash task canceled: another squash is already in progress.'))
end
- squash! || error('Failed to squash. Should be done manually.')
+ squash! || error(s_('MergeRequests|Failed to squash. Should be done manually.'))
end
private
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 8112c2a4299..6a0f3000ffb 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -16,7 +16,7 @@ module MergeRequests
params.delete(:force_remove_source_branch)
end
- if params[:force_remove_source_branch].present?
+ if params.has_key?(:force_remove_source_branch)
merge_request.merge_params['force_remove_source_branch'] = params.delete(:force_remove_source_branch)
end
@@ -24,13 +24,13 @@ module MergeRequests
update_task_event(merge_request) || update(merge_request)
end
- # rubocop:disable Metrics/AbcSize
def handle_changes(merge_request, options)
old_associations = options.fetch(:old_associations, {})
old_labels = old_associations.fetch(:labels, [])
old_mentioned_users = old_associations.fetch(:mentioned_users, [])
+ old_assignees = old_associations.fetch(:assignees, [])
- if has_changes?(merge_request, old_labels: old_labels)
+ if has_changes?(merge_request, old_labels: old_labels, old_assignees: old_assignees)
todo_service.mark_pending_todos_as_done(merge_request, current_user)
end
@@ -45,15 +45,10 @@ module MergeRequests
merge_request.target_branch)
end
- if merge_request.previous_changes.include?('assignee_id')
- reassigned_merge_request_args = [merge_request, current_user]
-
- old_assignee_id = merge_request.previous_changes['assignee_id'].first
- reassigned_merge_request_args << User.find(old_assignee_id) if old_assignee_id
-
- create_assignee_note(merge_request)
- notification_service.async.reassigned_merge_request(*reassigned_merge_request_args)
- todo_service.reassigned_merge_request(merge_request, current_user)
+ if merge_request.assignees != old_assignees
+ create_assignee_note(merge_request, old_assignees)
+ notification_service.async.reassigned_merge_request(merge_request, current_user, old_assignees)
+ todo_service.reassigned_issuable(merge_request, current_user, old_assignees)
end
if merge_request.previous_changes.include?('target_branch') ||
@@ -81,7 +76,6 @@ module MergeRequests
)
end
end
- # rubocop:enable Metrics/AbcSize
def handle_task_changes(merge_request)
todo_service.mark_pending_todos_as_done(merge_request, current_user)
@@ -95,7 +89,7 @@ module MergeRequests
merge_request.update(merge_error: nil)
if merge_request.head_pipeline && merge_request.head_pipeline.active?
- MergeRequests::MergeWhenPipelineSucceedsService.new(project, current_user).execute(merge_request)
+ AutoMergeService.new(project, current_user).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS)
else
merge_request.merge_async(current_user.id, {})
end
diff --git a/app/services/milestones/promote_service.rb b/app/services/milestones/promote_service.rb
index cbe5996e8ca..0fe67067eb5 100644
--- a/app/services/milestones/promote_service.rb
+++ b/app/services/milestones/promote_service.rb
@@ -26,17 +26,15 @@ module Milestones
private
- # rubocop: disable CodeReuse/ActiveRecord
def milestone_ids_for_merge(group_milestone)
# Pluck need to be used here instead of select so the array of ids
# is persistent after old milestones gets deleted.
@milestone_ids_for_merge ||= begin
search_params = { title: group_milestone.title, project_ids: group_project_ids, state: 'all' }
milestones = MilestonesFinder.new(search_params).execute
- milestones.pluck(:id)
+ milestones.pluck_primary_key
end
end
- # rubocop: enable CodeReuse/ActiveRecord
def move_children_to_group_milestone(group_milestone)
milestone_ids_for_merge(group_milestone).in_groups_of(100, false) do |milestone_ids|
@@ -45,7 +43,7 @@ module Milestones
end
def check_project_milestone!(milestone)
- raise_error('Only project milestones can be promoted.') unless milestone.project_milestone?
+ raise_error(s_('PromoteMilestone|Only project milestones can be promoted.')) unless milestone.project_milestone?
end
def clone_project_milestone(milestone)
@@ -73,7 +71,7 @@ module Milestones
# rubocop: enable CodeReuse/ActiveRecord
def group
- @group ||= parent.group || raise_error('Project does not belong to a group.')
+ @group ||= parent.group || raise_error(s_('PromoteMilestone|Project does not belong to a group.'))
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -87,7 +85,7 @@ module Milestones
end
def raise_error(message)
- raise PromoteMilestoneError, "Promotion failed - #{message}"
+ raise PromoteMilestoneError, s_("PromoteMilestone|Promotion failed - %{message}") % { message: message }
end
end
end
diff --git a/app/services/note_summary.rb b/app/services/note_summary.rb
index 81f6f92f75c..60a68568833 100644
--- a/app/services/note_summary.rb
+++ b/app/services/note_summary.rb
@@ -5,7 +5,9 @@ class NoteSummary
attr_reader :metadata
def initialize(noteable, project, author, body, action: nil, commit_count: nil)
- @note = { noteable: noteable, project: project, author: author, note: body }
+ @note = { noteable: noteable,
+ created_at: noteable.system_note_timestamp,
+ project: project, author: author, note: body }
@metadata = { action: action, commit_count: commit_count }.compact
set_commit_params if note[:noteable].is_a?(Commit)
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index 5a6e7338b42..1b46f6d8a72 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -21,7 +21,7 @@ module Notes
if quick_actions_service.supported?(note)
options = { merge_request_diff_head_sha: merge_request_diff_head_sha }
- content, command_params = quick_actions_service.extract_commands(note, options)
+ content, update_params = quick_actions_service.execute(note, options)
only_commands = content.empty?
@@ -43,16 +43,17 @@ module Notes
Suggestions::CreateService.new(note).execute
end
- if command_params.present?
- quick_actions_service.execute(command_params, note)
+ if quick_actions_service.commands_executed_count.to_i > 0
+ if update_params.present?
+ quick_actions_service.apply_updates(update_params, note)
+ note.commands_changes = update_params
+ end
# We must add the error after we call #save because errors are reset
# when #save is called
if only_commands
note.errors.add(:commands_only, 'Commands applied')
end
-
- note.commands_changes = command_params
end
note
diff --git a/app/services/notes/quick_actions_service.rb b/app/services/notes/quick_actions_service.rb
index 985a03060bd..0852a708240 100644
--- a/app/services/notes/quick_actions_service.rb
+++ b/app/services/notes/quick_actions_service.rb
@@ -1,7 +1,18 @@
# frozen_string_literal: true
+# QuickActionsService class
+#
+# Executes quick actions commands extracted from note text
+#
+# Most commands returns parameters to be applied later
+# using QuickActionService#apply_updates
+#
module Notes
class QuickActionsService < BaseService
+ attr_reader :interpret_service
+
+ delegate :commands_executed_count, to: :interpret_service, allow_nil: true
+
UPDATE_SERVICES = {
'Issue' => Issues::UpdateService,
'MergeRequest' => MergeRequests::UpdateService,
@@ -25,18 +36,21 @@ module Notes
self.class.supported?(note)
end
- def extract_commands(note, options = {})
+ def execute(note, options = {})
return [note.note, {}] unless supported?(note)
- QuickActions::InterpretService.new(project, current_user, options)
- .execute(note.note, note.noteable)
+ @interpret_service = QuickActions::InterpretService.new(project, current_user, options)
+
+ @interpret_service.execute(note.note, note.noteable)
end
- def execute(command_params, note)
- return if command_params.empty?
+ # Applies updates extracted to note#noteable
+ # The update parameters are extracted on self#execute
+ def apply_updates(update_params, note)
+ return if update_params.empty?
return unless supported?(note)
- self.class.noteable_update_service(note).new(note.parent, current_user, command_params).execute(note.noteable)
+ self.class.noteable_update_service(note).new(note.parent, current_user, update_params).execute(note.noteable)
end
end
end
diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb
index d2052bed646..384d1dd2e50 100644
--- a/app/services/notes/update_service.rb
+++ b/app/services/notes/update_service.rb
@@ -22,7 +22,7 @@ module Notes
# We need to refresh the previous suggestions call cache
# in order to get the new records.
- note.reload
+ note.reset
end
note
diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb
index 56f11b31110..ca3f0b73096 100644
--- a/app/services/notification_recipient_service.rb
+++ b/app/services/notification_recipient_service.rb
@@ -135,7 +135,7 @@ module NotificationRecipientService
global_users_ids = user_ids_with_project_level_global.concat(user_ids_with_group_level_global)
user_ids += user_ids_with_global_level_custom(global_users_ids, custom_action)
- add_recipients(user_scope.where(id: user_ids), :watch, nil)
+ add_recipients(user_scope.where(id: user_ids), :custom, nil)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -247,15 +247,15 @@ module NotificationRecipientService
attr_reader :target
attr_reader :current_user
attr_reader :action
- attr_reader :previous_assignee
+ attr_reader :previous_assignees
attr_reader :skip_current_user
- def initialize(target, current_user, action:, custom_action: nil, previous_assignee: nil, skip_current_user: true)
+ def initialize(target, current_user, action:, custom_action: nil, previous_assignees: nil, skip_current_user: true)
@target = target
@current_user = current_user
@action = action
@custom_action = custom_action
- @previous_assignee = previous_assignee
+ @previous_assignees = previous_assignees
@skip_current_user = skip_current_user
end
@@ -270,11 +270,7 @@ module NotificationRecipientService
# Re-assign is considered as a mention of the new assignee
case custom_action
- when :reassign_merge_request
- add_recipients(previous_assignee, :mention, nil)
- add_recipients(target.assignee, :mention, NotificationReason::ASSIGNED)
- when :reassign_issue
- previous_assignees = Array(previous_assignee)
+ when :reassign_merge_request, :reassign_issue
add_recipients(previous_assignees, :mention, nil)
add_recipients(target.assignees, :mention, NotificationReason::ASSIGNED)
end
@@ -287,17 +283,11 @@ module NotificationRecipientService
# receive them, too.
add_mentions(current_user, target: target)
- # Add the assigned users, if any
- assignees = case custom_action
- when :new_issue
- target.assignees
- else
- target.assignee
- end
-
# We use the `:participating` notification level in order to match existing legacy behavior as captured
# in existing specs (notification_service_spec.rb ~ line 507)
- add_recipients(assignees, :participating, NotificationReason::ASSIGNED) if assignees
+ if target.is_a?(Issuable)
+ add_recipients(target.assignees, :participating, NotificationReason::ASSIGNED)
+ end
add_labels_subscribers
end
@@ -401,7 +391,7 @@ module NotificationRecipientService
def build!
return [] unless project
- add_recipients(project.team.maintainers, :watch, nil)
+ add_recipients(project.team.maintainers, :mention, nil)
end
def acting_user
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 1a65561dd70..5aa804666f0 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -89,14 +89,14 @@ class NotificationService
# * project team members with notification level higher then Participating
# * users with custom level checked with "close issue"
#
- def close_issue(issue, current_user)
- close_resource_email(issue, current_user, :closed_issue_email)
+ def close_issue(issue, current_user, closed_via: nil)
+ close_resource_email(issue, current_user, :closed_issue_email, closed_via: closed_via)
end
# When we reassign an issue we should send an email to:
#
- # * issue old assignee if their notification level is not Disabled
- # * issue new assignee if their notification level is not Disabled
+ # * issue old assignees if their notification level is not Disabled
+ # * issue new assignees if their notification level is not Disabled
# * users with custom level checked with "reassign issue"
#
def reassigned_issue(issue, current_user, previous_assignees = [])
@@ -104,7 +104,7 @@ class NotificationService
issue,
current_user,
action: "reassign",
- previous_assignee: previous_assignees
+ previous_assignees: previous_assignees
)
previous_assignee_ids = previous_assignees.map(&:id)
@@ -140,7 +140,7 @@ class NotificationService
# When create a merge request we should send an email to:
#
# * mr author
- # * mr assignee if their notification level is not Disabled
+ # * mr assignees if their notification level is not Disabled
# * project team members with notification level higher then Participating
# * watchers of the mr's labels
# * users with custom level checked with "new merge request"
@@ -184,23 +184,25 @@ class NotificationService
# When we reassign a merge_request we should send an email to:
#
- # * merge_request old assignee if their notification level is not Disabled
- # * merge_request assignee if their notification level is not Disabled
+ # * merge_request old assignees if their notification level is not Disabled
+ # * merge_request new assignees if their notification level is not Disabled
# * users with custom level checked with "reassign merge request"
#
- def reassigned_merge_request(merge_request, current_user, previous_assignee = nil)
+ def reassigned_merge_request(merge_request, current_user, previous_assignees = [])
recipients = NotificationRecipientService.build_recipients(
merge_request,
current_user,
action: "reassign",
- previous_assignee: previous_assignee
+ previous_assignees: previous_assignees
)
+ previous_assignee_ids = previous_assignees.map(&:id)
+
recipients.each do |recipient|
mailer.reassigned_merge_request_email(
recipient.user.id,
merge_request.id,
- previous_assignee&.id,
+ previous_assignee_ids,
current_user.id,
recipient.reason
).deliver_later
@@ -236,7 +238,7 @@ class NotificationService
merge_request,
current_user,
:merged_merge_request_email,
- skip_current_user: !merge_request.merge_when_pipeline_succeeds?
+ skip_current_user: !merge_request.auto_merge_enabled?
)
end
@@ -502,7 +504,7 @@ class NotificationService
end
end
- def close_resource_email(target, current_user, method, skip_current_user: true)
+ def close_resource_email(target, current_user, method, skip_current_user: true, closed_via: nil)
action = method == :merged_merge_request_email ? "merge" : "close"
recipients = NotificationRecipientService.build_recipients(
@@ -513,7 +515,7 @@ class NotificationService
)
recipients.each do |recipient|
- mailer.send(method, recipient.user.id, target.id, current_user.id, recipient.reason).deliver_later
+ mailer.send(method, recipient.user.id, target.id, current_user.id, reason: recipient.reason, closed_via: closed_via).deliver_later
end
end
diff --git a/app/services/pages_domains/create_acme_order_service.rb b/app/services/pages_domains/create_acme_order_service.rb
new file mode 100644
index 00000000000..c600f497fa5
--- /dev/null
+++ b/app/services/pages_domains/create_acme_order_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module PagesDomains
+ class CreateAcmeOrderService
+ attr_reader :pages_domain
+
+ def initialize(pages_domain)
+ @pages_domain = pages_domain
+ end
+
+ def execute
+ lets_encrypt_client = Gitlab::LetsEncrypt::Client.new
+ order = lets_encrypt_client.new_order(pages_domain.domain)
+
+ challenge = order.new_challenge
+
+ private_key = OpenSSL::PKey::RSA.new(4096)
+ saved_order = pages_domain.acme_orders.create!(
+ url: order.url,
+ expires_at: order.expires,
+ private_key: private_key.to_pem,
+
+ challenge_token: challenge.token,
+ challenge_file_content: challenge.file_content
+ )
+
+ challenge.request_validation
+ saved_order
+ end
+ end
+end
diff --git a/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb
new file mode 100644
index 00000000000..2dfe1a3d8ca
--- /dev/null
+++ b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module PagesDomains
+ class ObtainLetsEncryptCertificateService
+ attr_reader :pages_domain
+
+ def initialize(pages_domain)
+ @pages_domain = pages_domain
+ end
+
+ def execute
+ pages_domain.acme_orders.expired.delete_all
+ acme_order = pages_domain.acme_orders.first
+
+ unless acme_order
+ ::PagesDomains::CreateAcmeOrderService.new(pages_domain).execute
+ return
+ end
+
+ api_order = ::Gitlab::LetsEncrypt::Client.new.load_order(acme_order.url)
+
+ # https://tools.ietf.org/html/rfc8555#section-7.1.6 - statuses diagram
+ case api_order.status
+ when 'ready'
+ api_order.request_certificate(private_key: acme_order.private_key, domain: pages_domain.domain)
+ when 'valid'
+ save_certificate(acme_order.private_key, api_order)
+ acme_order.destroy!
+ # when 'invalid'
+ # TODO: implement error handling
+ end
+ end
+
+ private
+
+ def save_certificate(private_key, api_order)
+ certificate = api_order.certificate
+ pages_domain.update!(key: private_key, certificate: certificate)
+ end
+ end
+end
diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb
index c1655c38095..2b4c4ae68e2 100644
--- a/app/services/preview_markdown_service.rb
+++ b/app/services/preview_markdown_service.rb
@@ -17,7 +17,7 @@ class PreviewMarkdownService < BaseService
private
def explain_quick_actions(text)
- return text, [] unless %w(Issue MergeRequest Commit).include?(commands_target_type)
+ return text, [] unless %w(Issue MergeRequest Commit).include?(target_type)
quick_actions_service = QuickActions::InterpretService.new(project, current_user)
quick_actions_service.explain(text, find_commands_target)
@@ -30,22 +30,36 @@ class PreviewMarkdownService < BaseService
end
def find_suggestions(text)
- return [] unless params[:preview_suggestions]
+ return [] unless preview_sugestions?
- Banzai::SuggestionsParser.parse(text)
+ position = Gitlab::Diff::Position.new(new_path: params[:file_path],
+ new_line: params[:line].to_i,
+ base_sha: params[:base_sha],
+ head_sha: params[:head_sha],
+ start_sha: params[:start_sha])
+
+ Gitlab::Diff::SuggestionsParser.parse(text, position: position,
+ project: project,
+ supports_suggestion: params[:preview_suggestions])
+ end
+
+ def preview_sugestions?
+ params[:preview_suggestions] &&
+ target_type == 'MergeRequest' &&
+ Ability.allowed?(current_user, :download_code, project)
end
def find_commands_target
QuickActions::TargetService
.new(project, current_user)
- .execute(commands_target_type, commands_target_id)
+ .execute(target_type, target_id)
end
- def commands_target_type
- params[:quick_actions_target_type]
+ def target_type
+ params[:target_type]
end
- def commands_target_id
- params[:quick_actions_target_id]
+ def target_id
+ params[:target_id]
end
end
diff --git a/app/services/projects/cleanup_service.rb b/app/services/projects/cleanup_service.rb
index 12103ea34b5..5972bfd4071 100644
--- a/app/services/projects/cleanup_service.rb
+++ b/app/services/projects/cleanup_service.rb
@@ -18,9 +18,6 @@ module Projects
# per rewritten object, with the old and new SHAs space-separated. It can be
# used to update or remove content that references the objects that BFG has
# altered
- #
- # Currently, only the project repository is modified by this service, but we
- # may wish to modify other data sources in the future.
def execute
apply_bfg_object_map!
@@ -41,10 +38,52 @@ module Projects
raise NoUploadError unless project.bfg_object_map.exists?
project.bfg_object_map.open do |io|
- repository_cleaner.apply_bfg_object_map(io)
+ repository_cleaner.apply_bfg_object_map_stream(io) do |response|
+ cleanup_diffs(response)
+ end
+ end
+ end
+
+ def cleanup_diffs(response)
+ old_commit_shas = extract_old_commit_shas(response.entries)
+
+ ActiveRecord::Base.transaction do
+ cleanup_merge_request_diffs(old_commit_shas)
+ cleanup_note_diff_files(old_commit_shas)
end
end
+ def extract_old_commit_shas(batch)
+ batch.lazy.select { |entry| entry.type == :COMMIT }.map(&:old_oid).force
+ end
+
+ def cleanup_merge_request_diffs(old_commit_shas)
+ merge_request_diffs = MergeRequestDiff
+ .by_project_id(project.id)
+ .by_commit_sha(old_commit_shas)
+
+ # It's important to run the ActiveRecord callbacks here
+ merge_request_diffs.destroy_all # rubocop:disable Cop/DestroyAll
+
+ # TODO: ensure the highlight cache is removed immediately. It's too hard
+ # to calculate the Redis keys at present.
+ #
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/61115
+ end
+
+ def cleanup_note_diff_files(old_commit_shas)
+ # Pluck the IDs instead of running the query twice to ensure we clear the
+ # cache for exactly the note diffs we remove
+ ids = NoteDiffFile
+ .referencing_sha(old_commit_shas, project_id: project.id)
+ .pluck_primary_key
+
+ NoteDiffFile.id_in(ids).delete_all
+
+ # A highlighted version of the diff is stored in redis. Remove it now.
+ Gitlab::DiscussionsDiff::HighlightCache.clear_multiple(ids)
+ end
+
def repository_cleaner
@repository_cleaner ||= Gitlab::Git::RepositoryCleaner.new(repository.raw)
end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index d03137b63b2..9f335cceb67 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -2,6 +2,8 @@
module Projects
class CreateService < BaseService
+ include ValidatesClassificationLabel
+
def initialize(user, params)
@current_user, @params = user, params.dup
@skip_wiki = @params.delete(:skip_wiki)
@@ -45,6 +47,8 @@ module Projects
relations_block&.call(@project)
yield(@project) if block_given?
+ validate_classification_label(@project, :external_authorization_classification_label)
+
# If the block added errors, don't try to save the project
return @project if @project.errors.any?
@@ -96,8 +100,6 @@ module Projects
current_user.invalidate_personal_projects_count
create_readme if @initialize_with_readme
-
- configure_group_clusters_for_project
end
# Refresh the current user's authorizations inline (so they can access the
@@ -123,10 +125,6 @@ module Projects
Files::CreateService.new(@project, current_user, commit_attrs).execute
end
- def configure_group_clusters_for_project
- ClusterProjectConfigureWorker.perform_async(@project.id)
- end
-
def skip_wiki?
!@project.feature_available?(:wiki, current_user) || @skip_wiki
end
@@ -155,8 +153,8 @@ module Projects
log_message << " Project ID: #{@project.id}" if @project&.id
Rails.logger.error(log_message)
- if @project
- @project.import_state.mark_as_failed(message) if @project.persisted? && @project.import?
+ if @project && @project.persisted? && @project.import_state
+ @project.import_state.mark_as_failed(message)
end
@project
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index b14b31302f5..d8e670e40ce 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -61,11 +61,11 @@ module Projects
flush_caches(@project)
unless rollback_repository(removal_path(repo_path), repo_path)
- raise_error('Failed to restore project repository. Please contact the administrator.')
+ raise_error(s_('DeleteProject|Failed to restore project repository. Please contact the administrator.'))
end
unless rollback_repository(removal_path(wiki_path), wiki_path)
- raise_error('Failed to restore wiki repository. Please contact the administrator.')
+ raise_error(s_('DeleteProject|Failed to restore wiki repository. Please contact the administrator.'))
end
end
@@ -81,11 +81,11 @@ module Projects
def trash_repositories!
unless remove_repository(repo_path)
- raise_error('Failed to remove project repository. Please try again or contact administrator.')
+ raise_error(s_('DeleteProject|Failed to remove project repository. Please try again or contact administrator.'))
end
unless remove_repository(wiki_path)
- raise_error('Failed to remove wiki repository. Please try again or contact administrator.')
+ raise_error(s_('DeleteProject|Failed to remove wiki repository. Please try again or contact administrator.'))
end
end
@@ -148,7 +148,7 @@ module Projects
def attempt_destroy_transaction(project)
unless remove_registry_tags
- raise_error('Failed to remove some tags in project container registry. Please try again or contact administrator.')
+ raise_error(s_('DeleteProject|Failed to remove some tags in project container registry. Please try again or contact administrator.'))
end
project.leave_pool_repository
diff --git a/app/services/projects/detect_repository_languages_service.rb b/app/services/projects/detect_repository_languages_service.rb
index 4a837a4fb6a..d3680637217 100644
--- a/app/services/projects/detect_repository_languages_service.rb
+++ b/app/services/projects/detect_repository_languages_service.rb
@@ -2,7 +2,7 @@
module Projects
class DetectRepositoryLanguagesService < BaseService
- attr_reader :detected_repository_languages, :programming_languages
+ attr_reader :programming_languages
# rubocop: disable CodeReuse/ActiveRecord
def execute
@@ -25,9 +25,11 @@ module Projects
RepositoryLanguage.table_name,
detection.insertions(matching_programming_languages)
)
+
+ set_detected_repository_languages
end
- project.repository_languages.reload
+ project.repository_languages.reset
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -56,5 +58,11 @@ module Projects
retry
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ def set_detected_repository_languages
+ return if project.detected_repository_languages?
+
+ project.update_column(:detected_repository_languages, true)
+ end
end
end
diff --git a/app/services/projects/download_service.rb b/app/services/projects/download_service.rb
index dd297c9ba43..aba175eb79b 100644
--- a/app/services/projects/download_service.rb
+++ b/app/services/projects/download_service.rb
@@ -11,7 +11,7 @@ module Projects
end
def execute
- return nil unless valid_url?(@url)
+ return unless valid_url?(@url)
uploader = FileUploader.new(@project)
uploader.download!(@url)
diff --git a/app/services/projects/fetch_statistics_increment_service.rb b/app/services/projects/fetch_statistics_increment_service.rb
new file mode 100644
index 00000000000..8644e6bf313
--- /dev/null
+++ b/app/services/projects/fetch_statistics_increment_service.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Projects
+ class FetchStatisticsIncrementService
+ attr_reader :project
+
+ def initialize(project)
+ @project = project
+ end
+
+ def execute
+ increment_fetch_count_sql = <<~SQL
+ INSERT INTO #{table_name} (project_id, date, fetch_count)
+ VALUES (#{project.id}, '#{Date.today}', 1)
+ SQL
+
+ increment_fetch_count_sql += if Gitlab::Database.postgresql?
+ "ON CONFLICT (project_id, date) DO UPDATE SET fetch_count = #{table_name}.fetch_count + 1"
+ else
+ "ON DUPLICATE KEY UPDATE fetch_count = #{table_name}.fetch_count + 1"
+ end
+
+ ActiveRecord::Base.connection.execute(increment_fetch_count_sql)
+ end
+
+ private
+
+ def table_name
+ ProjectDailyStatistic.table_name
+ end
+ end
+end
diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb
index fc234bafc57..0b4ab7b8e4d 100644
--- a/app/services/projects/fork_service.rb
+++ b/app/services/projects/fork_service.rb
@@ -36,18 +36,22 @@ module Projects
def fork_new_project
new_params = {
- visibility_level: allowed_visibility_level,
- description: @project.description,
- name: target_name,
- path: target_path,
- shared_runners_enabled: @project.shared_runners_enabled,
- namespace_id: target_namespace.id,
- fork_network: fork_network,
+ visibility_level: allowed_visibility_level,
+ description: @project.description,
+ name: target_name,
+ path: target_path,
+ shared_runners_enabled: @project.shared_runners_enabled,
+ namespace_id: target_namespace.id,
+ fork_network: fork_network,
+ # We need to set default_git_depth to 0 for the forked project when
+ # @project.default_git_depth is nil in order to keep the same behaviour
+ # and not get ProjectCiCdSetting::DEFAULT_GIT_DEPTH set on create
+ ci_cd_settings_attributes: { default_git_depth: @project.default_git_depth || 0 },
# We need to assign the fork network membership after the project has
# been instantiated to avoid ActiveRecord trying to create it when
# initializing the project, as that would cause a foreign key constraint
# exception.
- relations_block: -> (project) { build_fork_network_member(project) }
+ relations_block: -> (project) { build_fork_network_member(project) }
}
if @project.avatar.present? && @project.avatar.image?
diff --git a/app/services/projects/git_deduplication_service.rb b/app/services/projects/git_deduplication_service.rb
new file mode 100644
index 00000000000..74d469ecf37
--- /dev/null
+++ b/app/services/projects/git_deduplication_service.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+module Projects
+ class GitDeduplicationService < BaseService
+ include ExclusiveLeaseGuard
+
+ LEASE_TIMEOUT = 86400
+
+ delegate :pool_repository, to: :project
+ attr_reader :project
+
+ def initialize(project)
+ @project = project
+ end
+
+ def execute
+ try_obtain_lease do
+ unless project.has_pool_repository?
+ disconnect_git_alternates
+ break
+ end
+
+ if source_project? && pool_can_fetch_from_source?
+ fetch_from_source
+ end
+
+ project.link_pool_repository if same_storage_as_pool?(project.repository)
+ end
+ end
+
+ private
+
+ def disconnect_git_alternates
+ project.repository.disconnect_alternates
+ end
+
+ def pool_can_fetch_from_source?
+ project.git_objects_poolable? &&
+ same_storage_as_pool?(pool_repository.source_project.repository)
+ end
+
+ def same_storage_as_pool?(repository)
+ pool_repository.object_pool.repository.storage == repository.storage
+ end
+
+ def fetch_from_source
+ project.pool_repository.object_pool.fetch
+ end
+
+ def source_project?
+ return unless project.has_pool_repository?
+
+ project.pool_repository.source_project == project
+ end
+
+ def lease_timeout
+ LEASE_TIMEOUT
+ end
+
+ def lease_key
+ "git_deduplication:#{project.id}"
+ end
+ end
+end
diff --git a/app/services/projects/group_links/create_service.rb b/app/services/projects/group_links/create_service.rb
index 1392775f805..e3d5bea0852 100644
--- a/app/services/projects/group_links/create_service.rb
+++ b/app/services/projects/group_links/create_service.rb
@@ -4,13 +4,19 @@ module Projects
module GroupLinks
class CreateService < BaseService
def execute(group)
- return false unless group
+ return error('Not Found', 404) unless group && can?(current_user, :read_namespace, group)
- project.project_group_links.create(
+ link = project.project_group_links.new(
group: group,
group_access: params[:link_group_access],
expires_at: params[:expires_at]
)
+
+ if link.save
+ success(link: link)
+ else
+ error(link.errors.full_messages.to_sentence, 409)
+ end
end
end
end
diff --git a/app/services/projects/hashed_storage/base_attachment_service.rb b/app/services/projects/hashed_storage/base_attachment_service.rb
new file mode 100644
index 00000000000..828ab616bab
--- /dev/null
+++ b/app/services/projects/hashed_storage/base_attachment_service.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Projects
+ module HashedStorage
+ AttachmentMigrationError = Class.new(StandardError)
+
+ AttachmentCannotMoveError = Class.new(StandardError)
+
+ class BaseAttachmentService < BaseService
+ # Returns the disk_path value before the execution
+ attr_reader :old_disk_path
+
+ # Returns the disk_path value after the execution
+ attr_reader :new_disk_path
+
+ # Returns the logger currently in use
+ attr_reader :logger
+
+ # Return whether this operation was skipped or not
+ #
+ # @return [Boolean] true if skipped of false otherwise
+ def skipped?
+ @skipped
+ end
+
+ protected
+
+ def move_folder!(old_path, new_path)
+ unless File.directory?(old_path)
+ logger.info("Skipped attachments move from '#{old_path}' to '#{new_path}', source path doesn't exist or is not a directory (PROJECT_ID=#{project.id})")
+ @skipped = true
+
+ return true
+ end
+
+ if File.exist?(new_path)
+ logger.error("Cannot move attachments from '#{old_path}' to '#{new_path}', target path already exist (PROJECT_ID=#{project.id})")
+ raise AttachmentCannotMoveError, "Target path '#{new_path}' already exists"
+ end
+
+ # Create base path folder on the new storage layout
+ FileUtils.mkdir_p(File.dirname(new_path))
+
+ FileUtils.mv(old_path, new_path)
+ logger.info("Project attachments moved from '#{old_path}' to '#{new_path}' (PROJECT_ID=#{project.id})")
+
+ true
+ end
+ end
+ end
+end
diff --git a/app/services/projects/hashed_storage/base_repository_service.rb b/app/services/projects/hashed_storage/base_repository_service.rb
index 761c81d776f..f97a28b8c3b 100644
--- a/app/services/projects/hashed_storage/base_repository_service.rb
+++ b/app/services/projects/hashed_storage/base_repository_service.rb
@@ -2,11 +2,8 @@
module Projects
module HashedStorage
- # Returned when there is an error with the Hashed Storage migration
- RepositoryMigrationError = Class.new(StandardError)
-
- # Returned when there is an error with the Hashed Storage rollback
- RepositoryRollbackError = Class.new(StandardError)
+ # Returned when repository can't be made read-only because there is already a git transfer in progress
+ RepositoryInUseError = Class.new(StandardError)
class BaseRepositoryService < BaseService
include Gitlab::ShellAdapter
@@ -38,7 +35,10 @@ module Projects
# project was not originally empty.
if !from_exists && !to_exists
logger.warn "Can't find a repository on either source or target paths for #{project.full_path} (ID=#{project.id}) ..."
- return false
+
+ # We return true so we still reflect the change in the database.
+ # Next time the repository is (re)created it will be under the new storage layout
+ return true
elsif !from_exists
# Repository have been moved already.
return true
@@ -52,6 +52,16 @@ module Projects
move_repository(new_disk_path, old_disk_path)
move_repository("#{new_disk_path}.wiki", old_wiki_disk_path)
end
+
+ def try_to_set_repository_read_only!
+ # Mitigate any push operation to start during migration
+ unless project.set_repository_read_only!
+ migration_error = "Target repository '#{old_disk_path}' cannot be made read-only as there is a git transfer in progress"
+ logger.error migration_error
+
+ raise RepositoryInUseError, migration_error
+ end
+ end
end
end
end
diff --git a/app/services/projects/hashed_storage/migrate_attachments_service.rb b/app/services/projects/hashed_storage/migrate_attachments_service.rb
index 03e0685d2cd..3d0b8f58612 100644
--- a/app/services/projects/hashed_storage/migrate_attachments_service.rb
+++ b/app/services/projects/hashed_storage/migrate_attachments_service.rb
@@ -2,62 +2,37 @@
module Projects
module HashedStorage
- AttachmentMigrationError = Class.new(StandardError)
-
- class MigrateAttachmentsService < BaseService
- attr_reader :logger, :old_disk_path, :new_disk_path
-
+ class MigrateAttachmentsService < BaseAttachmentService
def initialize(project, old_disk_path, logger: nil)
@project = project
@logger = logger || Rails.logger
@old_disk_path = old_disk_path
- @new_disk_path = project.disk_path
@skipped = false
end
def execute
origin = FileUploader.absolute_base_dir(project)
- # It's possible that old_disk_path does not match project.disk_path. For example, that happens when we rename a project
+ # It's possible that old_disk_path does not match project.disk_path.
+ # For example, that happens when we rename a project
origin.sub!(/#{Regexp.escape(project.full_path)}\z/, old_disk_path)
project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:attachments]
target = FileUploader.absolute_base_dir(project)
- result = move_folder!(origin, target)
- project.save!
-
- if result && block_given?
- yield
- end
-
- result
- end
-
- def skipped?
- @skipped
- end
+ @new_disk_path = project.disk_path
- private
+ result = move_folder!(origin, target)
- def move_folder!(old_path, new_path)
- unless File.directory?(old_path)
- logger.info("Skipped attachments migration from '#{old_path}' to '#{new_path}', source path doesn't exist or is not a directory (PROJECT_ID=#{project.id})")
- @skipped = true
- return true
- end
+ if result
+ project.save!(validate: false)
- if File.exist?(new_path)
- logger.error("Cannot migrate attachments from '#{old_path}' to '#{new_path}', target path already exist (PROJECT_ID=#{project.id})")
- raise AttachmentMigrationError, "Target path '#{new_path}' already exist"
+ yield if block_given?
+ else
+ # Rollback changes
+ project.rollback!
end
- # Create hashed storage base path folder
- FileUtils.mkdir_p(File.dirname(new_path))
-
- FileUtils.mv(old_path, new_path)
- logger.info("Migrated project attachments from '#{old_path}' to '#{new_path}' (PROJECT_ID=#{project.id})")
-
- true
+ result
end
end
end
diff --git a/app/services/projects/hashed_storage/migrate_repository_service.rb b/app/services/projects/hashed_storage/migrate_repository_service.rb
index 9c672283c7e..e8393128d58 100644
--- a/app/services/projects/hashed_storage/migrate_repository_service.rb
+++ b/app/services/projects/hashed_storage/migrate_repository_service.rb
@@ -15,7 +15,7 @@ module Projects
result = move_repository(old_disk_path, new_disk_path)
if move_wiki
- result &&= move_repository("#{old_wiki_disk_path}", "#{new_disk_path}.wiki")
+ result &&= move_repository(old_wiki_disk_path, "#{new_disk_path}.wiki")
end
if result
@@ -27,7 +27,7 @@ module Projects
end
project.repository_read_only = false
- project.save!
+ project.save!(validate: false)
if result && block_given?
yield
@@ -35,18 +35,6 @@ module Projects
result
end
-
- private
-
- def try_to_set_repository_read_only!
- # Mitigate any push operation to start during migration
- unless project.set_repository_read_only!
- migration_error = "Target repository '#{old_disk_path}' cannot be made read-only as there is a git transfer in progress"
- logger.error migration_error
-
- raise RepositoryMigrationError, migration_error
- end
- end
end
end
end
diff --git a/app/services/projects/hashed_storage/rollback_attachments_service.rb b/app/services/projects/hashed_storage/rollback_attachments_service.rb
new file mode 100644
index 00000000000..5c6b92f965c
--- /dev/null
+++ b/app/services/projects/hashed_storage/rollback_attachments_service.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Projects
+ module HashedStorage
+ class RollbackAttachmentsService < BaseAttachmentService
+ def initialize(project, logger: nil)
+ @project = project
+ @logger = logger || Rails.logger
+ @old_disk_path = project.disk_path
+ end
+
+ def execute
+ origin = FileUploader.absolute_base_dir(project)
+ project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository]
+ target = FileUploader.absolute_base_dir(project)
+
+ @new_disk_path = FileUploader.base_dir(project)
+
+ result = move_folder!(origin, target)
+
+ if result
+ project.save!(validate: false)
+
+ yield if block_given?
+ else
+ # Rollback changes
+ project.rollback!
+ end
+
+ result
+ end
+ end
+ end
+end
diff --git a/app/services/projects/hashed_storage/rollback_repository_service.rb b/app/services/projects/hashed_storage/rollback_repository_service.rb
new file mode 100644
index 00000000000..67733f4770b
--- /dev/null
+++ b/app/services/projects/hashed_storage/rollback_repository_service.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Projects
+ module HashedStorage
+ class RollbackRepositoryService < BaseRepositoryService
+ def execute
+ try_to_set_repository_read_only!
+
+ @old_storage_version = project.storage_version
+ project.storage_version = nil
+ project.ensure_storage_path_exists
+
+ @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
+
+ if result
+ project.write_repository_config
+ project.track_project_repository
+ else
+ rollback_folder_move
+ project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository]
+ end
+
+ project.repository_read_only = false
+ project.save!(validate: false)
+
+ if result && block_given?
+ yield
+ end
+
+ result
+ end
+ end
+ end
+end
diff --git a/app/services/projects/hashed_storage/rollback_service.rb b/app/services/projects/hashed_storage/rollback_service.rb
new file mode 100644
index 00000000000..25767f5de5e
--- /dev/null
+++ b/app/services/projects/hashed_storage/rollback_service.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Projects
+ module HashedStorage
+ class RollbackService < BaseService
+ attr_reader :logger, :old_disk_path
+
+ def initialize(project, old_disk_path, logger: nil)
+ @project = project
+ @old_disk_path = old_disk_path
+ @logger = logger || Rails.logger
+ end
+
+ def execute
+ # Rollback attachments from Hashed Storage to Legacy
+ if project.hashed_storage?(:attachments)
+ return false unless rollback_attachments
+ end
+
+ # Rollback repository from Hashed Storage to Legacy
+ if project.hashed_storage?(:repository)
+ rollback_repository
+ end
+ end
+
+ private
+
+ def rollback_attachments
+ HashedStorage::RollbackAttachmentsService.new(project, logger: logger).execute
+ end
+
+ def rollback_repository
+ HashedStorage::RollbackRepositoryService.new(project, old_disk_path, logger: logger).execute
+ end
+ end
+ end
+end
diff --git a/app/services/projects/housekeeping_service.rb b/app/services/projects/housekeeping_service.rb
index 2f6dc4207dd..9428575591e 100644
--- a/app/services/projects/housekeeping_service.rb
+++ b/app/services/projects/housekeeping_service.rb
@@ -11,6 +11,7 @@ module Projects
class HousekeepingService < BaseService
# Timeout set to 24h
LEASE_TIMEOUT = 86400
+ PACK_REFS_PERIOD = 6
class LeaseTaken < StandardError
def to_s
@@ -18,8 +19,9 @@ module Projects
end
end
- def initialize(project)
+ def initialize(project, task = nil)
@project = project
+ @task = task
end
def execute
@@ -69,17 +71,21 @@ module Projects
end
def task
+ return @task if @task
+
if pushes_since_gc % gc_period == 0
:gc
elsif pushes_since_gc % full_repack_period == 0
:full_repack
- else
+ elsif pushes_since_gc % repack_period == 0
:incremental_repack
+ else
+ :pack_refs
end
end
def period_match?
- [gc_period, full_repack_period, repack_period].any? { |period| pushes_since_gc % period == 0 }
+ [gc_period, full_repack_period, repack_period, PACK_REFS_PERIOD].any? { |period| pushes_since_gc % period == 0 }
end
def housekeeping_enabled?
diff --git a/app/services/projects/import_error_filter.rb b/app/services/projects/import_error_filter.rb
index a0fc5149bb4..737b794484d 100644
--- a/app/services/projects/import_error_filter.rb
+++ b/app/services/projects/import_error_filter.rb
@@ -4,7 +4,7 @@ module Projects
# Used by project imports, it removes any potential paths
# included in an error message that could be stored in the DB
class ImportErrorFilter
- ERROR_MESSAGE_FILTER = /[^\s]*#{File::SEPARATOR}[^\s]*(?=(\s|\z))/
+ ERROR_MESSAGE_FILTER = /[^\s]*#{File::SEPARATOR}[^\s]*(?=(\s|\z))/.freeze
FILTER_MESSAGE = '[FILTERED]'
def self.filter_message(message)
diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb
index 7214e9efaf6..073c14040ce 100644
--- a/app/services/projects/import_service.rb
+++ b/app/services/projects/import_service.rb
@@ -27,13 +27,13 @@ module Projects
rescue Gitlab::UrlBlocker::BlockedUrlError => e
Gitlab::Sentry.track_acceptable_exception(e, extra: { project_path: project.full_path, importer: project.import_type })
- error("Error importing repository #{project.safe_import_url} into #{project.full_path} - #{e.message}")
+ 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 })
- error("Error importing repository #{project.safe_import_url} into #{project.full_path} - #{message}")
+ 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
private
@@ -43,7 +43,7 @@ module Projects
begin
Gitlab::UrlBlocker.validate!(project.import_url, ports: Project::VALID_IMPORT_PORTS)
rescue Gitlab::UrlBlocker::BlockedUrlError => e
- raise e, "Blocked import URL: #{e.message}"
+ raise e, s_("ImportProjects|Blocked import URL: %{message}") % { message: e.message }
end
end
@@ -61,7 +61,7 @@ module Projects
def create_repository
unless project.create_repository
- raise Error, 'The repository could not be created.'
+ raise Error, s_('ImportProjects|The repository could not be created.')
end
end
@@ -94,16 +94,13 @@ module Projects
return unless project.lfs_enabled?
- lfs_objects_to_download = Projects::LfsPointers::LfsImportService.new(project).execute
+ result = Projects::LfsPointers::LfsImportService.new(project).execute
- lfs_objects_to_download.each do |lfs_download_object|
- Projects::LfsPointers::LfsDownloadService.new(project, lfs_download_object)
- .execute
+ if result[:status] == :error
+ # To avoid aborting the importing process, we silently fail
+ # if any exception raises.
+ Gitlab::AppLogger.error("The Lfs import process failed. #{result[:message]}")
end
- rescue => e
- # Right now, to avoid aborting the importing process, we silently fail
- # if any exception raises.
- Rails.logger.error("The Lfs import process failed. #{e.message}")
end
def import_data
@@ -112,7 +109,7 @@ module Projects
project.repository.expire_content_cache unless project.gitlab_project_import?
unless importer.execute
- raise Error, 'The remote data could not be imported.'
+ raise Error, s_('ImportProjects|The remote data could not be imported.')
end
end
diff --git a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb
index 7998976b00a..9b72480d18b 100644
--- a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb
@@ -21,9 +21,9 @@ module Projects
# This method accepts two parameters:
# - oids: hash of oids to query. The structure is { lfs_file_oid => lfs_file_size }
#
- # Returns a hash with the structure { lfs_file_oids => download_link }
+ # Returns an array of LfsDownloadObject
def execute(oids)
- return {} unless project&.lfs_enabled? && remote_uri && oids.present?
+ return [] unless project&.lfs_enabled? && remote_uri && oids.present?
get_download_links(oids)
end
@@ -37,22 +37,30 @@ module Projects
raise DownloadLinksError, response.message unless response.success?
- parse_response_links(response['objects'])
+ # Since the LFS Batch API may return a Content-Ttpe of
+ # application/vnd.git-lfs+json
+ # (https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md#requests),
+ # HTTParty does not know this is actually JSON.
+ data = JSON.parse(response.body)
+
+ raise DownloadLinksError, "LFS Batch API did return any objects" unless data.is_a?(Hash) && data.key?('objects')
+
+ parse_response_links(data['objects'])
+ rescue JSON::ParserError
+ raise DownloadLinksError, "LFS Batch API response is not JSON"
end
def parse_response_links(objects_response)
objects_response.each_with_object([]) do |entry, link_list|
- begin
- link = entry.dig('actions', DOWNLOAD_ACTION, 'href')
+ link = entry.dig('actions', DOWNLOAD_ACTION, 'href')
- raise DownloadLinkNotFound unless link
+ raise DownloadLinkNotFound unless link
- link_list << LfsDownloadObject.new(oid: entry['oid'],
- size: entry['size'],
- link: add_credentials(link))
- rescue DownloadLinkNotFound, Addressable::URI::InvalidURIError
- log_error("Link for Lfs Object with oid #{entry['oid']} not found or invalid.")
- end
+ link_list << LfsDownloadObject.new(oid: entry['oid'],
+ size: entry['size'],
+ link: add_credentials(link))
+ rescue DownloadLinkNotFound, Addressable::URI::InvalidURIError
+ log_error("Link for Lfs Object with oid #{entry['oid']} not found or invalid.")
end
end
diff --git a/app/services/projects/lfs_pointers/lfs_download_service.rb b/app/services/projects/lfs_pointers/lfs_download_service.rb
index 398f00a598d..a009f479d5d 100644
--- a/app/services/projects/lfs_pointers/lfs_download_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_download_service.rb
@@ -75,17 +75,15 @@ module Projects
create_tmp_storage_dir
File.open(tmp_filename, 'wb') do |file|
- begin
- yield file
- rescue StandardError => e
- # If the lfs file is successfully downloaded it will be removed
- # when it is added to the project's lfs files.
- # Nevertheless if any excetion raises the file would remain
- # in the file system. Here we ensure to remove it
- File.unlink(file) if File.exist?(file)
-
- raise e
- end
+ yield file
+ rescue StandardError => e
+ # If the lfs file is successfully downloaded it will be removed
+ # when it is added to the project's lfs files.
+ # Nevertheless if any excetion raises the file would remain
+ # in the file system. Here we ensure to remove it
+ File.unlink(file) if File.exist?(file)
+
+ raise e
end
end
diff --git a/app/services/projects/lfs_pointers/lfs_import_service.rb b/app/services/projects/lfs_pointers/lfs_import_service.rb
index 9215fa0a7bf..2afcce7099b 100644
--- a/app/services/projects/lfs_pointers/lfs_import_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_import_service.rb
@@ -1,95 +1,23 @@
# frozen_string_literal: true
-# This service manages the whole worflow of discovering the Lfs files in a
-# repository, linking them to the project and downloading (and linking) the non
-# existent ones.
+# This service is responsible of managing the retrieval of the lfs objects,
+# and call the service LfsDownloadService, which performs the download
+# for each of the retrieved lfs objects
module Projects
module LfsPointers
class LfsImportService < BaseService
- include Gitlab::Utils::StrongMemoize
-
- HEAD_REV = 'HEAD'.freeze
- LFS_ENDPOINT_PATTERN = /^\t?url\s*=\s*(.+)$/.freeze
- LFS_BATCH_API_ENDPOINT = '/info/lfs/objects/batch'.freeze
-
- LfsImportError = Class.new(StandardError)
-
def execute
- return {} unless project&.lfs_enabled?
+ return success unless project&.lfs_enabled?
- if external_lfs_endpoint?
- # If the endpoint host is different from the import_url it means
- # that the repo is using a third party service for storing the LFS files.
- # In this case, we have to disable lfs in the project
- disable_lfs!
+ lfs_objects_to_download = LfsObjectDownloadListService.new(project).execute
- return {}
+ lfs_objects_to_download.each do |lfs_download_object|
+ LfsDownloadService.new(project, lfs_download_object).execute
end
- get_download_links
- rescue LfsDownloadLinkListService::DownloadLinksError => e
- raise LfsImportError, "The LFS objects download list couldn't be imported. Error: #{e.message}"
- end
-
- private
-
- def external_lfs_endpoint?
- lfsconfig_endpoint_uri && lfsconfig_endpoint_uri.host != import_uri.host
- end
-
- def disable_lfs!
- project.update(lfs_enabled: false)
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def get_download_links
- existent_lfs = LfsListService.new(project).execute
- linked_oids = LfsLinkService.new(project).execute(existent_lfs.keys)
-
- # Retrieving those oids not linked and which we need to download
- not_linked_lfs = existent_lfs.except(*linked_oids)
-
- LfsDownloadLinkListService.new(project, remote_uri: current_endpoint_uri).execute(not_linked_lfs)
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def lfsconfig_endpoint_uri
- strong_memoize(:lfsconfig_endpoint_uri) do
- # Retrieveing the blob data from the .lfsconfig file
- data = project.repository.lfsconfig_for(HEAD_REV)
- # Parsing the data to retrieve the url
- parsed_data = data&.match(LFS_ENDPOINT_PATTERN)
-
- if parsed_data
- URI.parse(parsed_data[1]).tap do |endpoint|
- endpoint.user ||= import_uri.user
- endpoint.password ||= import_uri.password
- end
- end
- end
- rescue URI::InvalidURIError
- raise LfsImportError, 'Invalid URL in .lfsconfig file'
- end
-
- def import_uri
- @import_uri ||= URI.parse(project.import_url)
- rescue URI::InvalidURIError
- raise LfsImportError, 'Invalid project import URL'
- end
-
- def current_endpoint_uri
- (lfsconfig_endpoint_uri || default_endpoint_uri)
- end
-
- # The import url must end with '.git' here we ensure it is
- def default_endpoint_uri
- @default_endpoint_uri ||= begin
- import_uri.dup.tap do |uri|
- path = uri.path.gsub(%r(/$), '')
- path += '.git' unless path.ends_with?('.git')
- uri.path = path + LFS_BATCH_API_ENDPOINT
- end
- end
+ success
+ rescue => e
+ error(e.message)
end
end
end
diff --git a/app/services/projects/lfs_pointers/lfs_link_service.rb b/app/services/projects/lfs_pointers/lfs_link_service.rb
index 8401f3d1d89..e3c956250f0 100644
--- a/app/services/projects/lfs_pointers/lfs_link_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_link_service.rb
@@ -6,9 +6,9 @@ module Projects
class LfsLinkService < BaseService
# Accept an array of oids to link
#
- # Returns a hash with the same structure with oids linked
+ # Returns an array with the oid of the existent lfs objects
def execute(oids)
- return {} unless project&.lfs_enabled?
+ return [] unless project&.lfs_enabled?
# Search and link existing LFS Object
link_existing_lfs_objects(oids)
diff --git a/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb b/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb
new file mode 100644
index 00000000000..5ba0f50f2ff
--- /dev/null
+++ b/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+# This service manages the whole worflow of discovering the Lfs files in a
+# repository, linking them to the project and downloading (and linking) the non
+# existent ones.
+module Projects
+ module LfsPointers
+ class LfsObjectDownloadListService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ HEAD_REV = 'HEAD'.freeze
+ LFS_ENDPOINT_PATTERN = /^\t?url\s*=\s*(.+)$/.freeze
+ LFS_BATCH_API_ENDPOINT = '/info/lfs/objects/batch'.freeze
+
+ LfsObjectDownloadListError = Class.new(StandardError)
+
+ def execute
+ return [] unless project&.lfs_enabled?
+
+ if external_lfs_endpoint?
+ # If the endpoint host is different from the import_url it means
+ # that the repo is using a third party service for storing the LFS files.
+ # In this case, we have to disable lfs in the project
+ disable_lfs!
+
+ return []
+ end
+
+ # Getting all Lfs pointers already in the database and linking them to the project
+ linked_oids = LfsLinkService.new(project).execute(lfs_pointers_in_repository.keys)
+ # Retrieving those oids not present in the database which we need to download
+ missing_oids = lfs_pointers_in_repository.except(*linked_oids) # rubocop: disable CodeReuse/ActiveRecord
+ # Downloading the required information and gathering it inside a LfsDownloadObject for each oid
+ LfsDownloadLinkListService.new(project, remote_uri: current_endpoint_uri).execute(missing_oids)
+ rescue LfsDownloadLinkListService::DownloadLinksError => e
+ raise LfsObjectDownloadListError, "The LFS objects download list couldn't be imported. Error: #{e.message}"
+ end
+
+ private
+
+ def external_lfs_endpoint?
+ lfsconfig_endpoint_uri && lfsconfig_endpoint_uri.host != import_uri.host
+ end
+
+ def disable_lfs!
+ unless project.update(lfs_enabled: false)
+ raise LfsDownloadLinkListService::DownloadLinksError, "Invalid project state"
+ end
+ end
+
+ # Retrieves all lfs pointers in the repository
+ def lfs_pointers_in_repository
+ @lfs_pointers_in_repository ||= LfsListService.new(project).execute
+ end
+
+ def lfsconfig_endpoint_uri
+ strong_memoize(:lfsconfig_endpoint_uri) do
+ # Retrieveing the blob data from the .lfsconfig file
+ data = project.repository.lfsconfig_for(HEAD_REV)
+ # Parsing the data to retrieve the url
+ parsed_data = data&.match(LFS_ENDPOINT_PATTERN)
+
+ if parsed_data
+ URI.parse(parsed_data[1]).tap do |endpoint|
+ endpoint.user ||= import_uri.user
+ endpoint.password ||= import_uri.password
+ end
+ end
+ end
+ rescue URI::InvalidURIError
+ raise LfsObjectDownloadListError, 'Invalid URL in .lfsconfig file'
+ end
+
+ def import_uri
+ @import_uri ||= URI.parse(project.import_url)
+ rescue URI::InvalidURIError
+ raise LfsObjectDownloadListError, 'Invalid project import URL'
+ end
+
+ def current_endpoint_uri
+ (lfsconfig_endpoint_uri || default_endpoint_uri)
+ end
+
+ # The import url must end with '.git' here we ensure it is
+ def default_endpoint_uri
+ @default_endpoint_uri ||= begin
+ import_uri.dup.tap do |uri|
+ path = uri.path.gsub(%r(/$), '')
+ path += '.git' unless path.ends_with?('.git')
+ uri.path = path + LFS_BATCH_API_ENDPOINT
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/projects/move_project_group_links_service.rb b/app/services/projects/move_project_group_links_service.rb
index 36afcd0c503..cf4b291c761 100644
--- a/app/services/projects/move_project_group_links_service.rb
+++ b/app/services/projects/move_project_group_links_service.rb
@@ -26,7 +26,7 @@ module Projects
# Remove remaining project group links from source_project
def remove_remaining_project_group_links
- source_project.reload.project_group_links.destroy_all # rubocop: disable DestroyAll
+ source_project.reset.project_group_links.destroy_all # rubocop: disable DestroyAll
end
def group_links_in_target_project
diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb
index abd6d8de750..48eddb0e8d0 100644
--- a/app/services/projects/operations/update_service.rb
+++ b/app/services/projects/operations/update_service.rb
@@ -12,7 +12,37 @@ module Projects
private
def project_update_params
- params.slice(:error_tracking_setting_attributes)
+ error_tracking_params.merge(metrics_setting_params)
+ end
+
+ def metrics_setting_params
+ attribs = params[:metrics_setting_attributes]
+ return {} unless attribs
+
+ destroy = attribs[:external_dashboard_url].blank?
+
+ { metrics_setting_attributes: attribs.merge(_destroy: destroy) }
+ end
+
+ def error_tracking_params
+ settings = params[:error_tracking_setting_attributes]
+ return {} if settings.blank?
+
+ api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from(
+ api_host: settings[:api_host],
+ project_slug: settings.dig(:project, :slug),
+ organization_slug: settings.dig(:project, :organization_slug)
+ )
+
+ {
+ error_tracking_setting_attributes: {
+ api_url: api_url,
+ token: settings[:token],
+ enabled: settings[:enabled],
+ project_name: settings.dig(:project, :name),
+ organization_name: settings.dig(:project, :organization_name)
+ }
+ }
end
end
end
diff --git a/app/services/projects/propagate_service_template.rb b/app/services/projects/propagate_service_template.rb
index 633a263af7b..a2f36d2bd1b 100644
--- a/app/services/projects/propagate_service_template.rb
+++ b/app/services/projects/propagate_service_template.rb
@@ -80,7 +80,7 @@ module Projects
value = value.is_a?(Hash) ? value.to_json : value
service_hash[ActiveRecord::Base.connection.quote_column_name(key)] =
- ActiveRecord::Base.sanitize(value)
+ ActiveRecord::Base.connection.quote(value)
end
end
end
diff --git a/app/services/projects/repository_languages_service.rb b/app/services/projects/repository_languages_service.rb
new file mode 100644
index 00000000000..05f43c2264b
--- /dev/null
+++ b/app/services/projects/repository_languages_service.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Projects
+ class RepositoryLanguagesService < BaseService
+ def execute
+ perform_language_detection unless project.detected_repository_languages?
+ persisted_repository_languages
+ end
+
+ private
+
+ def perform_language_detection
+ if persisted_repository_languages.blank?
+ ::DetectRepositoryLanguagesWorker.perform_async(project.id)
+ else
+ project.update_column(:detected_repository_languages, true)
+ end
+ end
+
+ def persisted_repository_languages
+ project.repository_languages
+ end
+ end
+end
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index 5da1e39a1fb..233dcf37e35 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -17,11 +17,11 @@ module Projects
@new_namespace = new_namespace
if @new_namespace.blank?
- raise TransferError, 'Please select a new namespace for your project.'
+ raise TransferError, s_('TransferProject|Please select a new namespace for your project.')
end
unless allowed_transfer?(current_user, project)
- raise TransferError, 'Transfer failed, please contact an admin.'
+ raise TransferError, s_('TransferProject|Transfer failed, please contact an admin.')
end
transfer(project)
@@ -30,7 +30,7 @@ module Projects
true
rescue Projects::TransferService::TransferError => ex
- project.reload
+ project.reset
project.errors.add(:new_namespace, ex.message)
false
end
@@ -45,16 +45,15 @@ module Projects
@old_namespace = project.namespace
if Project.where(namespace_id: @new_namespace.try(:id)).where('path = ? or name = ?', project.path, project.name).exists?
- raise TransferError.new("Project with same name or path in target namespace already exists")
+ raise TransferError.new(s_("TransferProject|Project with same name or path in target namespace already exists"))
end
if project.has_container_registry_tags?
# We currently don't support renaming repository if it contains tags in container registry
- raise TransferError.new('Project cannot be transferred, because tags are present in its container registry')
+ raise TransferError.new(s_('TransferProject|Project cannot be transferred, because tags are present in its container registry'))
end
attempt_transfer_transaction
- configure_group_clusters_for_project
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -122,7 +121,7 @@ module Projects
def rollback_side_effects
rollback_folder_move
- project.reload
+ project.reset
update_namespace_and_visibility(@old_namespace)
update_repository_configuration(@old_path)
end
@@ -145,7 +144,7 @@ module Projects
# Move main repository
unless move_repo_folder(@old_path, @new_path)
- raise TransferError.new("Cannot move project")
+ raise TransferError.new(s_("TransferProject|Cannot move project"))
end
# Disk path is changed; we need to ensure we reload it
@@ -164,9 +163,5 @@ module Projects
@new_namespace.full_path
)
end
-
- def configure_group_clusters_for_project
- ClusterProjectConfigureWorker.perform_async(project.id)
- end
end
end
diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb
index 6856009b395..2bc04470342 100644
--- a/app/services/projects/update_service.rb
+++ b/app/services/projects/update_service.rb
@@ -3,6 +3,7 @@
module Projects
class UpdateService < BaseService
include UpdateVisibilityLevel
+ include ValidatesClassificationLabel
ValidationError = Class.new(StandardError)
@@ -14,6 +15,8 @@ module Projects
yield if block_given?
+ validate_classification_label(project, :external_authorization_classification_label)
+
# If the block added errors, don't try to save the project
return update_failed! if project.errors.any?
@@ -39,15 +42,15 @@ module Projects
def validate!
unless valid_visibility_level_change?(project, params[:visibility_level])
- raise ValidationError.new('New visibility level not allowed!')
+ raise ValidationError.new(s_('UpdateProject|New visibility level not allowed!'))
end
if renaming_project_with_container_registry_tags?
- raise ValidationError.new('Cannot rename project because it contains container registry tags!')
+ raise ValidationError.new(s_('UpdateProject|Cannot rename project because it contains container registry tags!'))
end
if changing_default_branch?
- raise ValidationError.new("Could not set the default branch") unless project.change_head(params[:default_branch])
+ raise ValidationError.new(s_("UpdateProject|Could not set the default branch")) unless project.change_head(params[:default_branch])
end
end
@@ -61,6 +64,7 @@ module Projects
if project.previous_changes.include?(:visibility_level) && 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)
elsif (project_changed_feature_keys & todos_features_changes).present?
TodosDestroyer::PrivateFeaturesWorker.perform_in(Todo::WAIT_FOR_DELETE, project.id)
@@ -76,10 +80,7 @@ module Projects
end
def after_rename_service(project)
- # The path slug the project was using, before the rename took place.
- path_before = project.previous_changes['path'].first
-
- AfterRenameService.new(project, path_before: path_before, full_path_before: project.full_path_was)
+ AfterRenameService.new(project, path_before: project.path_before_last_save, full_path_before: project.full_path_before_last_save)
end
def changing_pages_related_config?
@@ -88,7 +89,7 @@ module Projects
def update_failed!
model_errors = project.errors.full_messages.to_sentence
- error_message = model_errors.presence || 'Project could not be updated!'
+ error_message = model_errors.presence || s_('UpdateProject|Project could not be updated!')
error(error_message)
end
diff --git a/app/services/projects/update_statistics_service.rb b/app/services/projects/update_statistics_service.rb
new file mode 100644
index 00000000000..28677a398f3
--- /dev/null
+++ b/app/services/projects/update_statistics_service.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Projects
+ class UpdateStatisticsService < BaseService
+ def execute
+ return unless project
+
+ Rails.logger.info("Updating statistics for project #{project.id}")
+
+ project.statistics.refresh!(only: statistics.map(&:to_sym))
+ end
+
+ private
+
+ def statistics
+ params[:statistics]
+ end
+ end
+end
diff --git a/app/services/prometheus/proxy_service.rb b/app/services/prometheus/proxy_service.rb
new file mode 100644
index 00000000000..c5d2b84878b
--- /dev/null
+++ b/app/services/prometheus/proxy_service.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+module Prometheus
+ class ProxyService < BaseService
+ include ReactiveCaching
+ include Gitlab::Utils::StrongMemoize
+
+ self.reactive_cache_key = ->(service) { service.cache_key }
+ self.reactive_cache_lease_timeout = 30.seconds
+ self.reactive_cache_refresh_interval = 30.seconds
+ self.reactive_cache_lifetime = 1.minute
+ self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
+
+ attr_accessor :proxyable, :method, :path, :params
+
+ PROXY_SUPPORT = {
+ 'query' => {
+ method: ['GET'],
+ params: %w(query time timeout)
+ },
+ 'query_range' => {
+ method: ['GET'],
+ params: %w(query start end step timeout)
+ }
+ }.freeze
+
+ def self.from_cache(proxyable_class_name, proxyable_id, method, path, params)
+ proxyable_class = begin
+ proxyable_class_name.constantize
+ rescue NameError
+ nil
+ end
+ return unless proxyable_class
+
+ proxyable = proxyable_class.find(proxyable_id)
+
+ new(proxyable, method, path, params)
+ end
+
+ # proxyable can be any model which responds to .prometheus_adapter
+ # like Environment.
+ def initialize(proxyable, method, path, params)
+ @proxyable = proxyable
+ @path = path
+
+ # Convert ActionController::Parameters to hash because reactive_cache_worker
+ # does not play nice with ActionController::Parameters.
+ @params = filter_params(params, path).to_hash
+
+ @method = method
+ end
+
+ def id
+ nil
+ end
+
+ def execute
+ return cannot_proxy_response unless can_proxy?
+ return no_prometheus_response unless can_query?
+
+ with_reactive_cache(*cache_key) do |result|
+ result
+ end
+ end
+
+ def calculate_reactive_cache(proxyable_class_name, proxyable_id, method, path, params)
+ return no_prometheus_response unless can_query?
+
+ response = prometheus_client_wrapper.proxy(path, params)
+
+ success(http_status: response.code, body: response.body)
+ rescue Gitlab::PrometheusClient::Error => err
+ service_unavailable_response(err)
+ end
+
+ def cache_key
+ [@proxyable.class.name, @proxyable.id, @method, @path, @params]
+ end
+
+ private
+
+ def service_unavailable_response(exception)
+ error(exception.message, :service_unavailable)
+ end
+
+ def no_prometheus_response
+ error('No prometheus server found', :service_unavailable)
+ end
+
+ def cannot_proxy_response
+ error('Proxy support for this API is not available currently')
+ end
+
+ def prometheus_adapter
+ strong_memoize(:prometheus_adapter) do
+ @proxyable.prometheus_adapter
+ end
+ end
+
+ def prometheus_client_wrapper
+ prometheus_adapter&.prometheus_client_wrapper
+ end
+
+ def can_query?
+ prometheus_adapter&.can_query?
+ end
+
+ def filter_params(params, path)
+ params.slice(*PROXY_SUPPORT.dig(path, :params))
+ end
+
+ def can_proxy?
+ PROXY_SUPPORT.dig(@path, :method)&.include?(@method)
+ end
+ end
+end
diff --git a/app/services/push_event_payload_service.rb b/app/services/push_event_payload_service.rb
index bb1259787af..fe366ac225b 100644
--- a/app/services/push_event_payload_service.rb
+++ b/app/services/push_event_payload_service.rb
@@ -46,7 +46,7 @@ class PushEventPayloadService
def commit_title
commit = @push_data.fetch(:commits).last
- return nil unless commit && commit[:message]
+ return unless commit && commit[:message]
raw_msg = commit[:message]
diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb
index 5c58caee8cd..8ff73522e5f 100644
--- a/app/services/quick_actions/interpret_service.rb
+++ b/app/services/quick_actions/interpret_service.rb
@@ -4,16 +4,24 @@ module QuickActions
class InterpretService < BaseService
include Gitlab::Utils::StrongMemoize
include Gitlab::QuickActions::Dsl
+ include Gitlab::QuickActions::IssueActions
+ include Gitlab::QuickActions::IssueAndMergeRequestActions
+ include Gitlab::QuickActions::IssuableActions
+ include Gitlab::QuickActions::MergeRequestActions
+ include Gitlab::QuickActions::CommitActions
+ include Gitlab::QuickActions::CommonActions
- attr_reader :issuable
+ attr_reader :quick_action_target
- SHRUG = '¯\\_(ツ)_/¯'.freeze
- TABLEFLIP = '(╯°□°)╯︵ ┻━┻'.freeze
+ # Counts how many commands have been executed.
+ # Used to display relevant feedback on UI when a note
+ # with only commands has been processed.
+ attr_accessor :commands_executed_count
- # Takes an issuable and returns an array of all the available commands
+ # Takes an quick_action_target and returns an array of all the available commands
# represented with .to_h
- def available_commands(issuable)
- @issuable = issuable
+ def available_commands(quick_action_target)
+ @quick_action_target = quick_action_target
self.class.command_definitions.map do |definition|
next unless definition.available?(self)
@@ -24,10 +32,10 @@ module QuickActions
# Takes a text and interprets the commands that are extracted from it.
# Returns the content without commands, and hash of changes to be applied to a record.
- def execute(content, issuable, only: nil)
+ def execute(content, quick_action_target, only: nil)
return [content, {}] unless current_user.can?(:use_quick_actions)
- @issuable = issuable
+ @quick_action_target = quick_action_target
@updates = {}
content, commands = extractor.extract_commands(content, only: only)
@@ -38,10 +46,10 @@ module QuickActions
# Takes a text and interprets the commands that are extracted from it.
# Returns the content without commands, and array of changes explained.
- def explain(content, issuable)
+ def explain(content, quick_action_target)
return [content, []] unless current_user.can?(:use_quick_actions)
- @issuable = issuable
+ @quick_action_target = quick_action_target
content, commands = extractor.extract_commands(content)
commands = explain_commands(commands)
@@ -54,598 +62,6 @@ module QuickActions
Gitlab::QuickActions::Extractor.new(self.class.command_definitions)
end
- desc do
- "Close this #{issuable.to_ability_name.humanize(capitalize: false)}"
- end
- explanation do
- "Closes this #{issuable.to_ability_name.humanize(capitalize: false)}."
- end
- condition do
- issuable.is_a?(Issuable) &&
- issuable.persisted? &&
- issuable.open? &&
- current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
- end
- command :close do
- @updates[:state_event] = 'close'
- end
-
- desc do
- "Reopen this #{issuable.to_ability_name.humanize(capitalize: false)}"
- end
- explanation do
- "Reopens this #{issuable.to_ability_name.humanize(capitalize: false)}."
- end
- condition do
- issuable.is_a?(Issuable) &&
- issuable.persisted? &&
- issuable.closed? &&
- current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
- end
- command :reopen do
- @updates[:state_event] = 'reopen'
- end
-
- desc 'Merge (when the pipeline succeeds)'
- explanation 'Merges this merge request when the pipeline succeeds.'
- condition do
- last_diff_sha = params && params[:merge_request_diff_head_sha]
- issuable.is_a?(MergeRequest) &&
- issuable.persisted? &&
- issuable.mergeable_with_quick_action?(current_user, autocomplete_precheck: !last_diff_sha, last_diff_sha: last_diff_sha)
- end
- command :merge do
- @updates[:merge] = params[:merge_request_diff_head_sha]
- end
-
- desc 'Change title'
- explanation do |title_param|
- "Changes the title to \"#{title_param}\"."
- end
- params '<New title>'
- condition do
- issuable.persisted? &&
- current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
- end
- command :title do |title_param|
- @updates[:title] = title_param
- end
-
- desc 'Assign'
- # rubocop: disable CodeReuse/ActiveRecord
- explanation do |users|
- users = issuable.allows_multiple_assignees? ? users : users.take(1)
- "Assigns #{users.map(&:to_reference).to_sentence}."
- end
- # rubocop: enable CodeReuse/ActiveRecord
- params do
- issuable.allows_multiple_assignees? ? '@user1 @user2' : '@user'
- end
- condition do
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- parse_params do |assignee_param|
- extract_users(assignee_param)
- end
- command :assign do |users|
- next if users.empty?
-
- if issuable.allows_multiple_assignees?
- @updates[:assignee_ids] ||= issuable.assignees.map(&:id)
- @updates[:assignee_ids] += users.map(&:id)
- else
- @updates[:assignee_ids] = [users.first.id]
- end
- end
-
- desc do
- if issuable.allows_multiple_assignees?
- 'Remove all or specific assignee(s)'
- else
- 'Remove assignee'
- end
- end
- explanation do |users = nil|
- assignees = issuable.assignees
- assignees &= users if users.present? && issuable.allows_multiple_assignees?
- "Removes #{'assignee'.pluralize(assignees.size)} #{assignees.map(&:to_reference).to_sentence}."
- end
- params do
- issuable.allows_multiple_assignees? ? '@user1 @user2' : ''
- end
- condition do
- issuable.is_a?(Issuable) &&
- issuable.persisted? &&
- issuable.assignees.any? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- parse_params do |unassign_param|
- # When multiple users are assigned, all will be unassigned if multiple assignees are no longer allowed
- extract_users(unassign_param) if issuable.allows_multiple_assignees?
- end
- command :unassign do |users = nil|
- if issuable.allows_multiple_assignees? && users&.any?
- @updates[:assignee_ids] ||= issuable.assignees.map(&:id)
- @updates[:assignee_ids] -= users.map(&:id)
- else
- @updates[:assignee_ids] = []
- end
- end
-
- desc 'Set milestone'
- explanation do |milestone|
- "Sets the milestone to #{milestone.to_reference}." if milestone
- end
- params '%"milestone"'
- condition do
- current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
- find_milestones(project, state: 'active').any?
- end
- parse_params do |milestone_param|
- extract_references(milestone_param, :milestone).first ||
- find_milestones(project, title: milestone_param.strip).first
- end
- command :milestone do |milestone|
- @updates[:milestone_id] = milestone.id if milestone
- end
-
- desc 'Remove milestone'
- explanation do
- "Removes #{issuable.milestone.to_reference(format: :name)} milestone."
- end
- condition do
- issuable.is_a?(Issuable) &&
- issuable.persisted? &&
- issuable.milestone_id? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :remove_milestone do
- @updates[:milestone_id] = nil
- end
-
- desc 'Add label(s)'
- explanation do |labels_param|
- labels = find_label_references(labels_param)
-
- "Adds #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any?
- end
- params '~label1 ~"label 2"'
- condition do
- parent &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", parent) &&
- find_labels.any?
- end
- command :label do |labels_param|
- label_ids = find_label_ids(labels_param)
-
- if label_ids.any?
- @updates[:add_label_ids] ||= []
- @updates[:add_label_ids] += label_ids
-
- @updates[:add_label_ids].uniq!
- end
- end
-
- desc 'Remove all or specific label(s)'
- explanation do |labels_param = nil|
- if labels_param.present?
- labels = find_label_references(labels_param)
- "Removes #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any?
- else
- 'Removes all labels.'
- end
- end
- params '~label1 ~"label 2"'
- condition do
- issuable.is_a?(Issuable) &&
- issuable.persisted? &&
- issuable.labels.any? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", parent)
- end
- command :unlabel do |labels_param = nil|
- if labels_param.present?
- label_ids = find_label_ids(labels_param)
-
- if label_ids.any?
- @updates[:remove_label_ids] ||= []
- @updates[:remove_label_ids] += label_ids
-
- @updates[:remove_label_ids].uniq!
- end
- else
- @updates[:label_ids] = []
- end
- end
-
- desc 'Replace all label(s)'
- explanation do |labels_param|
- labels = find_label_references(labels_param)
- "Replaces all labels with #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any?
- end
- params '~label1 ~"label 2"'
- condition do
- issuable.is_a?(Issuable) &&
- issuable.persisted? &&
- issuable.labels.any? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :relabel do |labels_param|
- label_ids = find_label_ids(labels_param)
-
- if label_ids.any?
- @updates[:label_ids] ||= []
- @updates[:label_ids] += label_ids
-
- @updates[:label_ids].uniq!
- end
- end
-
- desc 'Copy labels and milestone from other issue or merge request'
- explanation do |source_issuable|
- "Copy labels and milestone from #{source_issuable.to_reference}."
- end
- params '#issue | !merge_request'
- condition do
- [MergeRequest, Issue].include?(issuable.class) &&
- current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
- end
- parse_params do |issuable_param|
- extract_references(issuable_param, :issue).first ||
- extract_references(issuable_param, :merge_request).first
- end
- command :copy_metadata do |source_issuable|
- if source_issuable.present? && source_issuable.project.id == issuable.project.id
- @updates[:add_label_ids] = source_issuable.labels.map(&:id)
- @updates[:milestone_id] = source_issuable.milestone.id if source_issuable.milestone
- end
- end
-
- desc 'Add a todo'
- explanation 'Adds a todo.'
- condition do
- issuable.is_a?(Issuable) &&
- issuable.persisted? &&
- !TodoService.new.todo_exist?(issuable, current_user)
- end
- command :todo do
- @updates[:todo_event] = 'add'
- end
-
- desc 'Mark todo as done'
- explanation 'Marks todo as done.'
- condition do
- issuable.persisted? &&
- TodoService.new.todo_exist?(issuable, current_user)
- end
- command :done do
- @updates[:todo_event] = 'done'
- end
-
- desc 'Subscribe'
- explanation do
- "Subscribes to this #{issuable.to_ability_name.humanize(capitalize: false)}."
- end
- condition do
- issuable.is_a?(Issuable) &&
- issuable.persisted? &&
- !issuable.subscribed?(current_user, project)
- end
- command :subscribe do
- @updates[:subscription_event] = 'subscribe'
- end
-
- desc 'Unsubscribe'
- explanation do
- "Unsubscribes from this #{issuable.to_ability_name.humanize(capitalize: false)}."
- end
- condition do
- issuable.is_a?(Issuable) &&
- issuable.persisted? &&
- issuable.subscribed?(current_user, project)
- end
- command :unsubscribe do
- @updates[:subscription_event] = 'unsubscribe'
- end
-
- desc 'Set due date'
- explanation do |due_date|
- "Sets the due date to #{due_date.to_s(:medium)}." if due_date
- end
- params '<in 2 days | this Friday | December 31st>'
- condition do
- issuable.respond_to?(:due_date) &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- parse_params do |due_date_param|
- Chronic.parse(due_date_param).try(:to_date)
- end
- command :due do |due_date|
- @updates[:due_date] = due_date if due_date
- end
-
- desc 'Remove due date'
- explanation 'Removes the due date.'
- condition do
- issuable.persisted? &&
- issuable.respond_to?(:due_date) &&
- issuable.due_date? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :remove_due_date do
- @updates[:due_date] = nil
- end
-
- desc 'Toggle the Work In Progress status'
- explanation do
- verb = issuable.work_in_progress? ? 'Unmarks' : 'Marks'
- noun = issuable.to_ability_name.humanize(capitalize: false)
- "#{verb} this #{noun} as Work In Progress."
- end
- condition do
- issuable.respond_to?(:work_in_progress?) &&
- # Allow it to mark as WIP on MR creation page _or_ through MR notes.
- (issuable.new_record? || current_user.can?(:"update_#{issuable.to_ability_name}", issuable))
- end
- command :wip do
- @updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip'
- end
-
- desc 'Toggle emoji award'
- explanation do |name|
- "Toggles :#{name}: emoji award." if name
- end
- params ':emoji:'
- condition do
- issuable.is_a?(Issuable) &&
- issuable.persisted?
- end
- parse_params do |emoji_param|
- match = emoji_param.match(Banzai::Filter::EmojiFilter.emoji_pattern)
- match[1] if match
- end
- command :award do |name|
- if name && issuable.user_can_award?(current_user)
- @updates[:emoji_award] = name
- end
- end
-
- desc 'Set time estimate'
- explanation do |time_estimate|
- time_estimate = Gitlab::TimeTrackingFormatter.output(time_estimate)
-
- "Sets time estimate to #{time_estimate}." if time_estimate
- end
- params '<1w 3d 2h 14m>'
- condition do
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- parse_params do |raw_duration|
- Gitlab::TimeTrackingFormatter.parse(raw_duration)
- end
- command :estimate do |time_estimate|
- if time_estimate
- @updates[:time_estimate] = time_estimate
- end
- end
-
- desc 'Add or subtract spent time'
- explanation do |time_spent, time_spent_date|
- if time_spent
- if time_spent > 0
- verb = 'Adds'
- value = time_spent
- else
- verb = 'Subtracts'
- value = -time_spent
- end
-
- "#{verb} #{Gitlab::TimeTrackingFormatter.output(value)} spent time."
- end
- end
- params '<time(1h30m | -1h30m)> <date(YYYY-MM-DD)>'
- condition do
- issuable.is_a?(TimeTrackable) &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
- end
- parse_params do |raw_time_date|
- Gitlab::QuickActions::SpendTimeAndDateSeparator.new(raw_time_date).execute
- end
- command :spend do |time_spent, time_spent_date|
- if time_spent
- @updates[:spend_time] = {
- duration: time_spent,
- user_id: current_user.id,
- spent_at: time_spent_date
- }
- end
- end
-
- desc 'Remove time estimate'
- explanation 'Removes time estimate.'
- condition do
- issuable.persisted? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :remove_estimate do
- @updates[:time_estimate] = 0
- end
-
- desc 'Remove spent time'
- explanation 'Removes spent time.'
- condition do
- issuable.persisted? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :remove_time_spent do
- @updates[:spend_time] = { duration: :reset, user_id: current_user.id }
- end
-
- desc "Append the comment with #{SHRUG}"
- params '<Comment>'
- substitution :shrug do |comment|
- "#{comment} #{SHRUG}"
- end
-
- desc "Append the comment with #{TABLEFLIP}"
- params '<Comment>'
- substitution :tableflip do |comment|
- "#{comment} #{TABLEFLIP}"
- end
-
- desc "Lock the discussion"
- explanation "Locks the discussion"
- condition do
- [MergeRequest, Issue].include?(issuable.class) &&
- issuable.persisted? &&
- !issuable.discussion_locked? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
- end
- command :lock do
- @updates[:discussion_locked] = true
- end
-
- desc "Unlock the discussion"
- explanation "Unlocks the discussion"
- condition do
- [MergeRequest, Issue].include?(issuable.class) &&
- issuable.persisted? &&
- issuable.discussion_locked? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
- end
- command :unlock do
- @updates[:discussion_locked] = false
- end
-
- # This is a dummy command, so that it appears in the autocomplete commands
- desc 'CC'
- params '@user'
- command :cc
-
- desc 'Set target branch'
- explanation do |branch_name|
- "Sets target branch to #{branch_name}."
- end
- params '<Local branch name>'
- condition do
- issuable.respond_to?(:target_branch) &&
- (current_user.can?(:"update_#{issuable.to_ability_name}", issuable) ||
- issuable.new_record?)
- end
- parse_params do |target_branch_param|
- target_branch_param.strip
- end
- command :target_branch do |branch_name|
- @updates[:target_branch] = branch_name if project.repository.branch_exists?(branch_name)
- end
-
- desc 'Move issue from one column of the board to another'
- explanation do |target_list_name|
- label = find_label_references(target_list_name).first
- "Moves issue to #{label} column in the board." if label
- end
- params '~"Target column"'
- condition do
- issuable.is_a?(Issue) &&
- current_user.can?(:"update_#{issuable.to_ability_name}", issuable) &&
- issuable.project.boards.count == 1
- end
- # rubocop: disable CodeReuse/ActiveRecord
- command :board_move do |target_list_name|
- label_ids = find_label_ids(target_list_name)
-
- if label_ids.size == 1
- label_id = label_ids.first
-
- # Ensure this label corresponds to a list on the board
- next unless Label.on_project_boards(issuable.project_id).where(id: label_id).exists?
-
- @updates[:remove_label_ids] =
- issuable.labels.on_project_boards(issuable.project_id).where.not(id: label_id).pluck(:id)
- @updates[:add_label_ids] = [label_id]
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- desc 'Mark this issue as a duplicate of another issue'
- explanation do |duplicate_reference|
- "Marks this issue as a duplicate of #{duplicate_reference}."
- end
- params '#issue'
- condition do
- issuable.is_a?(Issue) &&
- issuable.persisted? &&
- current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
- end
- command :duplicate do |duplicate_param|
- canonical_issue = extract_references(duplicate_param, :issue).first
-
- if canonical_issue.present?
- @updates[:canonical_issue_id] = canonical_issue.id
- end
- end
-
- desc 'Move this issue to another project.'
- explanation do |path_to_project|
- "Moves this issue to #{path_to_project}."
- end
- params 'path/to/project'
- condition do
- issuable.is_a?(Issue) &&
- issuable.persisted? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :move do |target_project_path|
- target_project = Project.find_by_full_path(target_project_path)
-
- if target_project.present?
- @updates[:target_project] = target_project
- end
- end
-
- desc 'Make issue confidential.'
- explanation do
- 'Makes this issue confidential'
- end
- condition do
- issuable.is_a?(Issue) && current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
- end
- command :confidential do
- @updates[:confidential] = true
- end
-
- desc 'Tag this commit.'
- explanation do |tag_name, message|
- with_message = %{ with "#{message}"} if message.present?
- "Tags this commit to #{tag_name}#{with_message}."
- end
- params 'v1.2.3 <message>'
- parse_params do |tag_name_and_message|
- tag_name_and_message.split(' ', 2)
- end
- condition do
- issuable.is_a?(Commit) && current_user.can?(:push_code, project)
- end
- command :tag do |tag_name, message|
- @updates[:tag_name] = tag_name
- @updates[:tag_message] = message
- end
-
- desc 'Create a merge request.'
- explanation do |branch_name = nil|
- branch_text = branch_name ? "branch '#{branch_name}'" : 'a branch'
- "Creates #{branch_text} and a merge request to resolve this issue"
- end
- params "<branch name>"
- condition do
- issuable.is_a?(Issue) && current_user.can?(:create_merge_request_in, project) && current_user.can?(:push_code, project)
- end
- command :create_merge_request do |branch_name = nil|
- @updates[:create_merge_request] = {
- branch_name: branch_name,
- issue_iid: issuable.iid
- }
- end
-
# rubocop: disable CodeReuse/ActiveRecord
def extract_users(params)
return [] if params.nil?
@@ -675,19 +91,32 @@ module QuickActions
def group
strong_memoize(:group) do
- issuable.group if issuable.respond_to?(:group)
+ quick_action_target.group if quick_action_target.respond_to?(:group)
end
end
def find_labels(labels_params = nil)
+ extract_references(labels_params, :label) | find_labels_by_name_no_tilde(labels_params)
+ end
+
+ def find_labels_by_name_no_tilde(labels_params)
+ return Label.none if label_with_tilde?(labels_params)
+
finder_params = { include_ancestor_groups: true }
finder_params[:project_id] = project.id if project
finder_params[:group_id] = group.id if group
- finder_params[:name] = labels_params.split if labels_params
+ finder_params[:name] = extract_label_names(labels_params) if labels_params
- result = LabelsFinder.new(current_user, finder_params).execute
+ LabelsFinder.new(current_user, finder_params).execute
+ end
+
+ def label_with_tilde?(labels_params)
+ labels_params&.include?('~')
+ end
- extract_references(labels_params, :label) | result
+ def extract_label_names(labels_params)
+ # '"A" "A B C" A B' => ["A", "A B C", "A", "B"]
+ labels_params.scan(/"([^"]+)"|([^ ]+)/).flatten.compact
end
def find_label_references(labels_param)
diff --git a/app/services/releases/concerns.rb b/app/services/releases/concerns.rb
index a04bb8f9e14..ff6b696ca96 100644
--- a/app/services/releases/concerns.rb
+++ b/app/services/releases/concerns.rb
@@ -15,7 +15,7 @@ module Releases
end
def name
- params[:name]
+ params[:name] || tag_name
end
def description
diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb
index c6e143d440d..a271a7e5e49 100644
--- a/app/services/releases/create_service.rb
+++ b/app/services/releases/create_service.rb
@@ -15,6 +15,10 @@ module Releases
create_release(tag)
end
+ def find_or_build_release
+ release || build_release(existing_tag)
+ end
+
private
def ensure_tag
@@ -38,7 +42,17 @@ module Releases
end
def create_release(tag)
- release = project.releases.create!(
+ release = build_release(tag)
+
+ release.save!
+
+ success(tag: tag, release: release)
+ rescue => e
+ error(e.message, 400)
+ end
+
+ def build_release(tag)
+ project.releases.build(
name: name,
description: description,
author: current_user,
@@ -46,10 +60,6 @@ module Releases
sha: tag.dereferenced_target.sha,
links_attributes: params.dig(:assets, 'links') || []
)
-
- success(tag: tag, release: release)
- rescue => e
- error(e.message, 400)
end
end
end
diff --git a/app/services/releases/destroy_service.rb b/app/services/releases/destroy_service.rb
index 8c2bc3b4e6e..f9f6101abdd 100644
--- a/app/services/releases/destroy_service.rb
+++ b/app/services/releases/destroy_service.rb
@@ -5,7 +5,6 @@ module Releases
include Releases::Concerns
def execute
- return error('Tag does not exist', 404) unless existing_tag
return error('Release does not exist', 404) unless release
return error('Access Denied', 403) unless allowed?
diff --git a/app/services/resource_events/change_labels_service.rb b/app/services/resource_events/change_labels_service.rb
index 039d6e2ebad..b45e567079b 100644
--- a/app/services/resource_events/change_labels_service.rb
+++ b/app/services/resource_events/change_labels_service.rb
@@ -12,7 +12,7 @@ module ResourceEvents
label_hash = {
resource_column(resource) => resource.id,
user_id: user.id,
- created_at: Time.now
+ created_at: resource.system_note_timestamp
}
labels = added_labels.map do |label|
diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb
index d6af26d949d..f711839e389 100644
--- a/app/services/search/global_service.rb
+++ b/app/services/search/global_service.rb
@@ -23,7 +23,8 @@ module Search
def allowed_scopes
strong_memoize(:allowed_scopes) do
- %w[issues merge_requests milestones]
+ allowed_scopes = %w[issues merge_requests milestones]
+ allowed_scopes << 'users' if Feature.enabled?(:users_search, default_enabled: true)
end
end
diff --git a/app/services/search/group_service.rb b/app/services/search/group_service.rb
index 34803d005e3..6f3b5f00b86 100644
--- a/app/services/search/group_service.rb
+++ b/app/services/search/group_service.rb
@@ -11,6 +11,12 @@ module Search
@group = group
end
+ def execute
+ Gitlab::GroupSearchResults.new(
+ current_user, projects, group, params[:search], default_project_filter: default_project_filter
+ )
+ end
+
def projects
return Project.none unless group
return @projects if defined? @projects
diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb
index f223c8be103..32d5cd7ddb2 100644
--- a/app/services/search/project_service.rb
+++ b/app/services/search/project_service.rb
@@ -16,7 +16,12 @@ module Search
end
def scope
- @scope ||= %w[notes issues merge_requests milestones wiki_blobs commits].delete(params[:scope]) { 'blobs' }
+ @scope ||= begin
+ allowed_scopes = %w[notes issues merge_requests milestones wiki_blobs commits]
+ allowed_scopes << 'users' if Feature.enabled?(:users_search, default_enabled: true)
+
+ allowed_scopes.delete(params[:scope]) { 'blobs' }
+ end
end
end
end
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index e0cbfac2420..302510341ac 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -52,6 +52,10 @@ class SearchService
@search_objects ||= search_results.objects(scope, params[:page])
end
+ def display_options
+ @display_options ||= search_results.display_options(scope)
+ end
+
private
def search_service
diff --git a/app/services/service_response.rb b/app/services/service_response.rb
new file mode 100644
index 00000000000..f3437ba16de
--- /dev/null
+++ b/app/services/service_response.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class ServiceResponse
+ def self.success(message: nil, payload: {})
+ new(status: :success, message: message, payload: payload)
+ end
+
+ def self.error(message:, payload: {}, http_status: nil)
+ new(status: :error, message: message, payload: payload, http_status: http_status)
+ end
+
+ attr_reader :status, :message, :http_status, :payload
+
+ def initialize(status:, message: nil, payload: {}, http_status: nil)
+ self.status = status
+ self.message = message
+ self.payload = payload
+ self.http_status = http_status
+ end
+
+ def success?
+ status == :success
+ end
+
+ def error?
+ status == :error
+ end
+
+ private
+
+ attr_writer :status, :message, :http_status, :payload
+end
diff --git a/app/services/suggestions/apply_service.rb b/app/services/suggestions/apply_service.rb
index 1f720fc835f..8ba50e22b09 100644
--- a/app/services/suggestions/apply_service.rb
+++ b/app/services/suggestions/apply_service.rb
@@ -7,7 +7,7 @@ module Suggestions
end
def execute(suggestion)
- unless suggestion.appliable?
+ unless suggestion.appliable?(cached: false)
return error('Suggestion is not appliable')
end
@@ -15,7 +15,13 @@ module Suggestions
return error('The file has been changed')
end
- params = file_update_params(suggestion)
+ diff_file = suggestion.diff_file
+
+ unless diff_file
+ return error('The file was not found')
+ end
+
+ params = file_update_params(suggestion, diff_file)
result = ::Files::UpdateService.new(suggestion.project, @current_user, params).execute
if result[:status] == :success
@@ -38,8 +44,8 @@ module Suggestions
suggestion.position.head_sha == suggestion.noteable.source_branch_sha
end
- def file_update_params(suggestion)
- blob = suggestion.diff_file.new_blob
+ def file_update_params(suggestion, diff_file)
+ blob = diff_file.new_blob
file_path = suggestion.file_path
branch_name = suggestion.branch
file_content = new_file_content(suggestion, blob)
diff --git a/app/services/suggestions/create_service.rb b/app/services/suggestions/create_service.rb
index 77e958cbe0c..1d3338c1b45 100644
--- a/app/services/suggestions/create_service.rb
+++ b/app/services/suggestions/create_service.rb
@@ -9,48 +9,24 @@ module Suggestions
def execute
return unless @note.supports_suggestion?
- suggestions = Banzai::SuggestionsParser.parse(@note.note)
-
- # For single line suggestion we're only looking forward to
- # change the line receiving the comment. Though, in
- # https://gitlab.com/gitlab-org/gitlab-ce/issues/53310
- # we'll introduce a ```suggestion:L<x>-<y>, so this will
- # slightly change.
- comment_line = @note.position.new_line
+ suggestions = Gitlab::Diff::SuggestionsParser.parse(@note.note,
+ project: @note.project,
+ position: @note.position)
rows =
suggestions.map.with_index do |suggestion, index|
- from_content = changing_lines(comment_line, comment_line)
-
- # The parsed suggestion doesn't have information about the correct
- # ending characters (we may have a line break, or not), so we take
- # this information from the last line being changed (last
- # characters).
- endline_chars = line_break_chars(from_content.lines.last)
- to_content = "#{suggestion}#{endline_chars}"
+ creation_params =
+ suggestion.to_hash.slice(:from_content,
+ :to_content,
+ :lines_above,
+ :lines_below)
- {
- note_id: @note.id,
- from_content: from_content,
- to_content: to_content,
- relative_order: index
- }
+ creation_params.merge!(note_id: @note.id, relative_order: index)
end
rows.in_groups_of(100, false) do |rows|
Gitlab::Database.bulk_insert('suggestions', rows)
end
end
-
- private
-
- def changing_lines(from_line, to_line)
- @note.diff_file.new_blob_lines_between(from_line, to_line).join
- end
-
- def line_break_chars(line)
- match = /\r\n|\r|\n/.match(line)
- match[0] if match
- end
end
end
diff --git a/app/services/suggestions/outdate_service.rb b/app/services/suggestions/outdate_service.rb
new file mode 100644
index 00000000000..a33aac9f6b5
--- /dev/null
+++ b/app/services/suggestions/outdate_service.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Suggestions
+ class OutdateService
+ def execute(merge_request)
+ # rubocop: disable CodeReuse/ActiveRecord
+ suggestions = merge_request.suggestions.active.includes(:note)
+
+ suggestions.find_in_batches(batch_size: 100) do |group|
+ outdatable_suggestion_ids = group.select do |suggestion|
+ suggestion.outdated?(cached: false)
+ end.map(&:id)
+
+ Suggestion.where(id: outdatable_suggestion_ids).update_all(outdated: true)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+ end
+end
diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb
index bd3907cdf8e..858e04f43b2 100644
--- a/app/services/system_hooks_service.rb
+++ b/app/services/system_hooks_service.rb
@@ -47,7 +47,7 @@ class SystemHooksService
case event
when :rename
- data[:old_username] = model.username_was
+ data[:old_username] = model.username_before_last_save
when :failed_login
data[:state] = model.state
end
@@ -58,8 +58,8 @@ class SystemHooksService
if event == :rename
data.merge!(
- old_path: model.path_was,
- old_full_path: model.full_path_was
+ old_path: model.path_before_last_save,
+ old_full_path: model.full_path_before_last_save
)
end
when GroupMember
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index ea8ac7e4656..1390f7cdf46 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -25,7 +25,7 @@ module SystemNoteService
text_parts = ["added #{commits_text}"]
text_parts << commits_list(noteable, new_commits, existing_commits, oldrev)
- text_parts << "[Compare with previous version](#{diff_comparison_url(noteable, project, oldrev)})"
+ text_parts << "[Compare with previous version](#{diff_comparison_path(noteable, project, oldrev)})"
body = text_parts.join("\n\n")
@@ -41,7 +41,7 @@ module SystemNoteService
#
# Returns the created Note object
def tag_commit(noteable, project, author, tag_name)
- link = url_helpers.project_tag_url(project, id: tag_name)
+ link = url_helpers.project_tag_path(project, id: tag_name)
body = "tagged commit #{noteable.sha} to [`#{tag_name}`](#{link})"
create_note(NoteSummary.new(noteable, project, author, body, action: 'tag'))
@@ -69,7 +69,7 @@ module SystemNoteService
# Called when the assignees of an Issue is changed or removed
#
- # issue - Issue object
+ # issuable - Issuable object (responds to assignees)
# project - Project owning noteable
# author - User performing the change
# assignees - Users being assigned, or nil
@@ -85,9 +85,9 @@ module SystemNoteService
# "assigned to @user1 and @user2"
#
# Returns the created Note object
- def change_issue_assignees(issue, project, author, old_assignees)
- unassigned_users = old_assignees - issue.assignees
- added_users = issue.assignees.to_a - old_assignees
+ def change_issuable_assignees(issuable, project, author, old_assignees)
+ unassigned_users = old_assignees - issuable.assignees
+ added_users = issuable.assignees.to_a - old_assignees
text_parts = []
text_parts << "assigned to #{added_users.map(&:to_reference).to_sentence}" if added_users.any?
@@ -95,7 +95,7 @@ module SystemNoteService
body = text_parts.join(' and ')
- create_note(NoteSummary.new(issue, project, author, body, action: 'assignee'))
+ create_note(NoteSummary.new(issuable, project, author, body, action: 'assignee'))
end
# Called when the milestone of a Noteable is changed
@@ -258,7 +258,7 @@ module SystemNoteService
body = "created #{issue.to_reference} to continue this discussion"
note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body)
- note = Note.create(note_attributes.merge(system: true))
+ note = Note.create(note_attributes.merge(system: true, created_at: issue.system_note_timestamp))
note.system_note_metadata = SystemNoteMetadata.new(action: 'discussion')
note
@@ -272,7 +272,7 @@ module SystemNoteService
text_parts = ["changed this line in"]
if version_params = merge_request.version_params_for(diff_refs)
line_code = change_position.line_code(project.repository)
- url = url_helpers.diffs_project_merge_request_url(project, merge_request, version_params.merge(anchor: line_code))
+ url = url_helpers.diffs_project_merge_request_path(project, merge_request, version_params.merge(anchor: line_code))
text_parts << "[version #{version_index} of the diff](#{url})"
else
@@ -405,7 +405,7 @@ module SystemNoteService
#
# "created branch `201-issue-branch-button`"
def new_issue_branch(issue, project, author, branch)
- link = url_helpers.project_compare_url(project, from: project.default_branch, to: branch)
+ link = url_helpers.project_compare_path(project, from: project.default_branch, to: branch)
body = "created branch [`#{branch}`](#{link}) to address this issue"
@@ -668,10 +668,10 @@ module SystemNoteService
@url_helpers ||= Gitlab::Routing.url_helpers
end
- def diff_comparison_url(merge_request, project, oldrev)
+ def diff_comparison_path(merge_request, project, oldrev)
diff_id = merge_request.merge_request_diff.id
- url_helpers.diffs_project_merge_request_url(
+ url_helpers.diffs_project_merge_request_path(
project,
merge_request,
diff_id: diff_id,
diff --git a/app/services/tags/destroy_service.rb b/app/services/tags/destroy_service.rb
index cab507946b4..4f6ae07be7d 100644
--- a/app/services/tags/destroy_service.rb
+++ b/app/services/tags/destroy_service.rb
@@ -41,12 +41,11 @@ module Tags
def build_push_data(tag)
Gitlab::DataBuilder::Push.build(
- project,
- current_user,
- tag.dereferenced_target.sha,
- Gitlab::Git::BLANK_SHA,
- "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}",
- [])
+ project: project,
+ user: current_user,
+ oldrev: tag.dereferenced_target.sha,
+ newrev: Gitlab::Git::BLANK_SHA,
+ ref: "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}")
end
end
end
diff --git a/app/services/test_hooks/project_service.rb b/app/services/test_hooks/project_service.rb
index 7e14ddcd017..a71278e8b8b 100644
--- a/app/services/test_hooks/project_service.rb
+++ b/app/services/test_hooks/project_service.rb
@@ -11,7 +11,7 @@ module TestHooks
private
def push_events_data
- throw(:validation_error, 'Ensure the project has at least one commit.') if project.empty_repo?
+ throw(:validation_error, s_('TestHooks|Ensure the project has at least one commit.')) if project.empty_repo?
Gitlab::DataBuilder::Push.build_sample(project, current_user)
end
@@ -20,14 +20,14 @@ module TestHooks
def note_events_data
note = project.notes.first
- throw(:validation_error, 'Ensure the project has notes.') unless note.present?
+ throw(:validation_error, s_('TestHooks|Ensure the project has notes.')) unless note.present?
Gitlab::DataBuilder::Note.build(note, current_user)
end
def issues_events_data
issue = project.issues.first
- throw(:validation_error, 'Ensure the project has issues.') unless issue.present?
+ throw(:validation_error, s_('TestHooks|Ensure the project has issues.')) unless issue.present?
issue.to_hook_data(current_user)
end
@@ -36,29 +36,29 @@ module TestHooks
def merge_requests_events_data
merge_request = project.merge_requests.first
- throw(:validation_error, 'Ensure the project has merge requests.') unless merge_request.present?
+ throw(:validation_error, s_('TestHooks|Ensure the project has merge requests.')) unless merge_request.present?
merge_request.to_hook_data(current_user)
end
def job_events_data
build = project.builds.first
- throw(:validation_error, 'Ensure the project has CI jobs.') unless build.present?
+ throw(:validation_error, s_('TestHooks|Ensure the project has CI jobs.')) unless build.present?
Gitlab::DataBuilder::Build.build(build)
end
def pipeline_events_data
pipeline = project.ci_pipelines.first
- throw(:validation_error, 'Ensure the project has CI pipelines.') unless pipeline.present?
+ throw(:validation_error, s_('TestHooks|Ensure the project has CI pipelines.')) unless pipeline.present?
Gitlab::DataBuilder::Pipeline.build(pipeline)
end
def wiki_page_events_data
- page = project.wiki.pages.first
+ page = project.wiki.list_pages(limit: 1).first
if !project.wiki_enabled? || page.blank?
- throw(:validation_error, 'Ensure the wiki is enabled and has pages.')
+ throw(:validation_error, s_('TestHooks|Ensure the wiki is enabled and has pages.'))
end
Gitlab::DataBuilder::WikiPage.build(page, current_user, 'create')
diff --git a/app/services/test_hooks/system_service.rb b/app/services/test_hooks/system_service.rb
index 082830c5538..fedf9c6799b 100644
--- a/app/services/test_hooks/system_service.rb
+++ b/app/services/test_hooks/system_service.rb
@@ -18,7 +18,7 @@ module TestHooks
def merge_requests_events_data
merge_request = MergeRequest.of_projects(current_user.projects.select(:id)).first
- throw(:validation_error, 'Ensure one of your projects has merge requests.') unless merge_request.present?
+ throw(:validation_error, s_('TestHooks|Ensure one of your projects has merge requests.')) unless merge_request.present?
merge_request.to_hook_data(current_user)
end
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index f357dc37fe7..0ea230a44a1 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -49,12 +49,12 @@ class TodoService
todo_users.each(&:update_todos_count_cache)
end
- # When we reassign an issue we should:
+ # When we reassign an issuable we should:
#
- # * create a pending todo for new assignee if issue is assigned
+ # * create a pending todo for new assignee if issuable is assigned
#
- def reassigned_issue(issue, current_user, old_assignees = [])
- create_assignment_todo(issue, current_user, old_assignees)
+ def reassigned_issuable(issuable, current_user, old_assignees = [])
+ create_assignment_todo(issuable, current_user, old_assignees)
end
# When create a merge request we should:
@@ -82,14 +82,6 @@ class TodoService
mark_pending_todos_as_done(merge_request, current_user)
end
- # When we reassign a merge request we should:
- #
- # * creates a pending todo for new assignee if merge request is assigned
- #
- def reassigned_merge_request(merge_request, current_user)
- create_assignment_todo(merge_request, current_user)
- end
-
# When merge a merge request we should:
#
# * mark all pending todos related to the target for the current user as done
diff --git a/app/services/todos/destroy/base_service.rb b/app/services/todos/destroy/base_service.rb
index f3f1dbb5698..7378f10e7c4 100644
--- a/app/services/todos/destroy/base_service.rb
+++ b/app/services/todos/destroy/base_service.rb
@@ -13,7 +13,7 @@ module Todos
# rubocop: disable CodeReuse/ActiveRecord
def without_authorized(items)
- items.where('user_id NOT IN (?)', authorized_users)
+ items.where('todos.user_id NOT IN (?)', authorized_users)
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/todos/destroy/confidential_issue_service.rb b/app/services/todos/destroy/confidential_issue_service.rb
index 6276e332448..6cdd8c16894 100644
--- a/app/services/todos/destroy/confidential_issue_service.rb
+++ b/app/services/todos/destroy/confidential_issue_service.rb
@@ -2,36 +2,55 @@
module Todos
module Destroy
+ # Service class for deleting todos that belongs to confidential issues.
+ # It deletes todos for users that are not at least reporters, issue author or assignee.
+ #
+ # Accepts issue_id or project_id as argument.
+ # When issue_id is passed it deletes matching todos for one confidential issue.
+ # When project_id is passed it deletes matching todos for all confidential issues of the project.
class ConfidentialIssueService < ::Todos::Destroy::BaseService
extend ::Gitlab::Utils::Override
- attr_reader :issue
+ attr_reader :issues
# rubocop: disable CodeReuse/ActiveRecord
- def initialize(issue_id)
- @issue = Issue.find_by(id: issue_id)
+ def initialize(issue_id: nil, project_id: nil)
+ @issues =
+ if issue_id
+ Issue.where(id: issue_id)
+ elsif project_id
+ project_confidential_issues(project_id)
+ end
end
# rubocop: enable CodeReuse/ActiveRecord
private
+ def project_confidential_issues(project_id)
+ project = Project.find(project_id)
+
+ project.issues.confidential_only
+ end
+
override :todos
# rubocop: disable CodeReuse/ActiveRecord
def todos
- Todo.where(target: issue)
- .where('user_id != ?', issue.author_id)
- .where('user_id NOT IN (?)', issue.assignees.select(:id))
+ Todo.joins_issue_and_assignees
+ .where(target: issues)
+ .where('issues.confidential = ?', true)
+ .where('todos.user_id != issues.author_id')
+ .where('todos.user_id != issue_assignees.user_id')
end
# rubocop: enable CodeReuse/ActiveRecord
override :todos_to_remove?
def todos_to_remove?
- issue&.confidential?
+ issues&.any?(&:confidential?)
end
override :project_ids
def project_ids
- issue.project_id
+ issues&.distinct&.select(:project_id)
end
override :authorized_users
diff --git a/app/services/todos/destroy/entity_leave_service.rb b/app/services/todos/destroy/entity_leave_service.rb
index ebfb20132d0..4743e9b02ce 100644
--- a/app/services/todos/destroy/entity_leave_service.rb
+++ b/app/services/todos/destroy/entity_leave_service.rb
@@ -37,8 +37,8 @@ module Todos
private
def enqueue_private_features_worker
- project_ids.each do |project_id|
- TodosDestroyer::PrivateFeaturesWorker.perform_async(project_id, user.id)
+ projects.each do |project|
+ TodosDestroyer::PrivateFeaturesWorker.perform_async(project.id, user.id)
end
end
@@ -62,9 +62,8 @@ module Todos
end
# rubocop: enable CodeReuse/ActiveRecord
- override :project_ids
# rubocop: disable CodeReuse/ActiveRecord
- def project_ids
+ def projects
condition = case entity
when Project
{ id: entity.id }
@@ -72,13 +71,13 @@ module Todos
{ namespace_id: non_member_groups }
end
- Project.where(condition).select(:id)
+ Project.where(condition)
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def non_authorized_projects
- project_ids.where('id NOT IN (?)', user.authorized_projects.select(:id))
+ projects.where('id NOT IN (?)', user.authorized_projects.select(:id))
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -110,7 +109,7 @@ module Todos
authorized_reporter_projects = user
.authorized_projects(Gitlab::Access::REPORTER).select(:id)
- Issue.where(project_id: project_ids, confidential: true)
+ Issue.where(project_id: projects, confidential: true)
.where('project_id NOT IN(?)', authorized_reporter_projects)
.where('author_id != ?', user.id)
.where('id NOT IN (?)', assigned_ids)
diff --git a/app/services/update_deployment_service.rb b/app/services/update_deployment_service.rb
index aa7fcca1e2a..49a7d0178f4 100644
--- a/app/services/update_deployment_service.rb
+++ b/app/services/update_deployment_service.rb
@@ -27,6 +27,8 @@ class UpdateDeploymentService
deployment.tap(&:update_merge_request_metrics!)
end
+
+ deployment
end
private
diff --git a/app/services/upload_service.rb b/app/services/upload_service.rb
index 41ca95b3b6f..403944557a2 100644
--- a/app/services/upload_service.rb
+++ b/app/services/upload_service.rb
@@ -6,7 +6,7 @@ class UploadService
end
def execute
- return nil unless @file && @file.size <= max_attachment_size
+ return unless @file && @file.size <= max_attachment_size
uploader = @uploader_class.new(@model, nil, @uploader_context)
uploader.store!(@file)
diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb
index e50840a9158..33444c2a7dc 100644
--- a/app/services/users/activity_service.rb
+++ b/app/services/users/activity_service.rb
@@ -30,7 +30,7 @@ module Users
return if @user.last_activity_on == today
- lease = Gitlab::ExclusiveLease.new("acitvity_service:#{@user.id}",
+ lease = Gitlab::ExclusiveLease.new("activity_service:#{@user.id}",
timeout: LEASE_TIMEOUT)
return unless lease.try_obtain
diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb
index 3f503f3da28..30f7743c56e 100644
--- a/app/services/users/build_service.rb
+++ b/app/services/users/build_service.rb
@@ -26,7 +26,7 @@ module Users
end
end
- identity_attrs = params.slice(:extern_uid, :provider)
+ identity_attrs = params.slice(*identity_params)
unless identity_attrs.empty?
user.identities.build(identity_attrs)
@@ -37,6 +37,10 @@ module Users
private
+ def identity_params
+ [:extern_uid, :provider]
+ end
+
def can_create_user?
(current_user.nil? && Gitlab::CurrentSettings.allow_signup?) || current_user&.admin?
end
diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb
index 04fd6e37501..a66b6627e40 100644
--- a/app/services/users/migrate_to_ghost_user_service.rb
+++ b/app/services/users/migrate_to_ghost_user_service.rb
@@ -33,7 +33,7 @@ module Users
end
end
- user.reload
+ user.reset
end
private
diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb
index fe5a82e23fa..4a26d2be2af 100644
--- a/app/services/users/refresh_authorized_projects_service.rb
+++ b/app/services/users/refresh_authorized_projects_service.rb
@@ -25,7 +25,7 @@ module Users
# We need an up to date User object that has access to all relations that
# may have been created earlier. The only way to ensure this is to reload
# the User object.
- user.reload
+ user.reset
end
def execute
@@ -84,7 +84,7 @@ module Users
# Since we batch insert authorization rows, Rails' associations may get
# out of sync. As such we force a reload of the User object.
- user.reload
+ user.reset
end
def fresh_access_levels_per_project
diff --git a/app/services/validate_new_branch_service.rb b/app/services/validate_new_branch_service.rb
index c19e2ec2043..3f4a59e5cee 100644
--- a/app/services/validate_new_branch_service.rb
+++ b/app/services/validate_new_branch_service.rb
@@ -3,14 +3,14 @@
require_relative 'base_service'
class ValidateNewBranchService < BaseService
- def execute(branch_name)
+ 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)
+ if project.repository.branch_exists?(branch_name) && !force
return error('Branch already exists')
end
diff --git a/app/services/verify_pages_domain_service.rb b/app/services/verify_pages_domain_service.rb
index 07f7391f877..b53c3145caf 100644
--- a/app/services/verify_pages_domain_service.rb
+++ b/app/services/verify_pages_domain_service.rb
@@ -8,6 +8,7 @@ class VerifyPagesDomainService < BaseService
# How long verification lasts for
VERIFICATION_PERIOD = 7.days
+ REMOVAL_DELAY = 1.week.freeze
attr_reader :domain
@@ -36,7 +37,7 @@ class VerifyPagesDomainService < BaseService
# Prevent any pre-existing grace period from being truncated
reverify = [domain.enabled_until, VERIFICATION_PERIOD.from_now].compact.max
- domain.assign_attributes(verified_at: Time.now, enabled_until: reverify)
+ domain.assign_attributes(verified_at: Time.now, enabled_until: reverify, remove_at: nil)
domain.save!(validate: false)
if was_disabled
@@ -49,18 +50,20 @@ class VerifyPagesDomainService < BaseService
end
def unverify_domain!
- if domain.verified?
- domain.assign_attributes(verified_at: nil)
- domain.save!(validate: false)
+ was_verified = domain.verified?
- notify(:verification_failed)
- end
+ domain.assign_attributes(verified_at: nil)
+ domain.remove_at ||= REMOVAL_DELAY.from_now unless domain.enabled?
+ domain.save!(validate: false)
+
+ notify(:verification_failed) if was_verified
error("Couldn't verify #{domain.domain}")
end
def disable_domain!
domain.assign_attributes(verified_at: nil, enabled_until: nil)
+ domain.remove_at ||= REMOVAL_DELAY.from_now
domain.save!(validate: false)
notify(:disabled)
diff --git a/app/uploaders/attachment_uploader.rb b/app/uploaders/attachment_uploader.rb
index 0a166335b4e..b488bba00e9 100644
--- a/app/uploaders/attachment_uploader.rb
+++ b/app/uploaders/attachment_uploader.rb
@@ -9,6 +9,6 @@ class AttachmentUploader < GitlabUploader
private
def dynamic_segment
- File.join(model.class.to_s.underscore, mounted_as.to_s, model.id.to_s)
+ File.join(model.class.underscore, mounted_as.to_s, model.id.to_s)
end
end
diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb
index c0165759203..9af59b0aceb 100644
--- a/app/uploaders/avatar_uploader.rb
+++ b/app/uploaders/avatar_uploader.rb
@@ -25,6 +25,6 @@ class AvatarUploader < GitlabUploader
private
def dynamic_segment
- File.join(model.class.to_s.underscore, mounted_as.to_s, model.id.to_s)
+ File.join(model.class.underscore, mounted_as.to_s, model.id.to_s)
end
end
diff --git a/app/uploaders/file_mover.rb b/app/uploaders/file_mover.rb
index a7f8615e9ba..236b7ed2b3d 100644
--- a/app/uploaders/file_mover.rb
+++ b/app/uploaders/file_mover.rb
@@ -11,6 +11,8 @@ class FileMover
end
def execute
+ return unless valid?
+
move
if update_markdown
@@ -21,6 +23,12 @@ class FileMover
private
+ def valid?
+ Pathname.new(temp_file_path).realpath.to_path.start_with?(
+ (Pathname(temp_file_uploader.root) + temp_file_uploader.base_dir).to_path
+ )
+ end
+
def move
FileUtils.mkdir_p(File.dirname(file_path))
FileUtils.move(temp_file_path, file_path)
diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb
index e90599f2505..1c7582533ad 100644
--- a/app/uploaders/file_uploader.rb
+++ b/app/uploaders/file_uploader.rb
@@ -14,8 +14,8 @@ class FileUploader < GitlabUploader
include ObjectStorage::Concern
prepend ObjectStorage::Extension::RecordsUploads
- MARKDOWN_PATTERN = %r{\!?\[.*?\]\(/uploads/(?<secret>[0-9a-f]{32})/(?<file>.*?)\)}
- DYNAMIC_PATH_PATTERN = %r{.*(?<secret>\h{32})/(?<identifier>.*)}
+ MARKDOWN_PATTERN = %r{\!?\[.*?\]\(/uploads/(?<secret>[0-9a-f]{32})/(?<file>.*?)\)}.freeze
+ DYNAMIC_PATH_PATTERN = %r{.*(?<secret>\h{32})/(?<identifier>.*)}.freeze
after :remove, :prune_store_dir
@@ -109,12 +109,20 @@ class FileUploader < GitlabUploader
def upload_path
if file_storage?
# Legacy path relative to project.full_path
- File.join(dynamic_segment, identifier)
+ local_storage_path(identifier)
else
- File.join(store_dir, identifier)
+ remote_storage_path(identifier)
end
end
+ def local_storage_path(file_identifier)
+ File.join(dynamic_segment, file_identifier)
+ end
+
+ def remote_storage_path(file_identifier)
+ File.join(store_dir, file_identifier)
+ end
+
def store_dirs
{
Store::LOCAL => File.join(base_dir, dynamic_segment),
diff --git a/app/uploaders/import_export_uploader.rb b/app/uploaders/import_export_uploader.rb
index 716922bc017..104d5d3b3dd 100644
--- a/app/uploaders/import_export_uploader.rb
+++ b/app/uploaders/import_export_uploader.rb
@@ -7,10 +7,6 @@ class ImportExportUploader < AttachmentUploader
EXTENSION_WHITELIST
end
- def move_to_store
- true
- end
-
def move_to_cache
false
end
diff --git a/app/uploaders/legacy_artifact_uploader.rb b/app/uploaders/legacy_artifact_uploader.rb
deleted file mode 100644
index a9afc104ed1..00000000000
--- a/app/uploaders/legacy_artifact_uploader.rb
+++ /dev/null
@@ -1,24 +0,0 @@
-# frozen_string_literal: true
-
-class LegacyArtifactUploader < GitlabUploader
- extend Workhorse::UploadPath
- include ObjectStorage::Concern
-
- ObjectNotReadyError = Class.new(StandardError)
-
- storage_options Gitlab.config.artifacts
-
- alias_method :upload, :model
-
- def store_dir
- dynamic_segment
- end
-
- private
-
- def dynamic_segment
- raise ObjectNotReadyError, 'Build is not ready' unless model.id
-
- File.join(model.created_at.utc.strftime('%Y_%m'), model.project_id.to_s, model.id.to_s)
- end
-end
diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb
index dad6e85fb56..0a44d60778d 100644
--- a/app/uploaders/object_storage.rb
+++ b/app/uploaders/object_storage.rb
@@ -117,7 +117,7 @@ module ObjectStorage
next unless uploader
next unless uploader.exists?
- next unless send(:"#{mounted_as}_changed?") # rubocop:disable GitlabSecurity/PublicSend
+ next unless send(:"saved_change_to_#{mounted_as}?") # rubocop:disable GitlabSecurity/PublicSend
mount
end.keys
@@ -278,8 +278,12 @@ module ObjectStorage
self.class.object_store_credentials
end
+ # Set ACL of uploaded objects to not-public (fog-aws)[1] or no ACL at all
+ # (fog-google). Value is ignored by other supported backends (fog-aliyun,
+ # fog-openstack, fog-rackspace)
+ # [1]: https://github.com/fog/fog-aws/blob/daa50bb3717a462baf4d04d0e0cbfc18baacb541/lib/fog/aws/models/storage/file.rb#L152-L159
def fog_public
- false
+ nil
end
def delete_migrated_file(migrated_file)
diff --git a/app/uploaders/personal_file_uploader.rb b/app/uploaders/personal_file_uploader.rb
index 272837aa6ce..b43162f0935 100644
--- a/app/uploaders/personal_file_uploader.rb
+++ b/app/uploaders/personal_file_uploader.rb
@@ -6,21 +6,18 @@ class PersonalFileUploader < FileUploader
options.storage_path
end
- def self.base_dir(model, store = nil)
- base_dirs(model)[store || Store::LOCAL]
- end
-
- def self.base_dirs(model)
- {
- Store::LOCAL => File.join(options.base_dir, model_path_segment(model)),
- Store::REMOTE => model_path_segment(model)
- }
+ def self.base_dir(model, _store = nil)
+ # base_dir is the path seen by the user when rendering Markdown, so
+ # it should be the same for both local and object storage. It is
+ # typically prefaced with uploads/-/system, but that prefix
+ # is omitted in the path stored on disk.
+ File.join(options.base_dir, model_path_segment(model))
end
def self.model_path_segment(model)
return 'temp/' unless model
- File.join(model.class.to_s.underscore, model.id.to_s)
+ File.join(model.class.underscore, model.id.to_s)
end
def object_store
@@ -40,8 +37,61 @@ class PersonalFileUploader < FileUploader
store_dirs[object_store]
end
+ # A personal snippet path is stored using FileUploader#upload_path.
+ #
+ # The format for the path:
+ #
+ # Local storage: :random_hex/:filename.
+ # Object storage: personal_snippet/:id/:random_hex/:filename.
+ #
+ # upload_paths represent the possible paths for a given identifier,
+ # which will vary depending on whether the file is stored in local or
+ # object storage. upload_path should match an element in upload_paths.
+ #
+ # base_dir represents the path seen by the user in Markdown, and it
+ # should always be prefixed with uploads/-/system.
+ #
+ # store_dirs represent the paths that are actually used on disk. For
+ # object storage, this should omit the prefix /uploads/-/system.
+ #
+ # For example, consider the requested path /uploads/-/system/personal_snippet/172/ff4ad5c2e40b39ae57cda51577317d20/file.png.
+ #
+ # For local storage:
+ #
+ # File on disk: /opt/gitlab/embedded/service/gitlab-rails/public/uploads/-/system/personal_snippet/172/ff4ad5c2e40b39ae57cda51577317d20/file.png.
+ #
+ # base_dir: uploads/-/system/personal_snippet/172
+ # upload_path: ff4ad5c2e40b39ae57cda51577317d20/file.png
+ # upload_paths: ["ff4ad5c2e40b39ae57cda51577317d20/file.png", "personal_snippet/172/ff4ad5c2e40b39ae57cda51577317d20/file.png"].
+ # store_dirs:
+ # => {1=>"uploads/-/system/personal_snippet/172/ff4ad5c2e40b39ae57cda51577317d20", 2=>"personal_snippet/172/ff4ad5c2e40b39ae57cda51577317d20"}
+ #
+ # For object storage:
+ #
+ # upload_path: personal_snippet/172/ff4ad5c2e40b39ae57cda51577317d20/file.png
+ def upload_paths(identifier)
+ [
+ local_storage_path(identifier),
+ File.join(remote_storage_base_path, identifier)
+ ]
+ end
+
+ def store_dirs
+ {
+ Store::LOCAL => File.join(base_dir, dynamic_segment),
+ Store::REMOTE => remote_storage_base_path
+ }
+ end
+
private
+ # To avoid prefacing the remote storage path with `/uploads/-/system`,
+ # we just drop that part so that the destination path will be
+ # personal_snippet/:id/:random_hex/:filename.
+ def remote_storage_base_path
+ File.join(self.class.model_path_segment(model), dynamic_segment)
+ end
+
def secure_url
File.join('/', base_dir, secret, file.filename)
end
diff --git a/app/uploaders/records_uploads.rb b/app/uploaders/records_uploads.rb
index 9a243e07936..00b51f92b12 100644
--- a/app/uploaders/records_uploads.rb
+++ b/app/uploaders/records_uploads.rb
@@ -46,6 +46,10 @@ module RecordsUploads
File.join(store_dir, filename.to_s)
end
+ def filename
+ upload&.path ? File.basename(upload.path) : super
+ end
+
private
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/validators/addressable_url_validator.rb b/app/validators/addressable_url_validator.rb
new file mode 100644
index 00000000000..273e15ef925
--- /dev/null
+++ b/app/validators/addressable_url_validator.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+# AddressableUrlValidator
+#
+# Custom validator for URLs. This is a stricter version of UrlValidator - it also checks
+# for using the right protocol, but it actually parses the URL checking for any syntax errors.
+# The regex is also different from `URI` as we use `Addressable::URI` here.
+#
+# By default, only URLs for the HTTP(S) schemes will be considered valid.
+# Provide a `:schemes` option to configure accepted schemes.
+#
+# Example:
+#
+# class User < ActiveRecord::Base
+# validates :personal_url, addressable_url: true
+#
+# validates :ftp_url, addressable_url: { schemes: %w(ftp) }
+#
+# validates :git_url, addressable_url: { schemes: %w(http https ssh git) }
+# end
+#
+# This validator can also block urls pointing to localhost or the local network to
+# protect against Server-side Request Forgery (SSRF), or check for the right port.
+#
+# Configuration options:
+# * <tt>message</tt> - A custom error message (default is: "must be a valid URL").
+# * <tt>schemes</tt> - Array of URI schemes. Default: +['http', 'https']+
+# * <tt>allow_localhost</tt> - Allow urls pointing to +localhost+. Default: +true+
+# * <tt>allow_local_network</tt> - Allow urls pointing to private network addresses. Default: +true+
+# * <tt>allow_blank</tt> - Allow urls to be +blank+. Default: +false+
+# * <tt>allow_nil</tt> - Allow urls to be +nil+. Default: +false+
+# * <tt>ports</tt> - Allowed ports. Default: +all+.
+# * <tt>enforce_user</tt> - Validate user format. Default: +false+
+# * <tt>enforce_sanitization</tt> - Validate that there are no html/css/js tags. Default: +false+
+#
+# Example:
+# class User < ActiveRecord::Base
+# validates :personal_url, addressable_url: { allow_localhost: false, allow_local_network: false}
+#
+# validates :web_url, addressable_url: { ports: [80, 443] }
+# end
+class AddressableUrlValidator < ActiveModel::EachValidator
+ attr_reader :record
+
+ BLOCKER_VALIDATE_OPTIONS = {
+ schemes: %w(http https),
+ ports: [],
+ allow_localhost: true,
+ allow_local_network: true,
+ ascii_only: false,
+ enforce_user: false,
+ enforce_sanitization: false
+ }.freeze
+
+ DEFAULT_OPTIONS = BLOCKER_VALIDATE_OPTIONS.merge({
+ message: 'must be a valid URL'
+ }).freeze
+
+ def initialize(options)
+ options.reverse_merge!(DEFAULT_OPTIONS)
+
+ super(options)
+ end
+
+ def validate_each(record, attribute, value)
+ @record = record
+
+ unless value.present?
+ record.errors.add(attribute, options.fetch(:message))
+ return
+ end
+
+ value = strip_value!(record, attribute, value)
+
+ Gitlab::UrlBlocker.validate!(value, blocker_args)
+ rescue Gitlab::UrlBlocker::BlockedUrlError => e
+ record.errors.add(attribute, "is blocked: #{e.message}")
+ end
+
+ private
+
+ def strip_value!(record, attribute, value)
+ new_value = value.strip
+ return value if new_value == value
+
+ record.public_send("#{attribute}=", new_value) # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ def current_options
+ options.map do |option, value|
+ [option, value.is_a?(Proc) ? value.call(record) : value]
+ end.to_h
+ end
+
+ def blocker_args
+ current_options.slice(*BLOCKER_VALIDATE_OPTIONS.keys).tap do |args|
+ if self.class.allow_setting_local_requests?
+ args[:allow_localhost] = args[:allow_local_network] = true
+ end
+ end
+ end
+
+ def self.allow_setting_local_requests?
+ # We cannot use Gitlab::CurrentSettings as ApplicationSetting itself
+ # uses UrlValidator to validate urls. This ends up in a cycle
+ # when Gitlab::CurrentSettings creates an ApplicationSetting which then
+ # calls this validator.
+ #
+ # See https://gitlab.com/gitlab-org/gitlab-ee/issues/9833
+ ApplicationSetting.current&.allow_local_requests_from_hooks_and_services?
+ end
+end
diff --git a/app/validators/cluster_name_validator.rb b/app/validators/cluster_name_validator.rb
index 85fd63f08e5..79c9c67ae58 100644
--- a/app/validators/cluster_name_validator.rb
+++ b/app/validators/cluster_name_validator.rb
@@ -5,7 +5,9 @@
# Custom validator for ClusterName.
class ClusterNameValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
- if record.managed?
+ if record.provided_by_user?
+ record.errors.add(attribute, " has to be present") unless value.present?
+ else
if record.persisted? && record.name_changed?
record.errors.add(attribute, " can not be changed because it's synchronized with provider")
end
@@ -17,10 +19,6 @@ class ClusterNameValidator < ActiveModel::EachValidator
unless value =~ Gitlab::Regex.kubernetes_namespace_regex
record.errors.add(attribute, Gitlab::Regex.kubernetes_namespace_regex_message)
end
- else
- unless value.present?
- record.errors.add(attribute, " has to be present")
- end
end
end
end
diff --git a/app/validators/devise_email_validator.rb b/app/validators/devise_email_validator.rb
new file mode 100644
index 00000000000..6ca921ca7fa
--- /dev/null
+++ b/app/validators/devise_email_validator.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+# DeviseEmailValidator
+#
+# Custom validator for email formats. It asserts that there are no
+# @ symbols or whitespaces in either the localpart or the domain, and that
+# there is a single @ symbol separating the localpart and the domain.
+#
+# The available options are:
+# - regexp: Email regular expression used to validate email formats as instance of Regexp class.
+# If provided value has different type then a new Rexexp class instance is created using the value.
+# Default: +Devise.email_regexp+
+#
+# Example:
+# class User < ActiveRecord::Base
+# validates :personal_email, devise_email: true
+#
+# validates :public_email, devise_email: { regexp: Devise.email_regexp }
+# end
+class DeviseEmailValidator < ActiveModel::EachValidator
+ DEFAULT_OPTIONS = {
+ regexp: Devise.email_regexp
+ }.freeze
+
+ def initialize(options)
+ options.reverse_merge!(DEFAULT_OPTIONS)
+
+ raise ArgumentError, "Expected 'regexp' argument of type class Regexp" unless options[:regexp].is_a?(Regexp)
+
+ super(options)
+ end
+
+ def validate_each(record, attribute, value)
+ record.errors.add(attribute, :invalid) unless value =~ options[:regexp]
+ end
+end
diff --git a/app/validators/email_validator.rb b/app/validators/email_validator.rb
deleted file mode 100644
index 9459edb7515..00000000000
--- a/app/validators/email_validator.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-# frozen_string_literal: true
-
-class EmailValidator < ActiveModel::EachValidator
- def validate_each(record, attribute, value)
- record.errors.add(attribute, :invalid) unless value =~ Devise.email_regexp
- end
-end
diff --git a/app/validators/public_url_validator.rb b/app/validators/public_url_validator.rb
index 3ff880deedd..91847c5d866 100644
--- a/app/validators/public_url_validator.rb
+++ b/app/validators/public_url_validator.rb
@@ -2,7 +2,7 @@
# PublicUrlValidator
#
-# Custom validator for URLs. This validator works like UrlValidator but
+# Custom validator for URLs. This validator works like AddressableUrlValidator but
# it blocks by default urls pointing to localhost or the local network.
#
# This validator accepts the same params UrlValidator does.
@@ -12,17 +12,20 @@
# class User < ActiveRecord::Base
# validates :personal_url, public_url: true
#
-# validates :ftp_url, public_url: { protocols: %w(ftp) }
+# validates :ftp_url, public_url: { schemes: %w(ftp) }
#
# validates :git_url, public_url: { allow_localhost: true, allow_local_network: true}
# end
#
-class PublicUrlValidator < UrlValidator
- private
+class PublicUrlValidator < AddressableUrlValidator
+ DEFAULT_OPTIONS = {
+ allow_localhost: false,
+ allow_local_network: false
+ }.freeze
- def default_options
- # By default block all urls pointing to localhost or the local network
- super.merge(allow_localhost: false,
- allow_local_network: false)
+ def initialize(options)
+ options.reverse_merge!(DEFAULT_OPTIONS)
+
+ super(options)
end
end
diff --git a/app/validators/sha_validator.rb b/app/validators/sha_validator.rb
new file mode 100644
index 00000000000..77e7cfa4f6b
--- /dev/null
+++ b/app/validators/sha_validator.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class ShaValidator < ActiveModel::EachValidator
+ def validate_each(record, attribute, value)
+ return if value.blank? || Commit.valid_hash?(value)
+
+ record.errors.add(attribute, 'is not a valid SHA')
+ end
+end
diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb
deleted file mode 100644
index 3fd015c3cf5..00000000000
--- a/app/validators/url_validator.rb
+++ /dev/null
@@ -1,104 +0,0 @@
-# frozen_string_literal: true
-
-# UrlValidator
-#
-# Custom validator for URLs.
-#
-# By default, only URLs for the HTTP(S) protocols will be considered valid.
-# Provide a `:protocols` option to configure accepted protocols.
-#
-# Example:
-#
-# class User < ActiveRecord::Base
-# validates :personal_url, url: true
-#
-# validates :ftp_url, url: { protocols: %w(ftp) }
-#
-# validates :git_url, url: { protocols: %w(http https ssh git) }
-# end
-#
-# This validator can also block urls pointing to localhost or the local network to
-# protect against Server-side Request Forgery (SSRF), or check for the right port.
-#
-# The available options are:
-# - protocols: Allowed protocols. Default: http and https
-# - allow_localhost: Allow urls pointing to localhost. Default: true
-# - allow_local_network: Allow urls pointing to private network addresses. Default: true
-# - ports: Allowed ports. Default: all.
-# - enforce_user: Validate user format. Default: false
-# - enforce_sanitization: Validate that there are no html/css/js tags. Default: false
-#
-# Example:
-# class User < ActiveRecord::Base
-# validates :personal_url, url: { allow_localhost: false, allow_local_network: false}
-#
-# validates :web_url, url: { ports: [80, 443] }
-# end
-class UrlValidator < ActiveModel::EachValidator
- DEFAULT_PROTOCOLS = %w(http https).freeze
-
- attr_reader :record
-
- def validate_each(record, attribute, value)
- @record = record
-
- unless value.present?
- record.errors.add(attribute, 'must be a valid URL')
- return
- end
-
- value = strip_value!(record, attribute, value)
-
- Gitlab::UrlBlocker.validate!(value, blocker_args)
- rescue Gitlab::UrlBlocker::BlockedUrlError => e
- record.errors.add(attribute, "is blocked: #{e.message}")
- end
-
- private
-
- def strip_value!(record, attribute, value)
- new_value = value.strip
- return value if new_value == value
-
- record.public_send("#{attribute}=", new_value) # rubocop:disable GitlabSecurity/PublicSend
- end
-
- def default_options
- # By default the validator doesn't block any url based on the ip address
- {
- protocols: DEFAULT_PROTOCOLS,
- ports: [],
- allow_localhost: true,
- allow_local_network: true,
- ascii_only: false,
- enforce_user: false,
- enforce_sanitization: false
- }
- end
-
- def current_options
- options = self.options.map do |option, value|
- [option, value.is_a?(Proc) ? value.call(record) : value]
- end.to_h
-
- default_options.merge(options)
- end
-
- def blocker_args
- current_options.slice(*default_options.keys).tap do |args|
- if allow_setting_local_requests?
- args[:allow_localhost] = args[:allow_local_network] = true
- end
- end
- end
-
- def allow_setting_local_requests?
- # We cannot use Gitlab::CurrentSettings as ApplicationSetting itself
- # uses UrlValidator to validate urls. This ends up in a cycle
- # when Gitlab::CurrentSettings creates an ApplicationSetting which then
- # calls this validator.
- #
- # See https://gitlab.com/gitlab-org/gitlab-ee/issues/9833
- ApplicationSetting.current&.allow_local_requests_from_hooks_and_services?
- end
-end
diff --git a/app/validators/x509_certificate_credentials_validator.rb b/app/validators/x509_certificate_credentials_validator.rb
new file mode 100644
index 00000000000..d2f18e956c3
--- /dev/null
+++ b/app/validators/x509_certificate_credentials_validator.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+# X509CertificateCredentialsValidator
+#
+# Custom validator to check if certificate-attribute was signed using the
+# private key stored in an attrebute.
+#
+# This can be used as an `ActiveModel::Validator` as follows:
+#
+# validates_with X509CertificateCredentialsValidator,
+# certificate: :client_certificate,
+# pkey: :decrypted_private_key,
+# pass: :decrypted_passphrase
+#
+#
+# Required attributes:
+# - certificate: The name of the accessor that returns the certificate to check
+# - pkey: The name of the accessor that returns the private key
+# Optional:
+# - pass: The name of the accessor that returns the passphrase to decrypt the
+# private key
+class X509CertificateCredentialsValidator < ActiveModel::Validator
+ def initialize(*args)
+ super
+
+ # We can't validate if we don't have a private key or certificate attributes
+ # in which case this validator is useless.
+ if options[:pkey].nil? || options[:certificate].nil?
+ raise 'Provide at least `certificate` and `pkey` attribute names'
+ end
+ end
+
+ def validate(record)
+ unless certificate = read_certificate(record)
+ record.errors.add(options[:certificate], _('is not a valid X509 certificate.'))
+ end
+
+ unless private_key = read_private_key(record)
+ record.errors.add(options[:pkey], _('could not read private key, is the passphrase correct?'))
+ end
+
+ return if private_key.nil? || certificate.nil?
+
+ unless certificate.public_key.fingerprint == private_key.public_key.fingerprint
+ record.errors.add(options[:pkey], _('private key does not match certificate.'))
+ end
+ end
+
+ private
+
+ def read_private_key(record)
+ OpenSSL::PKey.read(pkey(record).to_s, pass(record).to_s)
+ rescue OpenSSL::PKey::PKeyError, ArgumentError
+ # When the primary key could not be read, an ArgumentError is raised.
+ # This hapens when the passed key is not valid or the passphrase is incorrect
+ nil
+ end
+
+ def read_certificate(record)
+ OpenSSL::X509::Certificate.new(certificate(record).to_s)
+ rescue OpenSSL::X509::CertificateError
+ nil
+ end
+
+ # rubocop:disable GitlabSecurity/PublicSend
+ #
+ # Allowing `#public_send` here because we don't want the validator to really
+ # care about the names of the attributes or where they come from.
+ #
+ # The credentials are mostly stored encrypted so we need to go through the
+ # accessors to get the values, `read_attribute` bypasses those.
+ def certificate(record)
+ record.public_send(options[:certificate])
+ end
+
+ def pkey(record)
+ record.public_send(options[:pkey])
+ end
+
+ def pass(record)
+ return unless options[:pass]
+
+ record.public_send(options[:pass])
+ end
+ # rubocop:enable GitlabSecurity/PublicSend
+end
diff --git a/app/views/abuse_reports/new.html.haml b/app/views/abuse_reports/new.html.haml
index 92ae40512c5..c6781e91cfd 100644
--- a/app/views/abuse_reports/new.html.haml
+++ b/app/views/abuse_reports/new.html.haml
@@ -1,22 +1,24 @@
-- page_title _("Report abuse to GitLab")
+- page_title _("Report abuse to admin")
%h3.page-title
- = _("Report abuse to GitLab")
+ = _("Report abuse to admin")
%p
- = _("Please use this form to report users to GitLab who create spam issues, comments or behave inappropriately.")
+ = _("Please use this form to report to the admin users who create spam issues, comments or behave inappropriately.")
%p
- = _("A member of GitLab's abuse team will review your report as soon as possible.")
+ = _("A member of the abuse team will review your report as soon as possible.")
%hr
= form_for @abuse_report, html: { class: 'js-quick-submit js-requires-input'} do |f|
= form_errors(@abuse_report)
= f.hidden_field :user_id
.form-group.row
- = f.label :user_id, class: 'col-sm-2 col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :user_id
.col-sm-10
- name = "#{@abuse_report.user.name} (@#{@abuse_report.user.username})"
= text_field_tag :user_name, name, class: "form-control", readonly: true
.form-group.row
- = f.label :message, class: 'col-sm-2 col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :message
.col-sm-10
= f.text_area :message, class: "form-control", rows: 2, required: true, value: sanitize(@ref_url)
.form-text.text-muted
diff --git a/app/views/admin/appearances/_system_header_footer_form.html.haml b/app/views/admin/appearances/_system_header_footer_form.html.haml
index ca9d6adebeb..4301ebd05af 100644
--- a/app/views/admin/appearances/_system_header_footer_form.html.haml
+++ b/app/views/admin/appearances/_system_header_footer_form.html.haml
@@ -13,6 +13,15 @@
.form-group
= form.label :footer_message, _('Footer message'), class: 'col-form-label label-bold'
= form.text_area :footer_message, placeholder: _('State your message to activate'), class: "form-control js-autosize"
+ .form-group
+ .form-check
+ = form.check_box :email_header_and_footer_enabled, class: 'form-check-input'
+ = form.label :email_header_and_footer_enabled, class: 'label-bold' do
+ = _('Enable header and footer in emails')
+
+ .hint
+ = _('Add header and footer to emails. Please note that color settings will only be applied within the application interface')
+
.form-group.js-toggle-colors-container
%button.btn.btn-link.js-toggle-colors-link{ type: 'button' }
= _('Customize colors')
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 65a24854583..9ed4bc44aae 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -6,32 +6,35 @@
.form-check
= f.check_box :gravatar_enabled, class: 'form-check-input'
= f.label :gravatar_enabled, class: 'form-check-label' do
- Gravatar enabled
+ = _('Gravatar enabled')
.form-group
= f.label :default_projects_limit, class: 'label-bold'
= f.number_field :default_projects_limit, class: 'form-control'
.form-group
- = f.label :max_attachment_size, 'Maximum attachment size (MB)', class: 'label-bold'
+ = f.label :max_attachment_size, _('Maximum attachment size (MB)'), class: 'label-bold'
= f.number_field :max_attachment_size, class: 'form-control'
+
+ = render_if_exists 'admin/application_settings/repository_size_limit_setting', form: f
+
.form-group
- = f.label :receive_max_input_size, 'Maximum push size (MB)', class: 'label-light'
+ = f.label :receive_max_input_size, _('Maximum push size (MB)'), class: 'label-light'
= f.number_field :receive_max_input_size, class: 'form-control qa-receive-max-input-size-field'
.form-group
- = f.label :session_expire_delay, 'Session duration (minutes)', class: 'label-light'
+ = 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
+ %span.form-text.text-muted#session_expire_delay_help_block= _('GitLab restart is required to apply changes')
.form-group
- = f.label :user_oauth_applications, 'User OAuth applications', class: 'label-bold'
+ = f.label :user_oauth_applications, _('User OAuth applications'), class: 'label-bold'
.form-check
= f.check_box :user_oauth_applications, class: 'form-check-input'
= f.label :user_oauth_applications, class: 'form-check-label' do
- Allow users to register any application to use GitLab as an OAuth provider
+ = _('Allow users to register any application to use GitLab as an OAuth provider')
.form-group
- = f.label :user_default_external, 'New users set to external', class: 'label-bold'
+ = f.label :user_default_external, _('New users set to external'), class: 'label-bold'
.form-check
= f.check_box :user_default_external, class: 'form-check-input'
= f.label :user_default_external, class: 'form-check-label' do
- Newly registered users will by default be external
+ = _('Newly registered users will by default be external')
.prepend-top-10
= _('Internal users')
= f.text_field :user_default_internal_regex, placeholder: _('Regex pattern'), class: 'form-control prepend-top-5'
@@ -40,10 +43,12 @@
= link_to _('More information'), help_page_path('user/permissions', anchor: 'external-users-permissions'),
target: '_blank'
.form-group
- = f.label :user_show_add_ssh_key_message, 'Prompt users to upload SSH keys', class: 'label-bold'
+ = f.label :user_show_add_ssh_key_message, _('Prompt users to upload SSH keys'), class: 'label-bold'
.form-check
= f.check_box :user_show_add_ssh_key_message, class: 'form-check-input'
= f.label :user_show_add_ssh_key_message, class: 'form-check-label' do
- Inform users without uploaded SSH keys that they can't push over SSH until one is added
+ = _("Inform users without uploaded SSH keys that they can't push over SSH until one is added")
+
+ = render_if_exists 'admin/application_settings/availability_on_namespace_setting', form: f
- = f.submit 'Save changes', class: 'btn btn-success qa-save-changes-button'
+ = f.submit _('Save changes'), class: 'btn btn-success qa-save-changes-button'
diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml
index c99d7e9b8e9..b8c481df0d2 100644
--- a/app/views/admin/application_settings/_ci_cd.html.haml
+++ b/app/views/admin/application_settings/_ci_cd.html.haml
@@ -8,7 +8,7 @@
.form-check
= f.check_box :auto_devops_enabled, class: 'form-check-input'
= f.label :auto_devops_enabled, class: 'form-check-label' do
- Default to Auto DevOps pipeline for all projects
+ = s_('CICD|Default to Auto DevOps pipeline for all projects')
.form-text.text-muted
= s_('CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found.')
= link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank'
@@ -21,34 +21,31 @@
.form-check
= f.check_box :shared_runners_enabled, class: 'form-check-input'
= f.label :shared_runners_enabled, class: 'form-check-label' do
- Enable shared runners for new projects
+ = s_("AdminSettings|Enable shared runners for new projects")
+
+ = render_if_exists 'admin/application_settings/shared_runners_minutes_setting', form: f
+
.form-group
= f.label :shared_runners_text, class: 'label-bold'
= f.text_area :shared_runners_text, class: 'form-control', rows: 4
- .form-text.text-muted Markdown enabled
+ .form-text.text-muted= _("Markdown enabled")
.form-group
- = f.label :max_artifacts_size, 'Maximum artifacts size (MB)', class: 'label-bold'
+ = f.label :max_artifacts_size, _('Maximum artifacts size (MB)'), class: 'label-bold'
= f.number_field :max_artifacts_size, class: 'form-control'
.form-text.text-muted
- Set the maximum file size for each job's artifacts
+ = _("Set the maximum file size for each job's artifacts")
= link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size')
.form-group
- = f.label :default_artifacts_expire_in, 'Default artifacts expiration', class: 'label-bold'
+ = f.label :default_artifacts_expire_in, _('Default artifacts expiration'), class: 'label-bold'
= f.text_field :default_artifacts_expire_in, class: 'form-control'
.form-text.text-muted
- Set the default expiration time for each job's artifacts.
- 0 for unlimited.
- The default unit is in seconds, but you can define an alternative. For example:
- <code>4 mins 2 sec</code>, <code>2h42min</code>.
+ = _("Set the default expiration time for each job's artifacts. 0 for unlimited. The default unit is in seconds, but you can define an alternative. For example: <code>4 mins 2 sec</code>, <code>2h42min</code>.").html_safe
= link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'default-artifacts-expiration')
.form-group
- = f.label :archive_builds_in_human_readable, 'Archive jobs', class: 'label-bold'
+ = f.label :archive_builds_in_human_readable, _('Archive jobs'), class: 'label-bold'
= f.text_field :archive_builds_in_human_readable, class: 'form-control', placeholder: 'never'
.form-text.text-muted
- Set the duration for which the jobs will be considered as old and expired.
- Once that time passes, the jobs will be archived and no longer able to be
- retried. Make it empty to never expire jobs. It has to be no less than 1 day,
- for example: <code>15 days</code>, <code>1 month</code>, <code>2 years</code>.
+ = _("Set the duration for which the jobs will be considered as old and expired. Once that time passes, the jobs will be archived and no longer able to be retried. Make it empty to never expire jobs. It has to be no less than 1 day, for example: <code>15 days</code>, <code>1 month</code>, <code>2 years</code>.").html_safe
.form-group
.form-check
= f.check_box :protected_ci_variables, class: 'form-check-input'
@@ -57,4 +54,4 @@
.form-text.text-muted
= s_('AdminSettings|When creating a new environment variable it will be protected by default.')
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml
index 60a6be731ea..3f30c75fbb6 100644
--- a/app/views/admin/application_settings/_email.html.haml
+++ b/app/views/admin/application_settings/_email.html.haml
@@ -6,20 +6,16 @@
.form-check
= f.check_box :email_author_in_body, class: 'form-check-input'
= f.label :email_author_in_body, class: 'form-check-label' do
- Include author name in notification email body
+ = _('Include author name in notification email body')
.form-text.text-muted
- Some email servers do not support overriding the email sender name.
- Enable this option to include the name of the author of the issue,
- merge request or comment in the email body instead.
+ = _('Some email servers do not support overriding the email sender name. Enable this option to include the name of the author of the issue, merge request or comment in the email body instead.')
.form-group
.form-check
= f.check_box :html_emails_enabled, class: 'form-check-input'
= f.label :html_emails_enabled, class: 'form-check-label' do
- Enable HTML emails
+ = _('Enable HTML emails')
.form-text.text-muted
- By default GitLab sends emails in HTML and plain text formats so mail
- clients can choose what format to use. Disable this option if you only
- want to send emails in plain text format.
+ = _('By default GitLab sends emails in HTML and plain text formats so mail clients can choose what format to use. Disable this option if you only want to send emails in plain text format.')
.form-group
= f.label :commit_email_hostname, _('Custom hostname (for private commit emails)'), class: 'label-bold'
= f.text_field :commit_email_hostname, class: 'form-control'
@@ -27,4 +23,6 @@
- commit_email_hostname_docs_link = link_to _('Learn more'), help_page_path('user/admin_area/settings/email', anchor: 'custom-private-commit-email-hostname'), target: '_blank'
= _("This setting will update the hostname that is used to generate private commit emails. %{learn_more}").html_safe % { learn_more: commit_email_hostname_docs_link }
- = f.submit 'Save changes', class: "btn btn-success"
+ = render_if_exists 'admin/application_settings/email_additional_text_setting', form: f
+
+ = f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
new file mode 100644
index 00000000000..7587ecbf9d3
--- /dev/null
+++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
@@ -0,0 +1,51 @@
+%section.settings.as-external-auth.no-animate#js-external-auth-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('External authentication')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('External Classification Policy Authorization')
+ .settings-content
+
+ = form_for @application_setting, url: admin_application_settings_path(anchor: 'js-external-auth-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ .form-check
+ = f.check_box :external_authorization_service_enabled, class: 'form-check-input'
+ = f.label :external_authorization_service_enabled, class: 'form-check-label' do
+ = _('Enable classification control using an external service')
+ %span.form-text.text-muted
+ = external_authorization_description
+ = link_to icon('question-circle'), help_page_path('user/admin_area/settings/external_authorization')
+ .form-group
+ = f.label :external_authorization_service_url, _('Service URL'), class: 'label-bold'
+ = f.text_field :external_authorization_service_url, class: 'form-control'
+ %span.form-text.text-muted
+ = external_authorization_url_help_text
+ .form-group
+ = f.label :external_authorization_service_timeout, _('External authorization request timeout'), class: 'label-bold'
+ = f.number_field :external_authorization_service_timeout, class: 'form-control', min: 0.001, max: 10, step: 0.001
+ %span.form-text.text-muted
+ = external_authorization_timeout_help_text
+ = f.label :external_auth_client_cert, _('Client authentication certificate'), class: 'label-bold'
+ = f.text_area :external_auth_client_cert, class: 'form-control'
+ %span.form-text.text-muted
+ = external_authorization_client_certificate_help_text
+ .form-group
+ = f.label :external_auth_client_key, _('Client authentication key'), class: 'label-bold'
+ = f.text_area :external_auth_client_key, class: 'form-control'
+ %span.form-text.text-muted
+ = external_authorization_client_key_help_text
+ .form-group
+ = f.label :external_auth_client_key_pass, _('Client authentication key password'), class: 'label-bold'
+ = f.password_field :external_auth_client_key_pass, class: 'form-control'
+ %span.form-text.text-muted
+ = external_authorization_client_pass_help_text
+ .form-group
+ = f.label :external_authorization_service_default_label, _('Default classification label'), class: 'label-bold'
+ = f.text_field :external_authorization_service_default_label, class: 'form-control'
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_help_page.html.haml b/app/views/admin/application_settings/_help_page.html.haml
index 70c8c74cc5d..aa491c735d1 100644
--- a/app/views/admin/application_settings/_help_page.html.haml
+++ b/app/views/admin/application_settings/_help_page.html.haml
@@ -2,18 +2,20 @@
= form_errors(@application_setting)
%fieldset
+ = render_if_exists 'admin/application_settings/help_text_setting', form: f
+
.form-group
= f.label :help_page_text, class: 'label-bold'
= f.text_area :help_page_text, class: 'form-control', rows: 4
- .form-text.text-muted Markdown enabled
+ .form-text.text-muted= _('Markdown enabled')
.form-group
.form-check
= f.check_box :help_page_hide_commercial_content, class: 'form-check-input'
= f.label :help_page_hide_commercial_content, class: 'form-check-label' do
- Hide marketing-related entries from help
+ = _('Hide marketing-related entries from help')
.form-group
- = f.label :help_page_support_url, 'Support page URL', class: 'label-bold'
+ = f.label :help_page_support_url, _('Support page URL'), class: 'label-bold'
= f.text_field :help_page_support_url, class: 'form-control', placeholder: 'http://company.example.com/getting-help', :'aria-describedby' => 'support_help_block'
- %span.form-text.text-muted#support_help_block Alternate support URL for help page
+ %span.form-text.text-muted#support_help_block= _('Alternate support URL for help page')
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_logging.html.haml b/app/views/admin/application_settings/_logging.html.haml
index 41b787515b5..1da5f6fccd6 100644
--- a/app/views/admin/application_settings/_logging.html.haml
+++ b/app/views/admin/application_settings/_logging.html.haml
@@ -1,6 +1,12 @@
= form_for @application_setting, url: admin_application_settings_path(anchor: 'js-logging-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
+ %p
+ %strong
+ NOTE:
+ These settings will be removed from the UI in a GitLab 12.0 release and made available within gitlab.yml.
+ In addition, you will be able to define a Sentry Environment to differentiate between multiple deployments. For example, development, staging, and production.
+
%fieldset
.form-group
.form-check
diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml
index f4bfb5af385..dd56bb99a06 100644
--- a/app/views/admin/application_settings/_outbound.html.haml
+++ b/app/views/admin/application_settings/_outbound.html.haml
@@ -8,4 +8,12 @@
= f.label :allow_local_requests_from_hooks_and_services, class: 'form-check-label' do
Allow requests to the local network from hooks and services
+ .form-group
+ .form-check
+ = f.check_box :dns_rebinding_protection_enabled, class: 'form-check-input'
+ = f.label :dns_rebinding_protection_enabled, class: 'form-check-label' do
+ = _('Enforce DNS rebinding attack protection')
+ %span.form-text.text-muted
+ = _('Resolves IP addresses once and uses them to submit requests')
+
= f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml
index ad5c8d4da22..77795dbf913 100644
--- a/app/views/admin/application_settings/_pages.html.haml
+++ b/app/views/admin/application_settings/_pages.html.haml
@@ -5,16 +5,32 @@
.form-group
= f.label :max_pages_size, 'Maximum size of pages (MB)', class: 'label-bold'
= f.number_field :max_pages_size, class: 'form-control'
- .form-text.text-muted 0 for unlimited
+ .form-text.text-muted
+ = _("0 for unlimited")
.form-group
.form-check
= f.check_box :pages_domain_verification_enabled, class: 'form-check-input'
= f.label :pages_domain_verification_enabled, class: 'form-check-label' do
- Require users to prove ownership of custom domains
+ = _("Require users to prove ownership of custom domains")
.form-text.text-muted
- Domain verification is an essential security measure for public GitLab
- sites. Users are required to demonstrate they control a domain before
- it is enabled
+ = _("Domain verification is an essential security measure for public GitLab sites. Users are required to demonstrate they control a domain before it is enabled")
= link_to icon('question-circle'), help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+ - if Feature.enabled?(:pages_auto_ssl)
+ %h5
+ = _("Configure Let's Encrypt")
+ %p
+ - lets_encrypt_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: "https://letsencrypt.org/" }
+ = _("%{lets_encrypt_link_start}Let's Encrypt%{lets_encrypt_link_end} is a free, automated, and open certificate authority (CA), that give digital certificates in order to enable HTTPS (SSL/TLS) for websites.").html_safe % { lets_encrypt_link_start: lets_encrypt_link_start, lets_encrypt_link_end: '</a>'.html_safe }
+ .form-group
+ = f.label :lets_encrypt_notification_email, _("Email"), class: 'label-bold'
+ = f.text_field :lets_encrypt_notification_email, class: 'form-control'
+ .form-text.text-muted
+ = _("A Let's Encrypt account will be configured for this GitLab installation using your email address. You will receive emails to warn of expiring certificates.")
+ .form-group
+ .form-check
+ = f.check_box :lets_encrypt_terms_of_service_accepted, class: 'form-check-input'
+ = f.label :lets_encrypt_terms_of_service_accepted, class: 'form-check-label' do
+ - terms_of_service_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: lets_encrypt_terms_of_service_admin_application_settings_path }
+ = _("I have read and agree to the Let's Encrypt %{link_start}Terms of Service%{link_end}").html_safe % { link_start: terms_of_service_link_start, link_end: '</a>'.html_safe }
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_performance_bar.html.haml b/app/views/admin/application_settings/_performance_bar.html.haml
index 44ac8d94764..f992d531ea5 100644
--- a/app/views/admin/application_settings/_performance_bar.html.haml
+++ b/app/views/admin/application_settings/_performance_bar.html.haml
@@ -5,10 +5,10 @@
.form-group
.form-check
= f.check_box :performance_bar_enabled, class: 'form-check-input'
- = f.label :performance_bar_enabled, class: 'form-check-label' do
+ = f.label :performance_bar_enabled, class: 'form-check-label qa-enable-performance-bar-checkbox' do
Enable the Performance Bar
.form-group
= f.label :performance_bar_allowed_group_path, 'Allowed group', class: 'label-bold'
= f.text_field :performance_bar_allowed_group_path, class: 'form-control', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit 'Save changes', class: 'btn btn-success qa-save-changes-button'
diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
index 615aa6317b0..f2f2cd1282a 100644
--- a/app/views/admin/application_settings/_repository_mirrors_form.html.haml
+++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
@@ -3,13 +3,15 @@
%fieldset
.form-group
- = f.label :mirror_available, 'Enable mirror configuration', class: 'label-bold'
+ = f.label :mirror_available, _('Enable mirror configuration'), class: 'label-bold'
.form-check
= f.check_box :mirror_available, class: 'form-check-input'
= f.label :mirror_available, class: 'form-check-label' do
- Allow mirrors to be set up for projects
+ = _('Allow mirrors to be set up for projects')
%span.form-text.text-muted
- If disabled, only admins will be able to set up mirrors in projects.
+ = _('If disabled, only admins will be able to set up mirrors in projects.')
= link_to icon('question-circle'), help_page_path('workflow/repository_mirroring')
- = f.submit 'Save changes', class: "btn btn-success"
+ = render_if_exists 'admin/application_settings/mirror_settings', form: f
+
+ = f.submit _('Save changes'), class: "btn btn-success"
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 0725ffb7f6c..03ef2924617 100644
--- a/app/views/admin/application_settings/_visibility_and_access.html.haml
+++ b/app/views/admin/application_settings/_visibility_and_access.html.haml
@@ -5,6 +5,9 @@
.form-group
= f.label :default_branch_protection, class: 'label-bold'
= f.select :default_branch_protection, options_for_select(Gitlab::Access.protection_options, @application_setting.default_branch_protection), {}, class: 'form-control'
+ .form-group
+ = 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'
.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)
@@ -22,32 +25,33 @@
.form-check
= level
%span.form-text.text-muted#restricted-visibility-help
- Selected levels cannot be used by non-admin users for groups, projects or snippets.
- If the public level is restricted, user profiles are only visible to logged in users.
+ = _('Selected levels cannot be used by non-admin users for groups, projects or snippets. If the public level is restricted, user profiles are only visible to logged in users.')
.form-group
= f.label :import_sources, class: 'label-bold'
= hidden_field_tag 'application_setting[import_sources][]'
- import_sources_checkboxes('import-sources-help', class: 'form-check-input').each do |source|
.form-check= source
%span.form-text.text-muted#import-sources-help
- Enabled sources for code import during project creation. OmniAuth must be configured for GitHub
+ = _('Enabled sources for code import during project creation. OmniAuth must be configured for GitHub')
= link_to "(?)", help_page_path("integration/github")
, Bitbucket
= link_to "(?)", help_page_path("integration/bitbucket")
and GitLab.com
= link_to "(?)", help_page_path("integration/gitlab")
+ = render_if_exists 'admin/application_settings/ldap_access_setting', form: f
+
.form-group
.form-check
= f.check_box :project_export_enabled, class: 'form-check-input'
= f.label :project_export_enabled, class: 'form-check-label' do
- Project export enabled
+ = _('Project export enabled')
.form-group
- %label.label-bold Enabled Git access protocols
+ %label.label-bold= _('Enabled Git access protocols')
= 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.
+ = _('Allow only the selected protocols to be used for Git access.')
- ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type|
- field_name = :"#{type}_key_restriction"
@@ -55,4 +59,4 @@
= f.label field_name, "#{type.upcase} SSH keys", class: 'label-bold'
= f.select field_name, key_restriction_options_for_select(type), {}, class: 'form-control'
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml
index f50aca32bdf..d5ba6abe7af 100644
--- a/app/views/admin/application_settings/metrics_and_profiling.html.haml
+++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml
@@ -24,7 +24,7 @@
.settings-content
= render 'prometheus'
-%section.settings.as-performance-bar.no-animate#js-performance-bar-settings{ class: ('expanded' if expanded_by_default?) }
+%section.settings.qa-performance-bar-settings.as-performance-bar.no-animate#js-performance-bar-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4
= _('Profiling - Performance bar')
diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml
index fc9dd29b8ca..31f18ba0d56 100644
--- a/app/views/admin/application_settings/show.html.haml
+++ b/app/views/admin/application_settings/show.html.haml
@@ -68,7 +68,7 @@
.settings-content
= render 'terms'
-= render_if_exists 'admin/application_settings/external_authorization_service_form', expanded: expanded_by_default?
+= render 'admin/application_settings/external_authorization_service_form', expanded: expanded_by_default?
%section.settings.as-terminal.no-animate#js-terminal-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml
index 12690343f6e..21e84016c66 100644
--- a/app/views/admin/applications/_form.html.haml
+++ b/app/views/admin/applications/_form.html.haml
@@ -2,13 +2,15 @@
= form_errors(application)
= content_tag :div, class: 'form-group row' do
- = f.label :name, class: 'col-sm-2 col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :name
.col-sm-10
= f.text_field :name, class: 'form-control'
= doorkeeper_errors_for application, :name
= content_tag :div, class: 'form-group row' do
- = f.label :redirect_uri, class: 'col-sm-2 col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :redirect_uri
.col-sm-10
= f.text_area :redirect_uri, class: 'form-control'
= doorkeeper_errors_for application, :redirect_uri
@@ -21,14 +23,16 @@
for local tests
= content_tag :div, class: 'form-group row' do
- = f.label :trusted, class: 'col-sm-2 col-form-label pt-0'
+ .col-sm-2.col-form-label.pt-0
+ = f.label :trusted
.col-sm-10
= f.check_box :trusted
%span.form-text.text-muted
Trusted applications are automatically authorized on GitLab OAuth flow.
.form-group.row
- = f.label :scopes, class: 'col-sm-2 col-form-label pt-0'
+ .col-sm-2.col-form-label.pt-0
+ = f.label :scopes
.col-sm-10
= render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes
diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml
index df3eeba907c..180066723f1 100644
--- a/app/views/admin/applications/show.html.haml
+++ b/app/views/admin/applications/show.html.haml
@@ -11,7 +11,7 @@
%td
.clipboard-group
.input-group
- %input.label.label-monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true }
+ %input.label.label-monospace.monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true }
.input-group-append
= clipboard_button(target: '#application_id', title: _("Copy ID to clipboard"), class: "btn btn btn-default")
%tr
@@ -20,7 +20,7 @@
%td
.clipboard-group
.input-group
- %input.label.label-monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true }
+ %input.label.label-monospace.monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true }
.input-group-append
= clipboard_button(target: '#secret', title: _("Copy secret to clipboard"), class: "btn btn btn-default")
%tr
diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml
index c465d9f51d6..c8ee87c6212 100644
--- a/app/views/admin/broadcast_messages/_form.html.haml
+++ b/app/views/admin/broadcast_messages/_form.html.haml
@@ -10,28 +10,34 @@
= form_errors(@broadcast_message)
.form-group.row
- = f.label :message, class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :message
.col-sm-10
= f.text_area :message, class: "form-control js-autosize",
required: true,
+ dir: 'auto',
data: { preview_path: preview_admin_broadcast_messages_path }
.form-group.row.js-toggle-colors-container
.col-sm-10.offset-sm-2
= link_to 'Customize colors', '#', class: 'js-toggle-colors-link'
.form-group.row.js-toggle-colors-container.toggle-colors.hide
- = f.label :color, "Background Color", class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :color, "Background Color"
.col-sm-10
= f.color_field :color, class: "form-control"
.form-group.row.js-toggle-colors-container.toggle-colors.hide
- = f.label :font, "Font Color", class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :font, "Font Color"
.col-sm-10
= f.color_field :font, class: "form-control"
.form-group.row
- = f.label :starts_at, _("Starts at (UTC)"), class: 'col-form-label col-sm-2'
+ .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'
.form-group.row
- = f.label :ends_at, _("Ends at (UTC)"), class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :ends_at, _("Ends at (UTC)")
.col-sm-10.datetime-controls
= f.datetime_select :ends_at, {}, class: 'form-control form-control-inline'
.form-actions
diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml
index 9ef58faf8cc..eb4dfdf2858 100644
--- a/app/views/admin/broadcast_messages/index.html.haml
+++ b/app/views/admin/broadcast_messages/index.html.haml
@@ -32,7 +32,7 @@
%td
= message.ends_at
%td
- = link_to icon('pencil-square-o'), edit_admin_broadcast_message_path(message), title: 'Edit', class: 'btn btn-sm'
- = link_to icon('times'), admin_broadcast_message_path(message), method: :delete, remote: true, title: 'Remove', class: 'js-remove-tr btn btn-sm btn-danger'
+ = 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'
= paginate @broadcast_messages, theme: 'gitlab'
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 6756299cf43..581f6ae0714 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -22,9 +22,10 @@
%h3.text-center
Users:
= approximate_count_with_delimiters(@counts, User)
- = render_if_exists 'admin/dashboard/users_statistics'
%hr
- = link_to 'New user', new_admin_user_path, class: "btn btn-success"
+ .btn-group.d-flex{ role: 'group' }
+ = link_to 'New user', new_admin_user_path, class: "btn btn-success"
+ = render_if_exists 'admin/dashboard/users_statistics'
.col-sm-4
.info-well.dark-well
.well-segment.well-centered
@@ -162,7 +163,7 @@
%span.float-right
#{Rails::VERSION::STRING}
%p
- = Gitlab::Database.adapter_name
+ = Gitlab::Database.human_adapter_name
%span.float-right
= Gitlab::Database.version
%p
diff --git a/app/views/admin/deploy_keys/edit.html.haml b/app/views/admin/deploy_keys/edit.html.haml
index 7c04ef03947..99d8af65068 100644
--- a/app/views/admin/deploy_keys/edit.html.haml
+++ b/app/views/admin/deploy_keys/edit.html.haml
@@ -1,10 +1,10 @@
-- page_title 'Edit Deploy Key'
-%h3.page-title Edit public deploy key
+- page_title _('Edit Deploy Key')
+%h3.page-title= _('Edit public deploy key')
%hr
%div
= form_for [:admin, @deploy_key], html: { class: 'deploy-key-form' } do |f|
= render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
.form-actions
- = f.submit 'Save changes', class: 'btn-success btn'
- = link_to 'Cancel', admin_deploy_keys_path, class: 'btn btn-cancel'
+ = f.submit _('Save changes'), class: 'btn-success btn'
+ = link_to _('Cancel'), admin_deploy_keys_path, class: 'btn btn-cancel'
diff --git a/app/views/admin/deploy_keys/index.html.haml b/app/views/admin/deploy_keys/index.html.haml
index 01013be06d6..9fffa97f969 100644
--- a/app/views/admin/deploy_keys/index.html.haml
+++ b/app/views/admin/deploy_keys/index.html.haml
@@ -1,19 +1,19 @@
-- page_title "Deploy Keys"
+- page_title _('Deploy Keys')
%h3.page-title.deploy-keys-title
- Public deploy keys (#{@deploy_keys.count})
+ = _('Public deploy keys (%{deploy_keys_count})') % { deploy_keys_count: @deploy_keys.count }
.float-right
- = link_to 'New deploy key', new_admin_deploy_key_path, class: 'btn btn-success btn-sm btn-inverted'
+ = link_to _('New deploy key'), new_admin_deploy_key_path, class: 'btn btn-success btn-sm btn-inverted'
- if @deploy_keys.any?
.table-holder.deploy-keys-list
%table.table
%thead
%tr
- %th.col-sm-2 Title
- %th.col-sm-4 Fingerprint
- %th.col-sm-2 Projects with write access
- %th.col-sm-2 Added at
+ %th.col-sm-2= _('Title')
+ %th.col-sm-4= _('Fingerprint')
+ %th.col-sm-2= _('Projects with write access')
+ %th.col-sm-2= _('Added at')
%th.col-sm-2
%tbody
- @deploy_keys.each do |deploy_key|
@@ -27,8 +27,8 @@
= link_to project.full_name, admin_project_path(project), class: 'label deploy-project-label'
%td
%span.cgray
- added #{time_ago_with_tooltip(deploy_key.created_at)}
+ = _('added %{created_at_timeago}').html_safe % { created_at_timeago: time_ago_with_tooltip(deploy_key.created_at) }
%td
.float-right
- = link_to 'Edit', edit_admin_deploy_key_path(deploy_key), class: 'btn btn-sm'
- = link_to 'Remove', admin_deploy_key_path(deploy_key), data: { confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-sm btn-remove delete-key'
+ = link_to _('Edit'), edit_admin_deploy_key_path(deploy_key), class: 'btn btn-sm'
+ = link_to _('Remove'), admin_deploy_key_path(deploy_key), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn btn-sm btn-remove delete-key'
diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml
index 5e05568e384..dd01ef8a29f 100644
--- a/app/views/admin/groups/_form.html.haml
+++ b/app/views/admin/groups/_form.html.haml
@@ -2,13 +2,14 @@
= form_errors(@group)
= render 'shared/group_form', f: f
- = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group
+ = render_if_exists 'shared/old_repository_size_limit_setting', form: f, type: :group
= render_if_exists 'admin/namespace_plan', f: f
.form-group.row.group-description-holder
- = f.label :avatar, _("Group avatar"), class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :avatar, _("Group avatar")
.col-sm-10
- = render 'shared/choose_group_avatar_button', f: f
+ = render 'shared/choose_avatar_button', f: f
= render 'shared/old_visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group, with_label: false
diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml
index 00d255846f9..f524d35d79e 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -44,12 +44,10 @@
%li
%span.light= _('Storage:')
- - counter_storage = storage_counter(@group.storage_size)
- - counter_repositories = storage_counter(@group.repository_size)
- - counter_build_artifacts = storage_counter(@group.build_artifacts_size)
- - counter_lfs_objects = storage_counter(@group.lfs_objects_size)
- %strong
- = _("%{counter_storage} (%{counter_repositories} repositories, %{counter_build_artifacts} build artifacts, %{counter_lfs_objects} LFS)") % { counter_storage: counter_storage, counter_repositories: counter_repositories, counter_build_artifacts: counter_build_artifacts, counter_lfs_objects: counter_lfs_objects }
+ %strong= storage_counter(@group.storage_size)
+ (
+ = storage_counters_details(@group)
+ )
%li
%span.light= _('Group Git LFS status:')
diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml
index 0f5e97e288a..ac56e354a4d 100644
--- a/app/views/admin/health_check/show.html.haml
+++ b/app/views/admin/health_check/show.html.haml
@@ -23,7 +23,7 @@
%code= liveness_url(token: Gitlab::CurrentSettings.health_check_access_token)
%li
%code= metrics_url(token: Gitlab::CurrentSettings.health_check_access_token)
-
+ = render_if_exists 'admin/health_check/health_check_url'
%hr
.card
.card-header
diff --git a/app/views/admin/identities/_form.html.haml b/app/views/admin/identities/_form.html.haml
index 3ab7990d9e2..40a7014e143 100644
--- a/app/views/admin/identities/_form.html.haml
+++ b/app/views/admin/identities/_form.html.haml
@@ -2,12 +2,14 @@
= form_errors(@identity)
.form-group.row
- = f.label :provider, class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :provider
.col-sm-10
- values = Gitlab::Auth::OAuth::Provider.providers.map { |name| ["#{Gitlab::Auth::OAuth::Provider.label_for(name)} (#{name})", name] }
= f.select :provider, values, { allow_blank: false }, class: 'form-control'
.form-group.row
- = f.label :extern_uid, _("Identifier"), class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :extern_uid, _("Identifier")
.col-sm-10
= f.text_field :extern_uid, class: 'form-control', required: true
diff --git a/app/views/admin/labels/_form.html.haml b/app/views/admin/labels/_form.html.haml
index 5e7b4817461..299d0a12e6c 100644
--- a/app/views/admin/labels/_form.html.haml
+++ b/app/views/admin/labels/_form.html.haml
@@ -2,15 +2,18 @@
= form_errors(@label)
.form-group.row
- = f.label :title, class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :title
.col-sm-10
= f.text_field :title, class: "form-control", required: true
.form-group.row
- = f.label :description, class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :description
.col-sm-10
= f.text_field :description, class: "form-control js-quick-submit"
.form-group.row
- = f.label :color, _("Background color"), class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :color, _("Background color")
.col-sm-10
.input-group
.input-group-prepend
@@ -21,10 +24,7 @@
%br
= _("Or you can choose one of the suggested colors below")
- .suggest-colors
- - suggested_colors.each do |color|
- = link_to '#', style: "background-color: #{color}", data: { color: color } do
- &nbsp;
+ = render_suggested_colors
.form-actions
= f.submit _('Save'), class: 'btn btn-success js-save-button'
diff --git a/app/views/admin/labels/_label.html.haml b/app/views/admin/labels/_label.html.haml
index dbb7224f5f9..6d934654c5d 100644
--- a/app/views/admin/labels/_label.html.haml
+++ b/app/views/admin/labels/_label.html.haml
@@ -1,5 +1,5 @@
%li.label-list-item{ id: dom_id(label) }
- = render "shared/label_row", label: label
+ = render "shared/label_row", label: label.present(issuable_subject: nil)
.label-actions-list
= link_to edit_admin_label_path(label), class: 'btn btn-transparent label-action has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do
= sprite_icon('pencil')
diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml
index 5bc695aa7b5..2f7ad35eb3e 100644
--- a/app/views/admin/projects/_projects.html.haml
+++ b/app/views/admin/projects/_projects.html.haml
@@ -7,17 +7,17 @@
= link_to 'Edit', edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn"
%button.delete-project-button.btn.btn-danger{ data: { toggle: 'modal',
target: '#delete-project-modal',
- delete_project_url: project_path(project),
+ delete_project_url: admin_project_path(project),
project_name: project.name }, type: 'button' }
= s_('AdminProjects|Delete')
.stats
%span.badge.badge-pill
- = storage_counter(project.statistics.storage_size)
+ = storage_counter(project.statistics&.storage_size)
- if project.archived
%span.badge.badge-warning archived
.title
- = link_to(admin_namespace_project_path(project.namespace, project)) do
+ = link_to(admin_project_path(project)) do
.dash-project-avatar
.avatar-container.rect-avatar.s40
= project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40)
diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml
index 46bb57c78a8..b88b760536d 100644
--- a/app/views/admin/projects/index.html.haml
+++ b/app/views/admin/projects/index.html.haml
@@ -7,7 +7,7 @@
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
.prepend-top-default
.search-holder
- = render 'shared/projects/search_form', autofocus: true, icon: true
+ = render 'shared/projects/search_form', autofocus: true, icon: true, admin_view: true
.dropdown
- toggle_text = 'Namespace'
- if params[:namespace_id].present?
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index 03cce4745aa..e23accc1ea9 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -73,16 +73,11 @@
= @project.repository.relative_path
%li
- %span.light Storage used:
- %strong= storage_counter(@project.statistics.storage_size)
- (
- = storage_counter(@project.statistics.repository_size)
- repository,
- = storage_counter(@project.statistics.build_artifacts_size)
- build artifacts,
- = storage_counter(@project.statistics.lfs_objects_size)
- LFS
- )
+ %span.light= _('Storage:')
+ %strong= storage_counter(@project.statistics&.storage_size)
+ - if @project.statistics
+ = surround '(', ')' do
+ = storage_counters_details(@project.statistics)
%li
%span.light last commit:
@@ -105,6 +100,8 @@
%span.light archived:
%strong project is read-only
+ = render_if_exists "shared_runner_status", project: @project
+
%li
%span.light access:
%strong
@@ -120,7 +117,8 @@
.card-body
= form_for @project, url: transfer_admin_project_path(@project), method: :put do |f|
.form-group.row
- = f.label :new_namespace_id, "Namespace", class: 'col-form-label col-sm-3'
+ .col-sm-3.col-form-label
+ = f.label :new_namespace_id, "Namespace"
.col-sm-9
.dropdown
= dropdown_toggle('Search for Namespace', { toggle: 'dropdown', field_name: 'new_namespace_id' }, { toggle_class: 'js-namespace-select large' })
diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml
index 4641986cb56..423472324fe 100644
--- a/app/views/admin/runners/_runner.html.haml
+++ b/app/views/admin/runners/_runner.html.haml
@@ -49,8 +49,8 @@
.table-section.section-10.section-wrap
.table-mobile-header{ role: 'rowheader' }= _('Tags')
.table-mobile-content
- - runner.tag_list.sort.each do |tag|
- %span.badge.badge-primary
+ - runner.tags.map(&:name).sort.each do |tag|
+ %span.badge.badge-primary.str-truncated.has-tooltip{ title: tag }
= tag
.table-section.section-10
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index 81380587fd2..2e23b748edb 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -92,6 +92,25 @@
= button_tag class: %w[btn btn-link] do
= runner_type.titleize
+ #js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ - Ci::Runner::AVAILABLE_TYPES.each do |runner_type|
+ %li.filter-dropdown-item{ data: { value: runner_type } }
+ = button_tag class: %w[btn btn-link] do
+ = runner_type.titleize
+
+ #js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'none' } }
+ %button.btn.btn-link
+ = _('No Tag')
+ %li.divider.droplab-item-ignore
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ %button.btn.btn-link.js-data-value
+ %span.dropdown-light-content
+ {{name}}
+
= button_tag class: %w[clear-search hidden] do
= icon('times')
.filter-dropdown-container
diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml
index 12e24ddef02..77729636f9d 100644
--- a/app/views/admin/users/_access_levels.html.haml
+++ b/app/views/admin/users/_access_levels.html.haml
@@ -1,18 +1,20 @@
%fieldset
%legend Access
.form-group.row
- .col-sm-2.text-right
- = f.label :projects_limit, class: 'col-form-label'
- .col-sm-10= f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control'
+ .col-sm-2.col-form-label
+ = f.label :projects_limit
+ .col-sm-10
+ = f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control'
.form-group.row
- .col-sm-2.text-right
- = f.label :can_create_group, class: 'col-form-label'
- .col-sm-10= f.check_box :can_create_group
+ .col-sm-2.col-form-label
+ = f.label :can_create_group
+ .col-sm-10
+ = f.check_box :can_create_group
.form-group.row
- .col-sm-2.text-right
- = f.label :access_level, class: 'col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :access_level
.col-sm-10
- editing_current_user = (current_user == @user)
@@ -22,6 +24,8 @@
%p.light
Regular users have access to their groups and projects
+ = render_if_exists 'admin/users/auditor_access_level_radio', f: f, disabled: editing_current_user
+
= f.radio_button :access_level, :admin, disabled: editing_current_user
= label_tag :admin, class: 'font-weight-bold' do
Admin
@@ -32,8 +36,8 @@
You cannot remove your own admin rights.
.form-group.row
- .col-sm-2.text-right
- = f.label :external, class: 'col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :external
.hidden{ data: user_internal_regex_data }
.col-sm-10
= f.check_box :external do
diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml
index 296ef073144..3281718071c 100644
--- a/app/views/admin/users/_form.html.haml
+++ b/app/views/admin/users/_form.html.haml
@@ -5,20 +5,20 @@
%fieldset
%legend Account
.form-group.row
- .col-sm-2.text-right
- = f.label :name, class: 'col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :name
.col-sm-10
= f.text_field :name, required: true, autocomplete: 'off', class: 'form-control'
%span.help-inline * required
.form-group.row
- .col-sm-2.text-right
- = f.label :username, class: 'col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :username
.col-sm-10
= f.text_field :username, required: true, autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false, class: 'form-control'
%span.help-inline * required
.form-group.row
- .col-sm-2.text-right
- = f.label :email, class: 'col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :email
.col-sm-10
= f.text_field :email, required: true, autocomplete: 'off', class: 'form-control'
%span.help-inline * required
@@ -27,8 +27,8 @@
%fieldset
%legend Password
.form-group.row
- .col-sm-2.text-right
- = f.label :password, class: 'col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :password
.col-sm-10
%strong
Reset link will be generated and sent to the user.
@@ -38,40 +38,52 @@
%fieldset
%legend Password
.form-group.row
- .col-sm-2.text-right
- = f.label :password, class: 'col-form-label'
- .col-sm-10= f.password_field :password, disabled: f.object.force_random_password, class: 'form-control'
+ .col-sm-2.col-form-label
+ = f.label :password
+ .col-sm-10
+ = f.password_field :password, disabled: f.object.force_random_password, class: 'form-control'
.form-group.row
- .col-sm-2.text-right
- = f.label :password_confirmation, class: 'col-form-label'
- .col-sm-10= f.password_field :password_confirmation, disabled: f.object.force_random_password, class: 'form-control'
+ .col-sm-2.col-form-label
+ = f.label :password_confirmation
+ .col-sm-10
+ = f.password_field :password_confirmation, disabled: f.object.force_random_password, class: 'form-control'
= render partial: 'access_levels', locals: { f: f }
+ = render_if_exists 'admin/users/namespace_plan_fieldset', f: f
+
+ = render_if_exists 'admin/users/limits', f: f
+
%fieldset
%legend Profile
.form-group.row
- .col-sm-2.text-right
- = f.label :avatar, class: 'col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :avatar
.col-sm-10
= f.file_field :avatar
.form-group.row
- .col-sm-2.text-right
- = f.label :skype, class: 'col-form-label'
- .col-sm-10= f.text_field :skype, class: 'form-control'
+ .col-sm-2.col-form-label
+ = f.label :skype
+ .col-sm-10
+ = f.text_field :skype, class: 'form-control'
.form-group.row
- .col-sm-2.text-right
- = f.label :linkedin, class: 'col-form-label'
- .col-sm-10= f.text_field :linkedin, class: 'form-control'
+ .col-sm-2.col-form-label
+ = f.label :linkedin
+ .col-sm-10
+ = f.text_field :linkedin, class: 'form-control'
.form-group.row
- .col-sm-2.text-right
- = f.label :twitter, class: 'col-form-label'
- .col-sm-10= f.text_field :twitter, class: 'form-control'
+ .col-sm-2.col-form-label
+ = f.label :twitter
+ .col-sm-10
+ = f.text_field :twitter, class: 'form-control'
.form-group.row
- .col-sm-2.text-right
- = f.label :website_url, 'Website', class: 'col-form-label'
- .col-sm-10= f.text_field :website_url, class: 'form-control'
+ .col-sm-2.col-form-label
+ = f.label :website_url
+ .col-sm-10
+ = f.text_field :website_url, class: 'form-control'
+
+ = render_if_exists 'admin/users/admin_notes', f: f
.form-actions
- if @user.new_record?
diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml
index a733f420d11..e7dde7985fd 100644
--- a/app/views/admin/users/_head.html.haml
+++ b/app/views/admin/users/_head.html.haml
@@ -6,6 +6,7 @@
%span.cred (Internal)
- if @user.admin
%span.cred (Admin)
+ = render_if_exists 'admin/users/audtior_user_badge'
.float-right
- if impersonation_enabled? && @user != current_user && @user.can?(:log_in)
diff --git a/app/views/admin/users/_user_detail.html.haml b/app/views/admin/users/_user_detail.html.haml
index 3319b4bad3a..13d10dcd625 100644
--- a/app/views/admin/users/_user_detail.html.haml
+++ b/app/views/admin/users/_user_detail.html.haml
@@ -6,7 +6,7 @@
= image_tag avatar_icon_for_user(user), class: 'avatar s16 d-xs-flex d-md-none mr-1 prepend-top-2', alt: _('Avatar for %{name}') % { name: sanitize_name(user.name) }
= link_to user.name, admin_user_path(user), class: 'text-plain js-user-link', data: { user_id: user.id }
- = render_if_exists 'admin/users/user_detail_note', user: user
+ = render_if_exists 'admin/users/user_listing_note', user: user
- user_badges_in_admin_section(user).each do |badge|
- css_badge = "badge badge-#{badge[:variant]}" if badge[:variant].present?
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index a74e052707f..dcd6f7c8078 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -53,6 +53,8 @@
- else
Disabled
+ = render_if_exists 'admin/namespace_plan_info', namespace: @user.namespace
+
%li
%span.light External User:
%strong
@@ -117,6 +119,13 @@
%strong
= @user.sign_in_count
+ %li
+ %span.light= _("Highest role:")
+ %strong
+ = Gitlab::Access.human_access_with_none(@user.highest_role)
+
+ = render_if_exists 'admin/users/using_license_seat', user: @user
+
- if @user.ldap_user?
%li
%span.light LDAP uid:
@@ -129,6 +138,8 @@
%strong
= link_to @user.created_by.name, [:admin, @user.created_by]
+ = render_if_exists partial: "namespaces/shared_runner_status", locals: { namespace: @user.namespace }
+
.col-md-6
- unless @user == current_user
- unless @user.confirmed?
@@ -141,6 +152,9 @@
%p This user has an unconfirmed email address#{email}. You may force a confirmation.
%br
= link_to 'Confirm user', confirm_admin_user_path(@user), method: :put, class: "btn btn-info", data: { confirm: 'Are you sure?' }
+
+ = render_if_exists 'admin/users/user_detail_note'
+
- if @user.blocked?
.card.border-info
.card-header.bg-info.text-white
diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml
index 8d9c083d223..60ca7e4e267 100644
--- a/app/views/award_emoji/_awards_block.html.haml
+++ b/app/views/award_emoji/_awards_block.html.haml
@@ -13,7 +13,7 @@
%button.btn.award-control.has-tooltip.js-add-award{ type: 'button',
'aria-label': _('Add reaction'),
data: { title: _('Add reaction') } }
- %span{ class: "award-control-icon award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face')
- %span{ class: "award-control-icon award-control-icon-positive" }= custom_icon('emoji_smiley')
- %span{ class: "award-control-icon award-control-icon-super-positive" }= custom_icon('emoji_smile')
+ %span{ class: "award-control-icon award-control-icon-neutral" }= sprite_icon('slight-smile')
+ %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")
diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml
index c787d7420b7..369b0f7e62c 100644
--- a/app/views/ci/status/_dropdown_graph_badge.html.haml
+++ b/app/views/ci/status/_dropdown_graph_badge.html.haml
@@ -6,14 +6,14 @@
- tooltip = "#{subject.name} - #{status.status_tooltip}"
- if status.has_details?
- = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do
+ = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item d-flex', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do
%span{ class: klass }= sprite_icon(status.icon)
- %span.ci-build-text= subject.name
+ %span.ci-build-text.text-truncate.mw-70p.gl-pl-1= subject.name
- else
- .menu-item.mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', title: tooltip, container: 'body' } }
+ .menu-item.mini-pipeline-graph-dropdown-item.d-flex{ data: { toggle: 'tooltip', title: tooltip, container: 'body' } }
%span{ class: klass }= sprite_icon(status.icon)
- %span.ci-build-text= subject.name
+ %span.ci-build-text.text-truncate.mw-70p.gl-pl-1= subject.name
- if status.has_action?
= link_to status.action_path, class: "ci-action-icon-container ci-action-icon-wrapper js-ci-action-icon", method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do
diff --git a/app/views/ci/status/_icon.html.haml b/app/views/ci/status/_icon.html.haml
new file mode 100644
index 00000000000..f38bdb2e5ed
--- /dev/null
+++ b/app/views/ci/status/_icon.html.haml
@@ -0,0 +1,16 @@
+- status = local_assigns.fetch(:status)
+- size = local_assigns.fetch(:size, 16)
+- type = local_assigns.fetch(:type, 'pipeline')
+- tooltip_placement = local_assigns.fetch(:tooltip_placement, "left")
+- path = local_assigns.fetch(:path, status.has_details? ? status.details_path : nil)
+- css_classes = "ci-status-link ci-status-icon ci-status-icon-#{status.group} has-tooltip"
+- title = s_("PipelineStatusTooltip|Pipeline: %{ci_status}") % {ci_status: status.label}
+- if type == 'commit'
+ - title = s_("PipelineStatusTooltip|Commit: %{ci_status}") % {ci_status: status.label}
+
+- if path
+ = link_to path, class: css_classes, title: title, data: { placement: tooltip_placement } do
+ = sprite_icon(status.icon, size: size)
+- else
+ %span{ class: css_classes, title: title, data: { placement: tooltip_placement } }
+ = sprite_icon(status.icon, size: size)
diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml
index 90c59bec975..0b5c1a806b2 100644
--- a/app/views/ci/variables/_content.html.haml
+++ b/app/views/ci/variables/_content.html.haml
@@ -1,3 +1,3 @@
-= _('Environment variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use environment variables for passwords, secret keys, or whatever you want.')
+= _('Environment variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. Additionally, they can be masked so they are hidden in job logs, though they must match certain regexp requirements to do so. You can use environment variables for passwords, secret keys, or whatever you want.')
= _('You may also add variables that are made available to the running application by prepending the variable key with <code>K8S_SECRET_</code>.').html_safe
= link_to _('More information'), help_page_path('ci/variables/README', anchor: 'variables')
diff --git a/app/views/ci/variables/_header.html.haml b/app/views/ci/variables/_header.html.haml
index cb7779e2175..dbfa0a9e5a1 100644
--- a/app/views/ci/variables/_header.html.haml
+++ b/app/views/ci/variables/_header.html.haml
@@ -1,7 +1,7 @@
- expanded = local_assigns.fetch(:expanded)
%h4
- = _('Environment variables')
+ = _('Variables')
= link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'variables'), target: '_blank', rel: 'noopener noreferrer'
%button.btn.btn-default.js-settings-toggle{ type: 'button' }
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index dc9ccb6cc39..94102b4dcd0 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -6,10 +6,11 @@
= s_('Environment variables are configured by your administrator to be %{link_start}protected%{link_end} by default').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
.row
- .col-lg-12.js-ci-variable-list-section{ data: { save_endpoint: save_endpoint } }
+ .col-lg-12.js-ci-variable-list-section{ data: { save_endpoint: save_endpoint, maskable_regex: ci_variable_maskable_regex } }
.hide.alert.alert-danger.js-ci-variable-error-box
%ul.ci-variable-list
+ = render 'ci/variables/variable_header'
- @variables.each.each do |variable|
= render 'ci/variables/variable_row', form_field: 'variables', variable: variable
= render 'ci/variables/variable_row', form_field: 'variables'
diff --git a/app/views/ci/variables/_variable_header.html.haml b/app/views/ci/variables/_variable_header.html.haml
new file mode 100644
index 00000000000..d3b7a5ae883
--- /dev/null
+++ b/app/views/ci/variables/_variable_header.html.haml
@@ -0,0 +1,16 @@
+- only_key_value = local_assigns.fetch(:only_key_value, false)
+
+%li.ci-variable-row.m-0.d-none.d-sm-block
+ .d-flex.w-100.align-items-center.pb-2
+ .bold.table-section.section-15.append-right-10
+ = s_('CiVariables|Type')
+ .bold.table-section.section-15.append-right-10
+ = s_('CiVariables|Key')
+ .bold.table-section.section-15.append-right-10
+ = s_('CiVariables|Value')
+ - unless only_key_value
+ .bold.table-section.section-20
+ = s_('CiVariables|State')
+ .bold.table-section.section-20
+ = s_('CiVariables|Masked')
+ = render_if_exists 'ci/variables/environment_scope_header'
diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml
index 16a7527c8ce..ed4bd5ae19e 100644
--- a/app/views/ci/variables/_variable_row.html.haml
+++ b/app/views/ci/variables/_variable_row.html.haml
@@ -3,35 +3,45 @@
- only_key_value = local_assigns.fetch(:only_key_value, false)
- id = variable&.id
+- variable_type = variable&.variable_type
- key = variable&.key
- value = variable&.value
- is_protected_default = ci_variable_protected_by_default?
- is_protected = ci_variable_protected?(variable, only_key_value)
+- is_masked_default = false
+- is_masked = ci_variable_masked?(variable, only_key_value)
- id_input_name = "#{form_field}[variables_attributes][][id]"
- destroy_input_name = "#{form_field}[variables_attributes][][_destroy]"
+- variable_type_input_name = "#{form_field}[variables_attributes][][variable_type]"
- key_input_name = "#{form_field}[variables_attributes][][key]"
- value_input_name = "#{form_field}[variables_attributes][][secret_value]"
- protected_input_name = "#{form_field}[variables_attributes][][protected]"
+- masked_input_name = "#{form_field}[variables_attributes][][masked]"
%li.js-row.ci-variable-row{ data: { is_persisted: "#{!id.nil?}" } }
- .ci-variable-row-body
+ .ci-variable-row-body.border-bottom
%input.js-ci-variable-input-id{ type: "hidden", name: id_input_name, value: id }
%input.js-ci-variable-input-destroy{ type: "hidden", name: destroy_input_name }
- %input.js-ci-variable-input-key.ci-variable-body-item.qa-ci-variable-input-key.form-control{ type: "text",
+ %select.js-ci-variable-input-variable-type.ci-variable-body-item.form-control.select-control.custom-select.table-section.section-15{ name: variable_type_input_name }
+ = options_for_select(ci_variable_type_options, variable_type)
+ %input.js-ci-variable-input-key.ci-variable-body-item.qa-ci-variable-input-key.form-control.table-section.section-15{ type: "text",
name: key_input_name,
value: key,
placeholder: s_('CiVariables|Input variable key') }
- .ci-variable-body-item
+ .ci-variable-body-item.gl-show-field-errors.table-section.section-15.border-top-0.p-0
.form-control.js-secret-value-placeholder.qa-ci-variable-input-value{ class: ('hide' unless id) }
- = '*' * 20
+ = '*' * 17
%textarea.js-ci-variable-input-value.js-secret-value.qa-ci-variable-input-value.form-control{ class: ('hide' if id),
rows: 1,
name: value_input_name,
placeholder: s_('CiVariables|Input variable value') }
= value
+ %p.masking-validation-error.gl-field-error.hide
+ = s_("CiVariables|Cannot use Masked Variable with current value")
+ = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'masked-variables'), target: '_blank', rel: 'noopener noreferrer'
- unless only_key_value
- .ci-variable-body-item.ci-variable-protected-item
+ .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',
@@ -45,6 +55,20 @@
%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") }
+ %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{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') }
- = icon('minus-circle')
+ %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 7037c80aa6b..8005dcbf65f 100644
--- a/app/views/clusters/clusters/_advanced_settings.html.haml
+++ b/app/views/clusters/clusters/_advanced_settings.html.haml
@@ -1,5 +1,5 @@
- if can?(current_user, :admin_cluster, @cluster)
- - if @cluster.managed?
+ - unless @cluster.provided_by_user?
.append-bottom-20
%label.append-bottom-10
= s_('ClusterIntegration|Google Kubernetes Engine')
diff --git a/app/views/clusters/clusters/_banner.html.haml b/app/views/clusters/clusters/_banner.html.haml
index 160c5f009a7..a5de67be96b 100644
--- a/app/views/clusters/clusters/_banner.html.haml
+++ b/app/views/clusters/clusters/_banner.html.haml
@@ -5,5 +5,17 @@
.hidden.js-cluster-creating.bs-callout.bs-callout-info{ role: 'alert' }
= s_('ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine...')
+.hidden.row.js-cluster-api-unreachable.bs-callout.bs-callout-warning{ role: 'alert' }
+ .col-11
+ = 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
+ = 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 on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details")
diff --git a/app/views/clusters/clusters/_form.html.haml b/app/views/clusters/clusters/_form.html.haml
index 9fb91a39387..455322b2089 100644
--- a/app/views/clusters/clusters/_form.html.haml
+++ b/app/views/clusters/clusters/_form.html.haml
@@ -33,11 +33,11 @@
- auto_devops_url = help_page_path('topics/autodevops/index')
- auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url }
= s_('ClusterIntegration|Specifying a domain will allow you to use Auto Review Apps and Auto Deploy stages for %{auto_devops_start}Auto DevOps%{auto_devops_end}. The domain should have a wildcard DNS configured matching the domain.').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe }
- - if @cluster.application_ingress_external_ip.present?
+ %span{ :class => ["js-ingress-domain-help-text", ("hide" unless @cluster.application_ingress_external_ip.present?)] }
= s_('ClusterIntegration|Alternatively')
- %code #{@cluster.application_ingress_external_ip}.nip.io
+ %code{ :class => "js-ingress-domain-snippet" } #{@cluster.application_ingress_external_ip}.nip.io
= s_('ClusterIntegration| can be used instead of a custom domain.')
- - custom_domain_url = help_page_path('user/project/clusters/index', anchor: 'pointing-your-dns-at-the-cluster-ip')
+ - custom_domain_url = help_page_path('user/project/clusters/index', anchor: 'pointing-your-dns-at-the-external-endpoint')
- custom_domain_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: custom_domain_url }
= s_('ClusterIntegration| %{custom_domain_start}More information%{custom_domain_end}.').html_safe % { custom_domain_start: custom_domain_start, custom_domain_end: '</a>'.html_safe }
diff --git a/app/views/clusters/clusters/_sidebar.html.haml b/app/views/clusters/clusters/_sidebar.html.haml
index 6e4415c21a9..60ccad5b943 100644
--- a/app/views/clusters/clusters/_sidebar.html.haml
+++ b/app/views/clusters/clusters/_sidebar.html.haml
@@ -4,3 +4,5 @@
= clusterable.sidebar_text
%p
= clusterable.learn_more_link
+
+= render_if_exists 'clusters/multiple_clusters_message'
diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml
index 8ed4666e79a..70e2eaeaf3b 100644
--- a/app/views/clusters/clusters/gcp/_form.html.haml
+++ b/app/views/clusters/clusters/gcp/_form.html.haml
@@ -7,23 +7,27 @@
- help_link_end = ' %{external_link_icon}</a>'.html_safe % { external_link_icon: external_link_icon }
%p
- - link_to_help_page = link_to(s_('ClusterIntegration|help page'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
- = s_('ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration.').html_safe % { link_to_help_page: link_to_help_page}
+ - link_to_help_page = link_to(s_('ClusterIntegration|help page'),
+ help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
+ = s_('ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration.').html_safe % { link_to_help_page: link_to_help_page }
%p= link_to('Select a different Google account', @authorize_url)
-= form_for @gcp_cluster, html: { class: 'js-gke-cluster-creation prepend-top-20', data: { token: token_in_session } }, url: clusterable.create_gcp_clusters_path, as: :cluster do |field|
- = form_errors(@gcp_cluster)
- .form-group
- = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold'
- = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name')
- .form-group
- = field.label :environment_scope, s_('ClusterIntegration|Environment scope'), class: 'label-bold'
- = field.text_field :environment_scope, class: 'form-control', readonly: !has_multiple_clusters?, placeholder: s_('ClusterIntegration|Environment scope')
+= bootstrap_form_for @gcp_cluster, html: { class: 'gl-show-field-errors js-gke-cluster-creation prepend-top-20',
+ data: { token: token_in_session } }, url: clusterable.create_gcp_clusters_path, as: :cluster do |field|
+ = field.text_field :name, required: true, title: s_('ClusterIntegration|Cluster name is required.'),
+ label: s_('ClusterIntegration|Kubernetes cluster name'), label_class: 'label-bold'
+ - if has_multiple_clusters?
+ = field.form_group :environment_scope, label: { text: s_('ClusterIntegration|Environment scope'),
+ class: 'label-bold' } do
+ = field.text_field :environment_scope, required: true, class: 'form-control',
+ title: 'Environment scope is required.', wrapper: false
+ .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster.")
= field.fields_for :provider_gcp, @gcp_cluster.provider_gcp do |provider_gcp_field|
.form-group
- = provider_gcp_field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project'), class: 'label-bold'
+ = provider_gcp_field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project'),
+ class: 'label-bold'
.js-gcp-project-id-dropdown-entry-point{ data: { docsUrl: 'https://console.cloud.google.com/home/dashboard' } }
= provider_gcp_field.hidden_field :gcp_project_id
.dropdown
@@ -45,9 +49,9 @@
%p.form-text.text-muted
= s_('ClusterIntegration|Learn more about %{help_link_start}zones%{help_link_end}.').html_safe % { help_link_start: help_link_start % { url: zones_link_url }, help_link_end: help_link_end }
- .form-group
- = provider_gcp_field.label :num_nodes, s_('ClusterIntegration|Number of nodes'), class: 'label-bold'
- = provider_gcp_field.text_field :num_nodes, class: 'form-control', placeholder: '3'
+ = provider_gcp_field.number_field :num_nodes, required: true, placeholder: '3',
+ title: s_('ClusterIntegration|Number of nodes must be a numerical value.'),
+ label: s_('ClusterIntegration|Number of nodes'), label_class: 'label-bold'
.form-group
= provider_gcp_field.label :machine_type, s_('ClusterIntegration|Machine type'), class: 'label-bold'
@@ -62,13 +66,21 @@
= s_('ClusterIntegration|Learn more about %{help_link_start_machine_type}machine types%{help_link_end} and %{help_link_start_pricing}pricing%{help_link_end}.').html_safe % { help_link_start_machine_type: help_link_start % { url: machine_type_link_url }, help_link_start_pricing: help_link_start % { url: pricing_link_url }, help_link_end: help_link_end }
.form-group
- .form-check
- = provider_gcp_field.check_box :legacy_abac, { class: 'form-check-input' }, false, true
- = provider_gcp_field.label :legacy_abac, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold'
- .form-text.text-muted
- = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).')
- = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.')
- = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'role-based-access-control-rbac-core-only'), target: '_blank'
+ = provider_gcp_field.check_box :legacy_abac, { label: s_('ClusterIntegration|RBAC-enabled cluster'),
+ label_class: 'label-bold' }, false, true
+ .form-text.text-muted
+ = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).')
+ = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.')
+ = link_to _('More information'), help_page_path('user/project/clusters/index.md',
+ anchor: 'role-based-access-control-rbac-core-only'), target: '_blank'
- .form-group
- = field.submit s_('ClusterIntegration|Create Kubernetes cluster'), class: 'js-gke-cluster-creation-submit btn btn-success', disabled: true
+ .form-group
+ = field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'),
+ label_class: 'label-bold' }
+ .form-text.text-muted
+ = s_('ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster.')
+ = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank'
+
+ .form-group
+ = field.submit s_('ClusterIntegration|Create Kubernetes cluster'),
+ class: 'js-gke-cluster-creation-submit btn btn-success', disabled: true
diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml
index 1ef76ef801e..4dfbb310142 100644
--- a/app/views/clusters/clusters/show.html.haml
+++ b/app/views/clusters/clusters/show.html.haml
@@ -4,7 +4,7 @@
- page_title _('Kubernetes Cluster')
- manage_prometheus_path = edit_project_service_path(@cluster.project, 'prometheus') if @project
-- expanded = Rails.env.test?
+- expanded = expanded_by_default?
- status_path = clusterable.cluster_status_cluster_path(@cluster.id, format: :json) if can?(current_user, :admin_cluster, @cluster)
.edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path,
@@ -15,15 +15,17 @@
install_runner_path: clusterable.install_applications_cluster_path(@cluster, :runner),
install_jupyter_path: clusterable.install_applications_cluster_path(@cluster, :jupyter),
install_knative_path: clusterable.install_applications_cluster_path(@cluster, :knative),
+ update_knative_path: clusterable.update_applications_cluster_path(@cluster, :knative),
toggle_status: @cluster.enabled? ? 'true': 'false',
- has_rbac: @cluster.platform_kubernetes_rbac? ? 'true': 'false',
+ has_rbac: has_rbac_enabled?(@cluster) ? 'true': 'false',
cluster_type: @cluster.cluster_type,
cluster_status: @cluster.status_name,
cluster_status_reason: @cluster.status_reason,
help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'),
- ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-ip-address'),
- ingress_dns_help_path: help_page_path('topics/autodevops/quick_start_guide.md', anchor: 'point-dns-at-cluster-ip'),
- manage_prometheus_path: manage_prometheus_path } }
+ ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-endpoint'),
+ ingress_dns_help_path: help_page_path('user/project/clusters/index.md', anchor: 'manually-determining-the-external-endpoint'),
+ manage_prometheus_path: manage_prometheus_path,
+ cluster_id: @cluster.id } }
.js-cluster-application-notice
.flash-container
@@ -33,6 +35,8 @@
= render 'banner'
= render 'form'
+ = render_if_exists 'projects/clusters/prometheus_graphs'
+
.cluster-applications-table#js-cluster-applications
%section.settings#js-cluster-details{ class: ('expanded' if expanded) }
@@ -50,5 +54,5 @@
%button.btn.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p= s_("ClusterIntegration|Advanced options on this Kubernetes cluster's integration")
- .settings-content
+ .settings-content#advanced-settings-section
= render 'advanced_settings'
diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml
index 9793c77fc2b..f2fc5ac93fb 100644
--- a/app/views/clusters/clusters/user/_form.html.haml
+++ b/app/views/clusters/clusters/user/_form.html.haml
@@ -1,39 +1,55 @@
-= form_for @user_cluster, url: clusterable.create_user_clusters_path, as: :cluster do |field|
- = form_errors(@user_cluster)
- .form-group
- = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold'
- = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name')
+- more_info_link = link_to _('More information'), help_page_path('user/project/clusters/index.md',
+ anchor: 'adding-an-existing-kubernetes-cluster'), target: '_blank'
+- rbac_help_link = link_to _('More information'), help_page_path('user/project/clusters/index.md',
+ anchor: 'role-based-access-control-rbac-core-only'), target: '_blank'
+
+- api_url_help_text = s_('ClusterIntegration|The URL used to access the Kubernetes API.')
+- ca_cert_help_text = s_('ClusterIntegration|The Kubernetes certificate used to authenticate to the cluster.')
+- token_help_text = s_('ClusterIntegration|A service token scoped to %{code}kube-system%{end_code} with %{code}cluster-admin%{end_code} privileges.').html_safe % { code: '<code>'.html_safe, end_code: '</code>'.html_safe }
+- rbac_help_text = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') + ' '
+- rbac_help_text << s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.')
+
+= bootstrap_form_for @user_cluster, html: { class: 'gl-show-field-errors' },
+ url: clusterable.create_user_clusters_path, as: :cluster do |field|
+ = field.text_field :name, required: true, title: s_('ClusterIntegration|Cluster name is required.'),
+ label: s_('ClusterIntegration|Kubernetes cluster name'), label_class: 'label-bold'
- if has_multiple_clusters?
- .form-group
- = field.label :environment_scope, s_('ClusterIntegration|Environment scope'), class: 'label-bold'
- = field.text_field :environment_scope, class: 'form-control', placeholder: s_('ClusterIntegration|Environment scope')
+ = field.text_field :environment_scope, required: true, title: 'Environment scope is required.',
+ label: s_('ClusterIntegration|Environment scope'), label_class: 'label-bold',
+ help: s_("ClusterIntegration|Choose which of your environments will use this cluster.")
= field.fields_for :platform_kubernetes, @user_cluster.platform_kubernetes do |platform_kubernetes_field|
- .form-group
- = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL'), class: 'label-bold'
- = platform_kubernetes_field.text_field :api_url, class: 'form-control', placeholder: s_('ClusterIntegration|API URL')
+ = platform_kubernetes_field.url_field :api_url, required: true,
+ title: s_('ClusterIntegration|API URL should be a valid http/https url.'),
+ label: s_('ClusterIntegration|API URL'), label_class: 'label-bold',
+ help: '%{help_text} %{help_link}'.html_safe % { help_text: api_url_help_text, help_link: more_info_link }
- .form-group
- = platform_kubernetes_field.label :ca_cert, s_('ClusterIntegration|CA Certificate'), class: 'label-bold'
- = platform_kubernetes_field.text_area :ca_cert, class: 'form-control', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)')
+ = platform_kubernetes_field.text_area :ca_cert,
+ placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'),
+ label: s_('ClusterIntegration|CA Certificate'), label_class: 'label-bold',
+ help: '%{help_text} %{help_link}'.html_safe % { help_text: ca_cert_help_text, help_link: more_info_link }
- .form-group
- = platform_kubernetes_field.label :token, s_('ClusterIntegration|Token'), class: 'label-bold'
- = platform_kubernetes_field.text_field :token, class: 'form-control', placeholder: s_('ClusterIntegration|Service token'), autocomplete: 'off'
+ = platform_kubernetes_field.text_field :token, required: true,
+ title: s_('ClusterIntegration|Service token is required.'), label: s_('ClusterIntegration|Service Token'),
+ autocomplete: 'off', label_class: 'label-bold',
+ help: '%{help_text} %{help_link}'.html_safe % { help_text: token_help_text, help_link: more_info_link }
- if @user_cluster.allow_user_defined_namespace?
- .form-group
- = platform_kubernetes_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)'), class: 'label-bold'
- = platform_kubernetes_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace')
-
- .form-group
- .form-check
- = platform_kubernetes_field.check_box :authorization_type, { class: 'form-check-input qa-rbac-checkbox' }, 'rbac', 'abac'
- = platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold'
- .form-text.text-muted
- = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).')
- = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.')
- = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'role-based-access-control-rbac-core-only'), target: '_blank'
-
- .form-group
- = field.submit s_('ClusterIntegration|Add Kubernetes cluster'), class: 'btn btn-success'
+ = platform_kubernetes_field.text_field :namespace,
+ label: s_('ClusterIntegration|Project namespace (optional, unique)'), label_class: 'label-bold'
+
+ = platform_kubernetes_field.form_group :authorization_type,
+ { help: '%{help_text} %{help_link}'.html_safe % { help_text: rbac_help_text, help_link: rbac_help_link } } do
+ = platform_kubernetes_field.check_box :authorization_type,
+ { class: 'qa-rbac-checkbox', label: s_('ClusterIntegration|RBAC-enabled cluster'),
+ label_class: 'label-bold', inline: true }, 'rbac', 'abac'
+
+ .form-group
+ = field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'),
+ label_class: 'label-bold' }
+ .form-text.text-muted
+ = s_('ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster.')
+ = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank'
+
+ .form-group
+ = field.submit s_('ClusterIntegration|Add Kubernetes cluster'), class: 'btn btn-success'
diff --git a/app/views/clusters/platforms/kubernetes/_form.html.haml b/app/views/clusters/platforms/kubernetes/_form.html.haml
index 4a452b83112..c1727cf9079 100644
--- a/app/views/clusters/platforms/kubernetes/_form.html.haml
+++ b/app/views/clusters/platforms/kubernetes/_form.html.haml
@@ -1,58 +1,58 @@
-= form_for cluster, url: update_cluster_url_path, as: :cluster do |field|
- = form_errors(cluster)
-
- .form-group
- - if cluster.managed?
- %label.append-bottom-10{ for: 'cluster-name' }
- = s_('ClusterIntegration|Kubernetes cluster name')
- .input-group
- %input.form-control.cluster-name.js-select-on-focus{ value: cluster.name, readonly: true }
- %span.input-group-append
- = clipboard_button(text: cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'), class: 'input-group-text btn-default')
- - else
- = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold'
- .input-group
- = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name')
+= bootstrap_form_for cluster, url: update_cluster_url_path, html: { class: 'gl-show-field-errors' },
+ as: :cluster do |field|
+ - copy_name_btn = clipboard_button(text: cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'),
+ class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields?
+ = field.text_field :name, class: 'js-select-on-focus cluster-name', required: true,
+ title: s_('ClusterIntegration|Cluster name is required.'),
+ readonly: cluster.read_only_kubernetes_platform_fields?,
+ label: s_('ClusterIntegration|Kubernetes cluster name'), label_class: 'label-bold',
+ input_group_class: 'gl-field-error-anchor', append: copy_name_btn
= field.fields_for :platform_kubernetes, platform do |platform_field|
- .form-group
- = platform_field.label :api_url, s_('ClusterIntegration|API URL')
- .input-group
- = platform_field.text_field :api_url, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|API URL'), readonly: cluster.managed?
- - if cluster.managed?
- %span.input-group-append
- = clipboard_button(text: platform.api_url, title: s_('ClusterIntegration|Copy API URL'), class: 'input-group-text btn-default')
+ - copy_api_url = clipboard_button(text: platform.api_url, title: s_('ClusterIntegration|Copy API URL'),
+ class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields?
+ = platform_field.text_field :api_url, class: 'js-select-on-focus', required: true,
+ title: s_('ClusterIntegration|API URL should be a valid http/https url.'),
+ readonly: cluster.read_only_kubernetes_platform_fields?,
+ label: s_('ClusterIntegration|API URL'), label_class: 'label-bold',
+ input_group_class: 'gl-field-error-anchor', append: copy_api_url
- .form-group
- = platform_field.label :ca_cert, s_('ClusterIntegration|CA Certificate')
- .input-group
- = platform_field.text_area :ca_cert, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'), readonly: cluster.managed?
- - if cluster.managed?
- %span.input-group-append.clipboard-addon
- = clipboard_button(text: platform.ca_cert, title: s_('ClusterIntegration|Copy CA Certificate'), class: 'input-group-text btn-blank')
+ - copy_ca_cert_btn = clipboard_button(text: platform.ca_cert, title: s_('ClusterIntegration|Copy CA Certificate'),
+ class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields?
+ = platform_field.text_area :ca_cert, class: 'js-select-on-focus', rows: '5',
+ readonly: cluster.read_only_kubernetes_platform_fields?,
+ placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'),
+ label: s_('ClusterIntegration|CA Certificate'), label_class: 'label-bold',
+ input_group_class: 'gl-field-error-anchor', append: copy_ca_cert_btn
- .form-group
- = platform_field.label :token, s_('ClusterIntegration|Token')
- .input-group
- = platform_field.text_field :token, class: 'form-control js-cluster-token js-select-on-focus', type: 'password', placeholder: s_('ClusterIntegration|Token'), readonly: cluster.managed?
- %span.input-group-append
- %button.btn.btn-default.input-group-text.js-show-cluster-token{ type: 'button' }
- = s_('ClusterIntegration|Show')
- - if cluster.managed?
- = clipboard_button(text: platform.token, title: s_('ClusterIntegration|Copy Token'), class: 'btn-default')
+ - show_token_btn = (platform_field.button s_('ClusterIntegration|Show'),
+ type: 'button', class: 'js-show-cluster-token btn btn-default')
+ - copy_token_btn = clipboard_button(text: platform.token, title: s_('ClusterIntegration|Copy Service Token'),
+ class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields?
+
+ = platform_field.text_field :token, type: 'password', class: 'js-select-on-focus js-cluster-token',
+ required: true, title: s_('ClusterIntegration|Service token is required.'),
+ readonly: cluster.read_only_kubernetes_platform_fields?,
+ label: s_('ClusterIntegration|Service Token'), label_class: 'label-bold',
+ input_group_class: 'gl-field-error-anchor', append: show_token_btn + copy_token_btn
- if cluster.allow_user_defined_namespace?
- .form-group
- = platform_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)')
- = platform_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace')
+ = platform_field.text_field :namespace, label: s_('ClusterIntegration|Project namespace (optional, unique)'),
+ label_class: 'label-bold'
+
+ = platform_field.form_group :authorization_type do
+ = platform_field.check_box :authorization_type, { disabled: true, label: s_('ClusterIntegration|RBAC-enabled cluster'),
+ label_class: 'label-bold', inline: true }, 'rbac', 'abac'
+ .form-text.text-muted
+ = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).')
+ = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.')
.form-group
- .form-check
- = platform_field.check_box :authorization_type, { class: 'form-check-input', disabled: true }, 'rbac', 'abac'
- = platform_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold'
- .form-text.text-muted
- = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).')
- = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.')
+ = field.check_box :managed, { disabled: true, label: s_('ClusterIntegration|GitLab-managed cluster'),
+ label_class: 'label-bold' }
+ .form-text.text-muted
+ = s_('ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster.')
+ = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank'
.form-group
= field.submit s_('ClusterIntegration|Save changes'), class: 'btn btn-success'
diff --git a/app/views/dashboard/_activity_head.html.haml b/app/views/dashboard/_activity_head.html.haml
index ec1a3fef435..3f39555a1d4 100644
--- a/app/views/dashboard/_activity_head.html.haml
+++ b/app/views/dashboard/_activity_head.html.haml
@@ -1,4 +1,4 @@
-.page-title-holder
+.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Activity')
.top-area
diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml
index 8ab5dc37f34..b2fadb77418 100644
--- a/app/views/dashboard/_groups_head.html.haml
+++ b/app/views/dashboard/_groups_head.html.haml
@@ -1,4 +1,4 @@
-.page-title-holder
+.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Groups')
- if current_user.can_create_group?
diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml
index ae67192cbcd..97a446dbeec 100644
--- a/app/views/dashboard/_projects_head.html.haml
+++ b/app/views/dashboard/_projects_head.html.haml
@@ -1,29 +1,35 @@
+- project_tab_filter = local_assigns.fetch(:project_tab_filter, "")
+- feature_project_list_filter_bar = Feature.enabled?(:project_list_filter_bar)
+
= content_for :flash_message do
= render 'shared/project_limit'
-.page-title-holder
+.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Projects')
- if current_user.can_create_project?
.page-title-controls
- = link_to "New project", new_project_path, class: "btn btn-success"
+ = link_to _("New project"), new_project_path, class: "btn btn-success"
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
- %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs
+ %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs{ class: ('border-0' if feature_project_list_filter_bar) }
= nav_link(page: [dashboard_projects_path, root_path]) do
= link_to dashboard_projects_path, class: 'shortcuts-activity', data: {placement: 'right'} do
- Your projects
+ = _("Your projects")
%span.badge.badge-pill= limited_counter_with_delimiter(@total_user_projects_count)
= nav_link(page: starred_dashboard_projects_path) do
= link_to starred_dashboard_projects_path, data: {placement: 'right'} do
- Starred projects
+ = _("Starred projects")
%span.badge.badge-pill= limited_counter_with_delimiter(@total_starred_projects_count)
= nav_link(page: [explore_root_path, trending_explore_projects_path, starred_explore_projects_path, explore_projects_path]) do
= link_to explore_root_path, data: {placement: 'right'} do
- Explore projects
-
- .nav-controls
- = render 'shared/projects/search_form'
- = render 'shared/projects/dropdown'
+ = _("Explore projects")
+ - unless feature_project_list_filter_bar
+ .nav-controls
+ = render 'shared/projects/search_form'
+ = render 'shared/projects/dropdown'
+- if feature_project_list_filter_bar
+ .project-filters
+ = render 'shared/projects/search_bar', project_tab_filter: project_tab_filter
diff --git a/app/views/dashboard/_snippets_head.html.haml b/app/views/dashboard/_snippets_head.html.haml
index a05d0190efb..34aca40d0d1 100644
--- a/app/views/dashboard/_snippets_head.html.haml
+++ b/app/views/dashboard/_snippets_head.html.haml
@@ -1,4 +1,4 @@
-.page-title-holder
+.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Snippets')
- if current_user && current_user.snippets.any? || @snippets.any?
diff --git a/app/views/dashboard/activity.html.haml b/app/views/dashboard/activity.html.haml
index 4dbda5c754b..b1c192d7bad 100644
--- a/app/views/dashboard/activity.html.haml
+++ b/app/views/dashboard/activity.html.haml
@@ -5,7 +5,7 @@
= auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity")
-= render_if_exists "shared/gold_trial_callout"
+= render_dashboard_gold_trial(current_user)
- page_title "Activity"
- header_title "Activity", activity_dashboard_path
diff --git a/app/views/dashboard/groups/_groups.html.haml b/app/views/dashboard/groups/_groups.html.haml
index db856ef7d7b..2f9dbf87d95 100644
--- a/app/views/dashboard/groups/_groups.html.haml
+++ b/app/views/dashboard/groups/_groups.html.haml
@@ -1,4 +1,4 @@
.js-groups-list-holder
#js-groups-tree{ data: { hide_projects: 'true', endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
- .loading-container.text-center
- = icon('spinner spin 2x', class: 'loading-animation prepend-top-20')
+ .loading-container.text-center.prepend-top-20
+ .spinner.spinner-md
diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml
index 19b06ba5cdd..d1d8d970b59 100644
--- a/app/views/dashboard/groups/index.html.haml
+++ b/app/views/dashboard/groups/index.html.haml
@@ -2,7 +2,7 @@
- page_title "Groups"
- header_title "Groups", dashboard_groups_path
-= render_if_exists "shared/gold_trial_callout"
+= render_dashboard_gold_trial(current_user)
= render 'dashboard/groups_head'
- if params[:filter].blank? && @groups.empty?
diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml
index afd46412fab..b3ee5034204 100644
--- a/app/views/dashboard/issues.html.haml
+++ b/app/views/dashboard/issues.html.haml
@@ -4,9 +4,9 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{current_user.name} issues")
-= render_if_exists "shared/gold_trial_callout"
+= render_dashboard_gold_trial(current_user)
-.page-title-holder
+.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Issues')
- if current_user
diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml
index 3e5f13b92e3..3956f03a3c8 100644
--- a/app/views/dashboard/merge_requests.html.haml
+++ b/app/views/dashboard/merge_requests.html.haml
@@ -2,9 +2,9 @@
- page_title _("Merge Requests")
- @breadcrumb_link = merge_requests_dashboard_path(assignee_username: current_user.username)
-= render_if_exists "shared/gold_trial_callout"
+= render_dashboard_gold_trial(current_user)
-.page-title-holder
+.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Merge Requests')
- if current_user
diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml
index 13822d36f15..37ba2143eba 100644
--- a/app/views/dashboard/milestones/index.html.haml
+++ b/app/views/dashboard/milestones/index.html.haml
@@ -2,7 +2,7 @@
- page_title 'Milestones'
- header_title 'Milestones', dashboard_milestones_path
-.page-title-holder
+.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Milestones')
- if current_user
diff --git a/app/views/dashboard/projects/_nav.html.haml b/app/views/dashboard/projects/_nav.html.haml
index da3cf5807b0..f9b61bf1f3e 100644
--- a/app/views/dashboard/projects/_nav.html.haml
+++ b/app/views/dashboard/projects/_nav.html.haml
@@ -1,6 +1,21 @@
-.nav-block
- %ul.nav-links.mobile-separator.nav.nav-tabs
- = nav_link(html_options: { class: ("active" unless params[:personal].present?) }) do
- = link_to s_('DashboardProjects|All'), dashboard_projects_path
- = nav_link(html_options: { class: ("active" if params[:personal].present?) }) do
- = link_to s_('DashboardProjects|Personal'), filter_projects_path(personal: true)
+- inactive_class = 'btn p-2'
+- active_class = 'btn p-2 active'
+- project_tab_filter = local_assigns.fetch(:project_tab_filter, "")
+- is_explore_trending = project_tab_filter == :explore_trending
+- feature_project_list_filter_bar = Feature.enabled?(:project_list_filter_bar)
+
+.nav-block{ class: ("w-100" if feature_project_list_filter_bar) }
+ - if feature_project_list_filter_bar
+ .btn-group.button-filter-group.d-flex.m-0.p-0
+ - if project_tab_filter == :explore || is_explore_trending
+ = link_to s_('DashboardProjects|Trending'), trending_explore_projects_path, class: is_explore_trending ? active_class : inactive_class
+ = link_to s_('DashboardProjects|All'), explore_projects_path, class: is_explore_trending ? inactive_class : active_class
+ - else
+ = link_to s_('DashboardProjects|All'), dashboard_projects_path, class: params[:personal].present? ? inactive_class : active_class
+ = link_to s_('DashboardProjects|Personal'), filter_projects_path(personal: true), class: params[:personal].present? ? active_class : inactive_class
+ - else
+ %ul.nav-links.mobile-separator.nav.nav-tabs
+ = nav_link(html_options: { class: ("active" unless params[:personal].present?) }) do
+ = link_to s_('DashboardProjects|All'), dashboard_projects_path
+ = nav_link(html_options: { class: ("active" if params[:personal].present?) }) do
+ = link_to s_('DashboardProjects|Personal'), filter_projects_path(personal: true)
diff --git a/app/views/dashboard/projects/_zero_authorized_projects.html.haml b/app/views/dashboard/projects/_zero_authorized_projects.html.haml
index 18a82feb189..8933c5d7227 100644
--- a/app/views/dashboard/projects/_zero_authorized_projects.html.haml
+++ b/app/views/dashboard/projects/_zero_authorized_projects.html.haml
@@ -1,4 +1,4 @@
-.blank-state-parent-container
+.blank-state-parent-container{ class: ('has-start-trial-container' if has_start_trial?) }
.section-container.section-welcome{ class: "#{ 'section-admin-welcome' if current_user.admin? }" }
.container.section-body
.row
@@ -7,7 +7,12 @@
Welcome to GitLab
%p.blank-state-text
Code, test, and deploy together
- - if current_user.admin?
- = render "blank_state_admin_welcome"
- - else
- = render "blank_state_welcome"
+ .blank-state-row
+ %div{ class: ('column-large' if has_start_trial?) }
+ - if current_user.admin?
+ = render "blank_state_admin_welcome"
+ - else
+ = render "blank_state_welcome"
+ - if has_start_trial?
+ .column-small
+ = render_if_exists "blank_state_ee_trial"
diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml
index 446b4715b2d..0298f539b4b 100644
--- a/app/views/dashboard/projects/index.html.haml
+++ b/app/views/dashboard/projects/index.html.haml
@@ -4,7 +4,7 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity")
-= render_if_exists "shared/gold_trial_callout"
+= render_dashboard_gold_trial(current_user)
- page_title "Projects"
- header_title "Projects", dashboard_projects_path
@@ -13,7 +13,7 @@
= render "projects/last_push"
- if show_projects?(@projects, params)
= render 'dashboard/projects_head'
- = render 'nav'
+ = render 'nav' unless Feature.enabled?(:project_list_filter_bar)
= render 'projects'
- else
= render "zero_authorized_projects"
diff --git a/app/views/dashboard/projects/starred.html.haml b/app/views/dashboard/projects/starred.html.haml
index 3a45f6df017..0fcc6894b68 100644
--- a/app/views/dashboard/projects/starred.html.haml
+++ b/app/views/dashboard/projects/starred.html.haml
@@ -4,11 +4,11 @@
- page_title _("Starred Projects")
- header_title _("Projects"), dashboard_projects_path
-= render_if_exists "shared/gold_trial_callout"
+= render_dashboard_gold_trial(current_user)
%div{ class: container_class }
= render "projects/last_push"
- = render 'dashboard/projects_head'
+ = render 'dashboard/projects_head', project_tab_filter: :starred
- if params[:filter_projects] || any_projects?(@projects)
= render 'projects'
diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml
index efe1fb99efc..db6e40a6fd0 100644
--- a/app/views/dashboard/todos/_todo.html.haml
+++ b/app/views/dashboard/todos/_todo.html.haml
@@ -34,7 +34,7 @@
= todo_due_date(todo)
.todo-body
- .todo-note
+ .todo-note.break-word
.md
= first_line_in_markdown(todo, :body, 150, project: todo.project)
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index 47729321961..8212fb8bb33 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -2,9 +2,9 @@
- page_title "Todos"
- header_title "Todos", dashboard_todos_path
-= render_if_exists "shared/gold_trial_callout"
+= render_dashboard_gold_trial(current_user)
-.page-title-holder
+.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Todos')
- if current_user.todos.any?
@@ -34,7 +34,7 @@
= icon('spinner spin')
.todos-filters
- .row-content-block.second-block
+ .issues-details-filters.row-content-block.second-block
= form_tag todos_filter_path(without: [:project_id, :author_id, :type, :action_id]), method: :get, class: 'filter-form d-sm-flex' do
.filter-categories.flex-fill
.filter-item.inline
diff --git a/app/views/devise/confirmations/new.html.haml b/app/views/devise/confirmations/new.html.haml
index 73e70dc63e5..f8aa3cf98dc 100644
--- a/app/views/devise/confirmations/new.html.haml
+++ b/app/views/devise/confirmations/new.html.haml
@@ -3,7 +3,7 @@
.login-body
= form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post, class: 'gl-show-field-errors' }) do |f|
.devise-errors
- = devise_error_messages!
+ = render "devise/shared/error_messages", resource: resource
.form-group
= f.label :email
= f.email_field :email, class: "form-control", required: true, title: 'Please provide a valid email address.'
diff --git a/app/views/devise/mailer/email_changed.html.haml b/app/views/devise/mailer/email_changed.html.haml
index 5398430fdfd..3689e9c5f61 100644
--- a/app/views/devise/mailer/email_changed.html.haml
+++ b/app/views/devise/mailer/email_changed.html.haml
@@ -2,7 +2,7 @@
- if @resource.try(:unconfirmed_email?)
%p
- We're contacting you to notify you that your email is being changed to #{@resource.reload.unconfirmed_email}.
+ We're contacting you to notify you that your email is being changed to #{@resource.reset.unconfirmed_email}.
- else
%p
We're contacting you to notify you that your email has been changed to #{@resource.email}.
diff --git a/app/views/devise/mailer/email_changed.text.erb b/app/views/devise/mailer/email_changed.text.erb
index 18137389e7b..69155db7246 100644
--- a/app/views/devise/mailer/email_changed.text.erb
+++ b/app/views/devise/mailer/email_changed.text.erb
@@ -1,7 +1,7 @@
Hello, <%= @resource.name %>!
<% if @resource.try(:unconfirmed_email?) %>
-We're contacting you to notify you that your email is being changed to <%= @resource.reload.unconfirmed_email %>.
+We're contacting you to notify you that your email is being changed to <%= @resource.reset.unconfirmed_email %>.
<% else %>
We're contacting you to notify you that your email has been changed to <%= @resource.email %>.
<% end %>
diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml
index dd1edb5fdc9..09ea7716a47 100644
--- a/app/views/devise/passwords/edit.html.haml
+++ b/app/views/devise/passwords/edit.html.haml
@@ -3,7 +3,7 @@
.login-body
= form_for(resource, as: resource_name, url: password_path(:user), html: { method: :put, class: 'gl-show-field-errors' }) do |f|
.devise-errors
- = devise_error_messages!
+ = render "devise/shared/error_messages", resource: resource
= f.hidden_field :reset_password_token
.form-group
= f.label 'New password', for: "user_password"
diff --git a/app/views/devise/passwords/new.html.haml b/app/views/devise/passwords/new.html.haml
index 99ce13adf74..fe999851605 100644
--- a/app/views/devise/passwords/new.html.haml
+++ b/app/views/devise/passwords/new.html.haml
@@ -3,7 +3,7 @@
.login-body
= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post, class: 'gl-show-field-errors' }) do |f|
.devise-errors
- = devise_error_messages!
+ = render "devise/shared/error_messages", resource: resource
.form-group
= f.label :email
= f.email_field :email, class: "form-control", required: true, value: params[:user_email], autofocus: true, title: 'Please provide a valid email address.'
diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb
index f379e71ae5b..5a1388ac7a1 100644
--- a/app/views/devise/registrations/edit.html.erb
+++ b/app/views/devise/registrations/edit.html.erb
@@ -1,7 +1,7 @@
<h2>Edit <%= resource_name.to_s.humanize %></h2>
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %>
- <%= devise_error_messages! %>
+ <%= render "devise/shared/error_messages", resource: resource %>
<div><%= f.label :email %><br />
<%= f.email_field :email %></div>
diff --git a/app/views/devise/shared/_signin_box.html.haml b/app/views/devise/shared/_signin_box.html.haml
index ec968e435cd..f8f36a8bfff 100644
--- a/app/views/devise/shared/_signin_box.html.haml
+++ b/app/views/devise/shared/_signin_box.html.haml
@@ -3,17 +3,21 @@
.login-box.tab-pane{ id: "crowd", role: 'tabpanel', class: active_when(form_based_auth_provider_has_active_class?(:crowd)) }
.login-body
= render 'devise/sessions/new_crowd'
+
+ = render_if_exists 'devise/sessions/new_kerberos_tab'
+
- @ldap_servers.each_with_index do |server, i|
.login-box.tab-pane{ id: "#{server['provider_name']}", role: 'tabpanel', class: active_when(i.zero? && form_based_auth_provider_has_active_class?(:ldapmain)) }
.login-body
= render 'devise/sessions/new_ldap', server: server
+
+ = render_if_exists 'devise/sessions/new_smartcard'
+
- if password_authentication_enabled_for_web?
.login-box.tab-pane{ id: 'login-pane', role: 'tabpanel' }
.login-body
= render 'devise/sessions/new_base'
- = render_if_exists 'devise/sessions/new_smartcard'
-
- elsif password_authentication_enabled_for_web?
.login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' }
.login-body
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index 9c7ca6ebbd4..5eba819172b 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -1,27 +1,29 @@
+- max_name_length = 128
+- max_username_length = 255
#register-pane.tab-pane.login-box{ role: 'tabpanel' }
.login-body
= form_for(resource, as: "new_#{resource_name}", url: registration_path(resource_name), html: { class: "new_new_user gl-show-field-errors", "aria-live" => "assertive" }) do |f|
.devise-errors
- = devise_error_messages!
+ = render "devise/shared/error_messages", resource: resource
.name.form-group
- = f.label :name, 'Full name', class: 'label-bold'
- = f.text_field :name, class: "form-control top qa-new-user-name js-block-emoji", required: true, title: _("This field is required.")
+ = f.label :name, _('Full name'), class: 'label-bold'
+ = f.text_field :name, class: "form-control top qa-new-user-name js-block-emoji js-validate-length", :data => { :max_length => max_name_length, :max_length_message => s_("SignUp|Name is too long (maximum is %{max_length} characters).") % { max_length: max_name_length } }, required: true, title: _("This field is required.")
.username.form-group
= f.label :username, class: 'label-bold'
- = f.text_field :username, class: "form-control middle qa-new-user-username js-block-emoji", pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.")
- %p.validation-error.hide Username is already taken.
- %p.validation-success.hide Username is available.
- %p.validation-pending.hide Checking username availability...
+ = f.text_field :username, class: "form-control middle qa-new-user-username js-block-emoji js-validate-length", :data => { :max_length => max_username_length, :max_length_message => s_("SignUp|Username is too long (maximum is %{max_length} characters).") % { max_length: max_username_length } }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.")
+ %p.validation-error.field-validation.hide= _('Username is already taken.')
+ %p.validation-success.field-validation.hide= _('Username is available.')
+ %p.validation-pending.field-validation.hide= _('Checking username availability...')
.form-group
= f.label :email, class: 'label-bold'
- = f.email_field :email, class: "form-control middle qa-new-user-email", required: true, title: "Please provide a valid email address."
+ = f.email_field :email, class: "form-control middle qa-new-user-email", required: true, title: _("Please provide a valid email address.")
.form-group
= f.label :email_confirmation, class: 'label-bold'
- = f.email_field :email_confirmation, class: "form-control middle qa-new-user-email-confirmation", required: true, title: "Please retype the email address."
+ = f.email_field :email_confirmation, class: "form-control middle qa-new-user-email-confirmation", required: true, title: _("Please retype the email address.")
.form-group.append-bottom-20#password-strength
= f.label :password, class: 'label-bold'
- = f.password_field :password, class: "form-control bottom qa-new-user-password", required: true, pattern: ".{#{@minimum_password_length},}", title: "Minimum length is #{@minimum_password_length} characters."
- %p.gl-field-hint.text-secondary Minimum length is #{@minimum_password_length} characters
+ = f.password_field :password, class: "form-control bottom qa-new-user-password", required: true, pattern: ".{#{@minimum_password_length},}", title: _("Minimum length is %{minimum_password_length} characters.") % { minimum_password_length: @minimum_password_length }
+ %p.gl-field-hint.text-secondary= _('Minimum length is %{minimum_password_length} characters') % { minimum_password_length: @minimum_password_length }
- if Gitlab::CurrentSettings.current_application_settings.enforce_terms?
.form-group
= check_box_tag :terms_opt_in, '1', false, required: true, class: 'qa-new-user-accept-terms'
@@ -29,8 +31,9 @@
- terms_link = link_to s_("I accept the|Terms of Service and Privacy Policy"), terms_path, target: "_blank"
- accept_terms_label = _("I accept the %{terms_link}") % { terms_link: terms_link }
= accept_terms_label.html_safe
+ = render_if_exists 'devise/shared/email_opted_in', f: f
%div
- if Gitlab::Recaptcha.enabled?
= recaptcha_tags
.submit-container
- = f.submit "Register", class: "btn-register btn qa-new-user-register-button"
+ = f.submit _("Register"), class: "btn-register btn qa-new-user-register-button"
diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml
index aee05b6c81c..b1a9470cf1c 100644
--- a/app/views/devise/shared/_tabs_ldap.html.haml
+++ b/app/views/devise/shared/_tabs_ldap.html.haml
@@ -2,6 +2,7 @@
- if crowd_enabled?
%li.nav-item
= link_to "Crowd", "#crowd", class: "nav-link #{active_when(form_based_auth_provider_has_active_class?(:crowd))}", 'data-toggle' => 'tab'
+ = render_if_exists "devise/shared/kerberos_tab"
- @ldap_servers.each_with_index do |server, i|
%li.nav-item
= link_to server['label'], "##{server['provider_name']}", class: "nav-link #{active_when(i.zero? && form_based_auth_provider_has_active_class?(:ldapmain))} qa-ldap-tab", 'data-toggle' => 'tab'
diff --git a/app/views/devise/unlocks/new.html.haml b/app/views/devise/unlocks/new.html.haml
index b2f48a4e0bf..1167f1718d6 100644
--- a/app/views/devise/unlocks/new.html.haml
+++ b/app/views/devise/unlocks/new.html.haml
@@ -3,7 +3,7 @@
.login-body
= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post, class: 'gl-show-field-errors' }) do |f|
.devise-errors
- = devise_error_messages!
+ = render "devise/shared/error_messages", resource: resource
.form-group.append-bottom-20
= f.label :email
= f.email_field :email, class: 'form-control', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', title: 'Please provide a valid email address.'
diff --git a/app/views/discussions/_diff_discussion.html.haml b/app/views/discussions/_diff_discussion.html.haml
index 6b8dd156874..5a47040874f 100644
--- a/app/views/discussions/_diff_discussion.html.haml
+++ b/app/views/discussions/_diff_discussion.html.haml
@@ -4,6 +4,6 @@
-# Text diff discussions
- expanded = local_assigns.fetch(:expanded, true)
%tr.notes_holder{ class: ('hide' unless expanded) }
- %td.notes_content{ colspan: 3 }
+ %td.notes-content{ colspan: 3 }
.content{ class: ('hide' unless expanded) }
= render partial: "discussions/notes", collection: discussions, as: :discussion, locals: { disable_collapse_class: true }
diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml
index 44c898e0fac..8a3c841de0b 100644
--- a/app/views/discussions/_diff_with_notes.html.haml
+++ b/app/views/discussions/_diff_with_notes.html.haml
@@ -11,8 +11,8 @@
= render "projects/diffs/file_header", diff_file: diff_file, url: discussion_path(discussion), show_toggle: false
- if diff_file.text?
- .diff-content.code.js-syntax-highlight
- %table
+ .diff-content
+ %table.code.js-syntax-highlight
- if expanded
- discussions = { discussion.original_line_code => [discussion] }
= render partial: "projects/diffs/line",
diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml
index 30b00ca86b3..0a5541c3e82 100644
--- a/app/views/discussions/_notes.html.haml
+++ b/app/views/discussions/_notes.html.haml
@@ -19,20 +19,24 @@
.discussion-reply-holder
- if can_create_note?
+ %a.user-avatar-link.d-none.d-sm-block{ href: user_path(current_user) }
+ = image_tag avatar_icon_for_user(current_user), alt: current_user.to_reference, class: 'avatar s40'
- if discussion.potentially_resolvable?
- line_type = local_assigns.fetch(:line_type, nil)
- .btn-group.discussion-with-resolve-btn{ role: "group" }
- .btn-group{ role: "group" }
- = link_to_reply_discussion(discussion, line_type)
+ .discussion-with-resolve-btn
+ .btn-group.discussion-with-resolve-btn{ role: "group" }
+ .btn-group{ role: "group" }
+ = link_to_reply_discussion(discussion, line_type)
- = render "discussions/resolve_all", discussion: discussion
+ = render "discussions/resolve_all", discussion: discussion
- .btn-group.discussion-actions
- = render "discussions/new_issue_for_discussion", discussion: discussion, merge_request: discussion.noteable
- = render "discussions/jump_to_next", discussion: discussion
+ .btn-group.discussion-actions
+ = render "discussions/new_issue_for_discussion", discussion: discussion, merge_request: discussion.noteable
+ = render "discussions/jump_to_next", discussion: discussion
- else
- = link_to_reply_discussion(discussion)
+ .discussion-with-resolve-btn
+ = link_to_reply_discussion(discussion)
- elsif !current_user
.disabled-comment.text-center
Please
diff --git a/app/views/discussions/_parallel_diff_discussion.html.haml b/app/views/discussions/_parallel_diff_discussion.html.haml
index 2e621c4082d..03b428714b9 100644
--- a/app/views/discussions/_parallel_diff_discussion.html.haml
+++ b/app/views/discussions/_parallel_diff_discussion.html.haml
@@ -1,17 +1,17 @@
- expanded = [*discussions_left, *discussions_right].any?(&:expanded?)
%tr.notes_holder{ class: ('hide' unless expanded) }
- if discussions_left
- %td.notes_content.parallel.old{ colspan: 2 }
+ %td.notes-content.parallel.old{ colspan: 2 }
.content{ class: ('hide' unless discussions_left.any?(&:expanded?)) }
= render partial: "discussions/notes", collection: discussions_left, as: :discussion, line_type: 'old', locals: { disable_collapse_class: true }
- else
- %td.notes_content.parallel.old{ colspan: 2 }
+ %td.notes-content.parallel.old{ colspan: 2 }
.content
- if discussions_right
- %td.notes_content.parallel.new{ colspan: 2 }
+ %td.notes-content.parallel.new{ colspan: 2 }
.content{ class: ('hide' unless discussions_right.any?(&:expanded?)) }
= render partial: "discussions/notes", collection: discussions_right, as: :discussion, line_type: 'new', locals: { disable_collapse_class: true }
- else
- %td.notes_content.parallel.new{ colspan: 2 }
+ %td.notes-content.parallel.new{ colspan: 2 }
.content
diff --git a/app/views/doorkeeper/applications/index.html.haml b/app/views/doorkeeper/applications/index.html.haml
index 1f5c70a6c6e..5d85d9e431f 100644
--- a/app/views/doorkeeper/applications/index.html.haml
+++ b/app/views/doorkeeper/applications/index.html.haml
@@ -52,7 +52,7 @@
.oauth-authorized-applications.prepend-top-20.append-bottom-default
- if user_oauth_applications?
%h5
- = _("Authorized applications (%{size})") % { size: @authorized_tokens.size }
+ = _("Authorized applications (%{size})") % { size: @authorized_apps.size + @authorized_anonymous_tokens.size }
- if @authorized_tokens.any?
.table-responsive
diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml
index cac00f9c854..6750732ab67 100644
--- a/app/views/doorkeeper/applications/show.html.haml
+++ b/app/views/doorkeeper/applications/show.html.haml
@@ -14,7 +14,7 @@
%td
.clipboard-group
.input-group
- %input.label.label-monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true }
+ %input.label.label-monospace.monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true }
.input-group-append
= clipboard_button(target: '#application_id', title: _("Copy ID to clipboard"), class: "btn btn btn-default")
%tr
@@ -23,7 +23,7 @@
%td
.clipboard-group
.input-group
- %input.label.label-monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true }
+ %input.label.label-monospace.monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true }
.input-group-append
= clipboard_button(target: '#secret', title: _("Copy secret to clipboard"), class: "btn btn btn-default")
%tr
diff --git a/app/views/events/_event.html.haml b/app/views/events/_event.html.haml
index 2fcb1d1fd2b..222175c818a 100644
--- a/app/views/events/_event.html.haml
+++ b/app/views/events/_event.html.haml
@@ -3,11 +3,11 @@
.event-item-timestamp
#{time_ago_with_tooltip(event.created_at)}
- - if event.created_project?
+ - if event.created_project_action?
= render "events/event/created_project", event: event
- - elsif event.push?
+ - elsif event.push_action?
= render "events/event/push", event: event
- - elsif event.commented?
+ - elsif event.commented_action?
= render "events/event/note", event: event
- else
= render "events/event/common", event: event
diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml
index 96d6553a2ac..b02fdb4b638 100644
--- a/app/views/events/event/_common.html.haml
+++ b/app/views/events/event/_common.html.haml
@@ -11,7 +11,8 @@
= link_to [event.project.namespace.becomes(Namespace), event.project, event.target], class: 'has-tooltip event-target-link append-right-4', title: event.target_title do
= event.target.reference_link_text
- unless event.milestone?
- %span.event-target-title.append-right-4= "&quot;".html_safe + event.target.title + "&quot".html_safe
+ %span.event-target-title.append-right-4{ dir: "auto" }
+ = "&quot;".html_safe + event.target.title + "&quot".html_safe
- else
%span.event-type.d-inline-block.append-right-4{ class: event.action_name }
= event_action_name(event)
diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml
index 90ed8e41d32..7e2103287f7 100644
--- a/app/views/events/event/_note.html.haml
+++ b/app/views/events/event/_note.html.haml
@@ -7,7 +7,8 @@
%span.event-type.d-inline-block.append-right-4{ class: event.action_name }
= event.action_name
= event_note_title_html(event)
- %span.event-target-title.append-right-4= "&quot;".html_safe + event.target.title + "&quot".html_safe
+ %span.event-target-title.append-right-4{ dir: "auto" }
+ = "&quot;".html_safe + event.target.title + "&quot".html_safe
= render "events/event_scope", event: event
diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml
index 69914fccc48..21c418cb0e4 100644
--- a/app/views/events/event/_push.html.haml
+++ b/app/views/events/event/_push.html.haml
@@ -32,7 +32,8 @@
- from_label = from
= link_to project_compare_path(project, from: from, to: event.commit_to) do
- Compare #{from_label}...#{truncate_sha(event.commit_to)}
+ %span Compare
+ %span.commit-sha #{from_label}...#{truncate_sha(event.commit_to)}
- if create_mr
%span
diff --git a/app/views/explore/groups/_groups.html.haml b/app/views/explore/groups/_groups.html.haml
index ff57b39e947..a3249275d5e 100644
--- a/app/views/explore/groups/_groups.html.haml
+++ b/app/views/explore/groups/_groups.html.haml
@@ -1,4 +1,4 @@
.js-groups-list-holder
#js-groups-tree{ data: { hide_projects: 'true', endpoint: explore_groups_path(format: :json), path: explore_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
- .loading-container.text-center
- = icon('spinner spin 2x', class: 'loading-animation prepend-top-20')
+ .loading-container.text-center.prepend-top-20
+ .spinner.spinner-md
diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml
index 869be4e8581..fd86d07fc86 100644
--- a/app/views/explore/groups/index.html.haml
+++ b/app/views/explore/groups/index.html.haml
@@ -2,7 +2,7 @@
- page_title _("Groups")
- header_title _("Groups"), dashboard_groups_path
-= render_if_exists "shared/gold_trial_callout"
+= render_dashboard_gold_trial(current_user)
- if current_user
= render 'dashboard/groups_head'
diff --git a/app/views/explore/projects/_filter.html.haml b/app/views/explore/projects/_filter.html.haml
index f518205f14c..d00a3d266d8 100644
--- a/app/views/explore/projects/_filter.html.haml
+++ b/app/views/explore/projects/_filter.html.haml
@@ -1,8 +1,12 @@
+- has_label = local_assigns.fetch(:has_label, false)
+- feature_project_list_filter_bar = Feature.enabled?(:project_list_filter_bar)
+
- if current_user
- .dropdown
+ .dropdown.js-project-filter-dropdown-wrap{ class: ('d-flex flex-grow-1 flex-shrink-1' if feature_project_list_filter_bar) }
%button.dropdown-menu-toggle{ href: '#', "data-toggle" => "dropdown", 'data-display' => 'static' }
- = icon('globe', class: 'mt-1')
- %span.light.ml-3= _("Visibility:")
+ - unless has_label
+ = icon('globe', class: 'mt-1')
+ %span.light.ml-3= _("Visibility:")
- if params[:visibility_level].present?
= visibility_level_label(params[:visibility_level].to_i)
- else
diff --git a/app/views/explore/projects/index.html.haml b/app/views/explore/projects/index.html.haml
index d18dec7bd8e..341ad681c7c 100644
--- a/app/views/explore/projects/index.html.haml
+++ b/app/views/explore/projects/index.html.haml
@@ -2,12 +2,12 @@
- page_title _("Projects")
- header_title _("Projects"), dashboard_projects_path
-= render_if_exists "shared/gold_trial_callout"
+= render_dashboard_gold_trial(current_user)
- if current_user
- = render 'dashboard/projects_head'
+ = render 'dashboard/projects_head', project_tab_filter: :explore
- else
= render 'explore/head'
-= render 'explore/projects/nav'
+= render 'explore/projects/nav' unless Feature.enabled?(:project_list_filter_bar) && current_user
= render 'projects', projects: @projects
diff --git a/app/views/explore/projects/starred.html.haml b/app/views/explore/projects/starred.html.haml
index d18dec7bd8e..ec92852ddde 100644
--- a/app/views/explore/projects/starred.html.haml
+++ b/app/views/explore/projects/starred.html.haml
@@ -2,12 +2,12 @@
- page_title _("Projects")
- header_title _("Projects"), dashboard_projects_path
-= render_if_exists "shared/gold_trial_callout"
+= render_dashboard_gold_trial(current_user)
- if current_user
- = render 'dashboard/projects_head'
+ = render 'dashboard/projects_head', project_tab_filter: :starred
- else
= render 'explore/head'
-= render 'explore/projects/nav'
+= render 'explore/projects/nav' unless Feature.enabled?(:project_list_filter_bar) && current_user
= render 'projects', projects: @projects
diff --git a/app/views/explore/projects/trending.html.haml b/app/views/explore/projects/trending.html.haml
index d18dec7bd8e..ed508fa2506 100644
--- a/app/views/explore/projects/trending.html.haml
+++ b/app/views/explore/projects/trending.html.haml
@@ -2,12 +2,12 @@
- page_title _("Projects")
- header_title _("Projects"), dashboard_projects_path
-= render_if_exists "shared/gold_trial_callout"
+= render_dashboard_gold_trial(current_user)
- if current_user
- = render 'dashboard/projects_head'
+ = render 'dashboard/projects_head', project_tab_filter: :explore_trending
- else
= render 'explore/head'
-= render 'explore/projects/nav'
+= render 'explore/projects/nav' unless Feature.enabled?(:project_list_filter_bar) && current_user
= render 'projects', projects: @projects
diff --git a/app/views/groups/_archived_projects.html.haml b/app/views/groups/_archived_projects.html.haml
index ed79f5790f0..48e9f630050 100644
--- a/app/views/groups/_archived_projects.html.haml
+++ b/app/views/groups/_archived_projects.html.haml
@@ -4,5 +4,5 @@
%ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
.js-groups-list-holder
- .loading-container.text-center
- = icon('spinner spin 2x', class: 'loading-animation prepend-top-20')
+ .loading-container.text-center.prepend-top-20
+ .spinner.spinner-md
diff --git a/app/views/groups/_create_chat_team.html.haml b/app/views/groups/_create_chat_team.html.haml
index f950968030f..561e68a9155 100644
--- a/app/views/groups/_create_chat_team.html.haml
+++ b/app/views/groups/_create_chat_team.html.haml
@@ -1,8 +1,9 @@
.form-group
- = f.label :create_chat_team, class: 'col-form-label' do
- %span.mattermost-icon
- = custom_icon('icon_mattermost')
- Mattermost
+ .col-sm-2.col-form-label
+ = f.label :create_chat_team do
+ %span.mattermost-icon
+ = custom_icon('icon_mattermost')
+ Mattermost
.col-sm-10
.form-check.js-toggle-container
.js-toggle-button.form-check-input= f.check_box(:create_chat_team, { checked: true }, true, false)
diff --git a/app/views/groups/_group_admin_settings.html.haml b/app/views/groups/_group_admin_settings.html.haml
index ff59013ed67..b8f632d11d3 100644
--- a/app/views/groups/_group_admin_settings.html.haml
+++ b/app/views/groups/_group_admin_settings.html.haml
@@ -1,5 +1,6 @@
.form-group.row
- = f.label :lfs_enabled, 'Large File Storage', class: 'col-form-label col-sm-2 pt-0'
+ .col-sm-2.col-form-label.pt-0
+ = f.label :lfs_enabled, 'Large File Storage'
.col-sm-10
.form-check
= f.check_box :lfs_enabled, checked: @group.lfs_enabled?, class: 'form-check-input'
@@ -9,9 +10,15 @@
= link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
%br/
%span.descr This setting can be overridden in each project.
+.form-group.row
+ .col-sm-2.col-form-label
+ = f.label s_('ProjectCreationLevel|Allowed to create projects')
+ .col-sm-10
+ = f.select :project_creation_level, options_for_select(::Gitlab::Access.project_creation_options, @group.project_creation_level), {}, class: 'form-control'
.form-group.row
- = f.label :require_two_factor_authentication, 'Two-factor authentication', class: 'col-form-label col-sm-2 pt-0'
+ .col-sm-2.col-form-label.pt-0
+ = f.label :require_two_factor_authentication, 'Two-factor authentication'
.col-sm-10
.form-check
= f.check_box :require_two_factor_authentication, class: 'form-check-input'
diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml
index 39c0c113793..4daf3683eaf 100644
--- a/app/views/groups/_home_panel.html.haml
+++ b/app/views/groups/_home_panel.html.haml
@@ -13,7 +13,7 @@
= visibility_level_icon(@group.visibility_level, fw: false, options: {class: 'icon'})
.home-panel-metadata.d-flex.align-items-center.text-secondary
%span
- = _("Group")
+ = _("Group ID: %{group_id}") % { group_id: @group.id }
- if current_user
%span.access-request-links.prepend-left-8
= render 'shared/members/access_request_links', source: @group
@@ -47,7 +47,7 @@
%strong= new_subgroup_label
%span= s_("GroupsTree|Create a subgroup in this group.")
- else
- = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success"
+ = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success prepend-top-default"
- if @group.description.present?
.group-home-desc.mt-1
diff --git a/app/views/groups/_shared_projects.html.haml b/app/views/groups/_shared_projects.html.haml
index 4eb8367f633..2769b69add3 100644
--- a/app/views/groups/_shared_projects.html.haml
+++ b/app/views/groups/_shared_projects.html.haml
@@ -4,5 +4,5 @@
%ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
.js-groups-list-holder
- .loading-container.text-center
- = icon('spinner spin 2x', class: 'loading-animation prepend-top-20')
+ .loading-container.text-center.prepend-top-20
+ .spinner.spinner-md
diff --git a/app/views/groups/_subgroups_and_projects.html.haml b/app/views/groups/_subgroups_and_projects.html.haml
index d53c8026df8..784f5ac233e 100644
--- a/app/views/groups/_subgroups_and_projects.html.haml
+++ b/app/views/groups/_subgroups_and_projects.html.haml
@@ -4,5 +4,5 @@
%ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
.js-groups-list-holder
- .loading-container.text-center
- = icon('spinner spin 2x', class: 'loading-animation prepend-top-20')
+ .loading-container.text-center.prepend-top-20
+ .spinner.spinner-md
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index 39d0f620283..0c8f86c2822 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -1,6 +1,6 @@
- breadcrumb_title "General Settings"
- @content_class = "limit-container-width" unless fluid_layout
-- expanded = Rails.env.test?
+- expanded = expanded_by_default?
%section.settings.gs-general.no-animate#js-general-settings{ class: ('expanded') }
@@ -25,6 +25,8 @@
.settings-content
= render 'groups/settings/permissions'
+= render_if_exists 'groups/insights', expanded: expanded
+
%section.settings.no-animate#js-badge-settings{ class: ('expanded' if expanded) }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' }
diff --git a/app/views/groups/group_members/_new_group_member.html.haml b/app/views/groups/group_members/_new_group_member.html.haml
index c8cdc2cc3e4..8b511f6866f 100644
--- a/app/views/groups/group_members/_new_group_member.html.haml
+++ b/app/views/groups/group_members/_new_group_member.html.haml
@@ -1,7 +1,7 @@
= form_for @group_member, url: group_group_members_path(@group), html: { class: 'users-project-form users-group-form' } do |f|
.row
.col-md-4.col-lg-6
- = users_select_tag(:user_ids, multiple: true, class: 'input-clamp', scope: :all, email_user: true)
+ = users_select_tag(:user_ids, group_member_select_options)
.form-text.text-muted.append-bottom-10
Search for members by name, username, or email, or invite new ones using their email address.
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index 2af3e861587..021c0b6c429 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -14,6 +14,8 @@
= render 'shared/members/requests', membership_source: @group, requesters: @requesters
+ = render_if_exists 'groups/group_members/ldap_sync'
+
.clearfix
%h5.member.existing-title
Existing members
@@ -22,7 +24,7 @@
%span.flex-project-title
Members with access to
%strong= @group.name
- %span.badge= @members.total_count
+ %span.badge.badge-pill= @members.total_count
= form_tag group_group_members_path(@group), method: :get, class: 'form-inline member-search-form flex-project-members-form' do
.form-group
.position-relative.append-right-8
diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml
index 5cf3193bc62..a8358704b03 100644
--- a/app/views/groups/labels/index.html.haml
+++ b/app/views/groups/labels/index.html.haml
@@ -1,7 +1,6 @@
- @no_container = true
-- page_title "Labels"
+- page_title 'Labels'
- can_admin_label = can?(current_user, :admin_label, @group)
-- issuables = ['issues', 'merge requests']
- search = params[:search]
- subscribed = params[:subscribed]
- labels_or_filters = @labels.exists? || search.present? || subscribed.present?
@@ -14,11 +13,11 @@
.labels-container.prepend-top-5
- if @labels.any?
.text-muted
- = _('Labels can be applied to %{features}. Group labels are available for any project within the group.') % { features: issuables.to_sentence }
+ = _('Labels can be applied to %{features}. Group labels are available for any project within the group.') % { features: issuable_types.to_sentence }
.other-labels
%h5= _('Labels')
%ul.content-list.manage-labels-list.js-other-labels
- = render partial: 'shared/label', subject: @group, collection: @labels, as: :label, locals: { use_label_priority: false }
+ = render partial: 'shared/label', collection: @labels, as: :label, locals: { use_label_priority: false, subject: @group }
= paginate @labels, theme: 'gitlab'
- elsif search.present?
.nothing-here-block
diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml
index 51dcc9d0cda..06e05d898d6 100644
--- a/app/views/groups/new.html.haml
+++ b/app/views/groups/new.html.haml
@@ -3,7 +3,7 @@
- page_title _('New Group')
- header_title _("Groups"), dashboard_groups_path
-.page-title-holder
+.page-title-holder.d-flex.align-items-center
%h1.page-title= _('New group')
.row.prepend-top-default
.col-lg-3.profile-settings-sidebar
@@ -27,7 +27,7 @@
.form-group.group-description-holder.col-sm-12
= f.label :avatar, _("Group avatar"), class: 'label-bold'
%div
- = render 'shared/choose_group_avatar_button', f: f
+ = render 'shared/choose_avatar_button', f: f
.form-group.col-sm-12
%label.label-bold
diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml
index 9ed71d19d32..e12748666c8 100644
--- a/app/views/groups/settings/_general.html.haml
+++ b/app/views/groups/settings/_general.html.haml
@@ -17,17 +17,17 @@
= f.label :description, _('Group description (optional)'), class: 'label-bold'
= f.text_area :description, class: 'form-control', rows: 3, maxlength: 250
- = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group
+ = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group
- .form-group.prepend-top-default.append-bottom-20
- .avatar-container.rect-avatar.s90
- = group_icon(@group, alt: '', class: 'avatar group-avatar s90')
- = f.label :avatar, _('Group avatar'), class: 'label-bold d-block'
- = render 'shared/choose_group_avatar_button', f: f
- - if @group.avatar?
- %hr
- = link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-danger btn-inverted'
+ .form-group.prepend-top-default.append-bottom-20
+ .avatar-container.rect-avatar.s90
+ = group_icon(@group, alt: '', class: 'avatar group-avatar s90')
+ = f.label :avatar, _('Group avatar'), class: 'label-bold d-block'
+ = render 'shared/choose_avatar_button', f: f
+ - if @group.avatar?
+ %hr
+ = link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link'
- = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group
+ = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group
= f.submit _('Save changes'), class: 'btn btn-success mt-4 js-dirty-submit'
diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml
index 6b0a6e7ed99..0a14830c666 100644
--- a/app/views/groups/settings/_permissions.html.haml
+++ b/app/views/groups/settings/_permissions.html.haml
@@ -18,6 +18,7 @@
%span.descr.text-muted= share_with_group_lock_help_text(@group)
= render 'groups/settings/lfs', f: f
+ = render 'groups/settings/project_creation_level', f: f, group: @group
= render 'groups/settings/two_factor_auth', f: f
= render_if_exists 'groups/member_lock_setting', f: f, group: @group
diff --git a/app/views/groups/settings/_project_creation_level.html.haml b/app/views/groups/settings/_project_creation_level.html.haml
new file mode 100644
index 00000000000..9f711e6aade
--- /dev/null
+++ b/app/views/groups/settings/_project_creation_level.html.haml
@@ -0,0 +1,3 @@
+.form-group
+ = f.label s_('ProjectCreationLevel|Allowed to create projects'), class: 'label-bold'
+ = f.select :project_creation_level, options_for_select(::Gitlab::Access.project_creation_options, group.project_creation_level), {}, class: 'form-control'
diff --git a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
new file mode 100644
index 00000000000..e7efc0237c8
--- /dev/null
+++ b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
@@ -0,0 +1,15 @@
+= form_for group, url: update_auto_devops_group_settings_ci_cd_path(group), method: :patch do |f|
+ = form_errors(group)
+ %fieldset
+ .form-group
+ .card.auto-devops-card
+ .card-body
+ .form-check
+ = f.check_box :auto_devops_enabled, class: 'form-check-input', checked: group.auto_devops_enabled?
+ = f.label :auto_devops_enabled, class: 'form-check-label' do
+ %strong= s_('GroupSettings|Default to Auto DevOps pipeline for all projects within this group')
+ %span.badge.badge-info#auto-devops-badge= badge_for_auto_devops_scope(group)
+ .form-text.text-muted
+ = s_('GroupSettings|The Auto DevOps pipeline will run if no alternative CI configuration file is found.')
+ = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank'
+ = f.submit _('Save changes'), class: 'btn btn-success prepend-top-15'
diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml
index d9332e36ef5..d21496ee0aa 100644
--- a/app/views/groups/settings/ci_cd/show.html.haml
+++ b/app/views/groups/settings/ci_cd/show.html.haml
@@ -1,7 +1,7 @@
- breadcrumb_title "CI / CD Settings"
- page_title "CI / CD"
-- expanded = Rails.env.test?
+- expanded = expanded_by_default?
%section.settings#ci-variables.no-animate{ class: ('expanded' if expanded) }
.settings-header
@@ -19,3 +19,17 @@
= _('Register and see your runners for this group.')
.settings-content
= render 'groups/runners/index'
+
+%section.settings#auto-devops-settings.no-animate{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Auto DevOps')
+ %button.btn.btn-default.js-settings-toggle{ type: "button" }
+ = expanded ? _('Collapse') : _('Expand')
+ %p
+ - auto_devops_url = help_page_path('topics/autodevops/index')
+ - auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url }
+ = s_('GroupSettings|Auto DevOps will automatically build, test and deploy your application based on a predefined Continuous Integration and Delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end}').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe }
+
+ .settings-content
+ = render 'groups/settings/ci_cd/auto_devops_form', group: @group
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index 77fe88dacb7..255a9ad038c 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -9,7 +9,7 @@
= render 'groups/home_panel'
.groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } }
- .top-area.group-nav-container
+ .top-area.group-nav-container.justify-content-between
.scrolling-tabs-container.inner-page-scroll-tabs
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml
index 28ffb2dd63c..efb3815b257 100644
--- a/app/views/help/_shortcuts.html.haml
+++ b/app/views/help/_shortcuts.html.haml
@@ -356,6 +356,18 @@
%td.shortcut
%kbd l
%td Change Label
+ %tr
+ %td.shortcut
+ %kbd ]
+ \/
+ %kbd j
+ %td Move to next file
+ %tr
+ %td.shortcut
+ %kbd [
+ \/
+ %kbd k
+ %td Move to previous file
%tbody.hidden-shortcut{ style: 'display:none' }
%tr
%th
diff --git a/app/views/help/index.html.haml b/app/views/help/index.html.haml
index dfa5d820ce9..50933c7d434 100644
--- a/app/views/help/index.html.haml
+++ b/app/views/help/index.html.haml
@@ -1,11 +1,11 @@
%div
- if Gitlab::CurrentSettings.help_page_text.present?
- = markdown_field(Gitlab::CurrentSettings.current_application_settings, :help_page_text)
+ .prepend-top-default.md
+ = markdown_field(Gitlab::CurrentSettings.current_application_settings, :help_page_text)
%hr
%h1
- GitLab
- Community Edition
+ = default_brand_title
- if user_signed_in?
%span= link_to_version
= version_status_badge
@@ -24,12 +24,13 @@
Used by more than 100,000 organizations, GitLab is the most popular solution to manage git repositories on-premises.
%br
Read more about GitLab at #{link_to promo_host, promo_url, target: '_blank', rel: 'noopener noreferrer'}.
- %p= link_to 'Check the current instance configuration ', help_instance_configuration_url
- %hr
+
+%p= link_to 'Check the current instance configuration ', help_instance_configuration_url
+%hr
.row.prepend-top-default
.col-md-8
- .documentation-index.wiki
+ .documentation-index.md
= markdown(@help_index)
.col-md-4
.card
diff --git a/app/views/help/instance_configuration.html.haml b/app/views/help/instance_configuration.html.haml
index f09e3825a4b..99576d45f76 100644
--- a/app/views/help/instance_configuration.html.haml
+++ b/app/views/help/instance_configuration.html.haml
@@ -1,5 +1,5 @@
- page_title 'Instance Configuration'
-.wiki.documentation
+.documentation.md
%h1 Instance Configuration
%p
diff --git a/app/views/help/show.html.haml b/app/views/help/show.html.haml
index c07c148a12a..dce27dee9be 100644
--- a/app/views/help/show.html.haml
+++ b/app/views/help/show.html.haml
@@ -1,3 +1,3 @@
- page_title @path.split("/").reverse.map(&:humanize)
-.documentation.wiki.prepend-top-default
+.documentation.md.prepend-top-default
= markdown @markdown
diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml
index 506f580b246..cdc894ee5a0 100644
--- a/app/views/help/ui.html.haml
+++ b/app/views/help/ui.html.haml
@@ -70,7 +70,7 @@
.cover-title
John Smith
- .cover-desc
+ .cover-desc.cgray
= lorem
.cover-controls
@@ -513,7 +513,7 @@
%h2#markdown Markdown
%h4
- %code .md or .wiki and others
+ %code .md
Markdown rendering has a bit different css and presented in next UI elements:
diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml
index 9280f12e187..40609fddbde 100644
--- a/app/views/import/bitbucket_server/status.html.haml
+++ b/app/views/import/bitbucket_server/status.html.haml
@@ -29,7 +29,7 @@
%tr
%th= _('From Bitbucket Server')
%th= _('To GitLab')
- %th= _(' Status')
+ %th= _('Status')
%tbody
- @already_added_projects.each do |project|
%tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" }
diff --git a/app/views/import/gitea/new.html.haml b/app/views/import/gitea/new.html.haml
index a88b04eccbb..c4670869c93 100644
--- a/app/views/import/gitea/new.html.haml
+++ b/app/views/import/gitea/new.html.haml
@@ -2,18 +2,18 @@
- header_title _("Projects"), root_path
%h3.page-title
- = custom_icon('go_logo')
+ = custom_icon('gitea_logo')
= _('Import Projects from Gitea')
%p
- - link_to_personal_token = link_to(_('Personal Access Token'), 'https://github.com/gogits/go-gogs-client/wiki#access-token')
+ - link_to_personal_token = link_to(_('Personal Access Token'), 'https://docs.gitea.io/en-us/api-usage/#authentication-via-the-api')
= _('To get started, please enter your Gitea Host URL and a %{link_to_personal_token}.').html_safe % { link_to_personal_token: link_to_personal_token }
= form_tag personal_access_token_import_gitea_path do
.form-group.row
= label_tag :gitea_host_url, _('Gitea Host URL'), class: 'col-form-label col-sm-2'
.col-sm-4
- = text_field_tag :gitea_host_url, nil, placeholder: 'https://try.gitea.io', class: 'form-control'
+ = text_field_tag :gitea_host_url, nil, placeholder: 'https://gitea.com', class: 'form-control'
.form-group.row
= label_tag :personal_access_token, _('Personal Access Token'), class: 'col-form-label col-sm-2'
.col-sm-4
diff --git a/app/views/import/gitea/status.html.haml b/app/views/import/gitea/status.html.haml
index 88244fde16b..ef0693e73c3 100644
--- a/app/views/import/gitea/status.html.haml
+++ b/app/views/import/gitea/status.html.haml
@@ -1,7 +1,7 @@
- page_title _("Gitea Import")
- header_title _("Projects"), root_path
%h3.page-title
- = custom_icon('go_logo')
+ = custom_icon('gitea_logo')
= _('Import Projects from Gitea')
= render 'import/githubish_status', provider: 'gitea'
diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml
index cf32c5c9387..72e5934574a 100644
--- a/app/views/import/github/new.html.haml
+++ b/app/views/import/github/new.html.haml
@@ -22,6 +22,8 @@
= text_field_tag :personal_access_token, '', class: 'form-control append-right-8', placeholder: _('Personal Access Token'), size: 40
= submit_tag _('List your GitHub repositories'), class: 'btn btn-success'
+ = render_if_exists 'import/github/ci_cd_only'
+
- unless github_import_configured?
%hr
%p
diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml
index 5e4595d930b..a19c8911559 100644
--- a/app/views/import/gitlab_projects/new.html.haml
+++ b/app/views/import/gitlab_projects/new.html.haml
@@ -7,28 +7,7 @@
%hr
= form_tag import_gitlab_project_path, class: 'new_project', multipart: true do
- .row
- .form-group.project-name.col-sm-12
- = label_tag :name, _('Project name'), class: 'label-bold'
- = text_field_tag :name, @name, placeholder: "My awesome project", class: "js-project-name form-control input-lg", autofocus: true
- .form-group.col-12.col-sm-6
- = label_tag :namespace_id, _('Project URL'), class: 'label-bold'
- .form-group
- .input-group
- - if current_user.can_select_namespace?
- .input-group-prepend.has-tooltip{ title: root_url }
- .input-group-text
- = root_url
- = select_tag :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), class: 'select2 js-select-namespace', tabindex: 1
-
- - else
- .input-group-prepend.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' }
- .input-group-text.border-0
- #{user_url(current_user.username)}/
- = hidden_field_tag :namespace_id, value: current_user.namespace_id
- .form-group.col-12.col-sm-6.project-path
- = label_tag :path, _('Project slug'), class: 'label-bold'
- = text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control", tabindex: 2, required: true
+ = render 'import/shared/new_project_form'
.row
.form-group.col-md-12
diff --git a/app/views/import/manifest/new.html.haml b/app/views/import/manifest/new.html.haml
index 056e4922b9e..df00c4d2179 100644
--- a/app/views/import/manifest/new.html.haml
+++ b/app/views/import/manifest/new.html.haml
@@ -4,9 +4,5 @@
%h3.page-title
= _('Manifest file import')
-- if @errors.present?
- .alert.alert-danger
- - @errors.each do |error|
- = error
-
+= render 'import/shared/errors'
= render 'form'
diff --git a/app/views/import/phabricator/new.html.haml b/app/views/import/phabricator/new.html.haml
new file mode 100644
index 00000000000..811e126579e
--- /dev/null
+++ b/app/views/import/phabricator/new.html.haml
@@ -0,0 +1,25 @@
+- title = _('Phabricator Server Import')
+- page_title title
+- breadcrumb_title title
+- header_title _("Projects"), root_path
+
+%h3.page-title
+ = icon 'issues', text: _('Import tasks from Phabricator into issues')
+
+= render 'import/shared/errors'
+
+= form_tag import_phabricator_path, class: 'new_project', method: :post do
+ = render 'import/shared/new_project_form'
+
+ %h4.prepend-top-0= _('Enter in your Phabricator Server URL and personal access token below')
+
+ .form-group.row
+ = label_tag :phabricator_server_url, _('Phabricator Server URL'), class: 'col-form-label col-md-2'
+ .col-md-4
+ = text_field_tag :phabricator_server_url, params[:phabricator_server_url], class: 'form-control append-right-8', placeholder: 'https://your-phabricator-server', size: 40
+ .form-group.row
+ = label_tag :api_token, _('API Token'), class: 'col-form-label col-md-2'
+ .col-md-4
+ = password_field_tag :api_token, params[:api_token], class: 'form-control append-right-8', placeholder: _('Personal Access Token'), size: 40
+ .form-actions
+ = submit_tag _('Import tasks'), class: 'btn btn-success'
diff --git a/app/views/import/shared/_errors.html.haml b/app/views/import/shared/_errors.html.haml
new file mode 100644
index 00000000000..de60c15351f
--- /dev/null
+++ b/app/views/import/shared/_errors.html.haml
@@ -0,0 +1,4 @@
+- if @errors.present?
+ .alert.alert-danger
+ - @errors.each do |error|
+ = error
diff --git a/app/views/import/shared/_new_project_form.html.haml b/app/views/import/shared/_new_project_form.html.haml
new file mode 100644
index 00000000000..4d13d4f2869
--- /dev/null
+++ b/app/views/import/shared/_new_project_form.html.haml
@@ -0,0 +1,21 @@
+.row
+ .form-group.project-name.col-sm-12
+ = label_tag :name, _('Project name'), class: 'label-bold'
+ = text_field_tag :name, @name, placeholder: "My awesome project", class: "js-project-name form-control input-lg", autofocus: true
+ .form-group.col-12.col-sm-6
+ = label_tag :namespace_id, _('Project URL'), class: 'label-bold'
+ .form-group
+ .input-group.flex-nowrap
+ - if current_user.can_select_namespace?
+ .input-group-prepend.flex-shrink-0.has-tooltip{ title: root_url }
+ .input-group-text
+ = root_url
+ = select_tag :namespace_id, namespaces_options(namespace_id_from(params) || :current_user, display_path: true, extra_group: namespace_id_from(params)), class: 'select2 js-select-namespace', tabindex: 1
+ - else
+ .input-group-prepend.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' }
+ .input-group-text.border-0
+ #{user_url(current_user.username)}/
+ = hidden_field_tag :namespace_id, value: current_user.namespace_id
+ .form-group.col-12.col-sm-6.project-path
+ = label_tag :path, _('Project slug'), class: 'label-bold'
+ = text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control", tabindex: 2, required: true
diff --git a/app/views/issues/_issue.atom.builder b/app/views/issues/_issue.atom.builder
index 21cf6d0dd65..94c32df7c60 100644
--- a/app/views/issues/_issue.atom.builder
+++ b/app/views/issues/_issue.atom.builder
@@ -12,6 +12,7 @@ xml.entry do
xml.summary issue.title
xml.description issue.description if issue.description
+ xml.content issue.description if issue.description
xml.milestone issue.milestone.title if issue.milestone
xml.due_date issue.due_date if issue.due_date
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 11e83ddfe64..c357207054b 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -77,3 +77,4 @@
= render 'layouts/google_analytics' if extra_config.has_key?('google_analytics_id')
= render 'layouts/piwik' if extra_config.has_key?('piwik_url') && extra_config.has_key?('piwik_site_id')
+ = render_if_exists 'layouts/snowplow'
diff --git a/app/views/layouts/_mailer.html.haml b/app/views/layouts/_mailer.html.haml
index 26fd34347ec..6e8294d6adc 100644
--- a/app/views/layouts/_mailer.html.haml
+++ b/app/views/layouts/_mailer.html.haml
@@ -52,6 +52,7 @@
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#6b4fbb;height:4px;font-size:4px;line-height:4px;" }
%tr.header
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" }
+ = html_header_message
= header_logo
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" }
@@ -63,6 +64,8 @@
%tbody
= yield
+ = render_if_exists 'layouts/mailer/additional_text'
+
%tr.footer
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" }
%img{ alt: "GitLab", height: "33", src: image_url('mailers/gitlab_footer_logo.gif'), style: "display:block;margin:0 auto 1em;", width: "90" }/
@@ -72,3 +75,6 @@
= _("You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}").html_safe % { host: Gitlab.config.gitlab.host, manage_notifications_link: manage_notifications_link, help_link: help_link }
= yield :additional_footer
+ %tr
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" }
+ = html_footer_message
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 1b2a4cd6780..006334ade07 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -5,8 +5,10 @@
= render 'shared/outdated_browser'
.mobile-overlay
.alert-wrapper
+ = render_if_exists "layouts/header/ee_license_banner"
= render "layouts/broadcast"
= render "layouts/header/read_only_banner"
+ = render "layouts/nav/classification_level_banner"
= yield :flash_message
= render "shared/ping_consent"
- unless @hide_breadcrumbs
diff --git a/app/views/layouts/_piwik.html.haml b/app/views/layouts/_piwik.html.haml
index a888e8ae187..473b14ce626 100644
--- a/app/views/layouts/_piwik.html.haml
+++ b/app/views/layouts/_piwik.html.haml
@@ -7,7 +7,7 @@
(function() {
var u="//#{extra_config.piwik_url}/";
_paq.push(['setTrackerUrl', u+'piwik.php']);
- _paq.push(['setSiteId', #{extra_config.piwik_site_id}]);
+ _paq.push(['setSiteId', "#{extra_config.piwik_site_id}"]);
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s);
})();
diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml
index a6023a1cbb9..496ec3c78b0 100644
--- a/app/views/layouts/_search.html.haml
+++ b/app/views/layouts/_search.html.haml
@@ -16,7 +16,7 @@
mr_path: merge_requests_dashboard_path },
aria: { label: _('Search or jump to…') }
%button.hidden.js-dropdown-search-toggle{ type: 'button', data: { toggle: 'dropdown' } }
- .dropdown-menu.dropdown-select
+ .dropdown-menu.dropdown-select.js-dashboard-search-options
= dropdown_content do
%ul
%li.dropdown-menu-empty-item
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 043cca6ad38..c38f96f302a 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -10,4 +10,6 @@
= render 'layouts/page', sidebar: sidebar, nav: nav
= footer_message
+ = render_if_exists "shared/onboarding_guide"
+
= yield :scripts_body
diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml
index 2f3c13aaf6e..ff3410f6268 100644
--- a/app/views/layouts/devise.html.haml
+++ b/app/views/layouts/devise.html.haml
@@ -10,15 +10,17 @@
.container.navless-container
.content
= render "layouts/flash"
- .row.append-bottom-15
- .col-sm-7.brand-holder
- %h1
+ .row.mt-3
+ .col-sm-12
+ %h1.mb-3.font-weight-normal
= brand_title
+ .row.mb-3
+ .col-sm-7.order-12.order-sm-1.brand-holder
= brand_image
- if current_appearance&.description?
= brand_text
- else
- %h3
+ %h3.mt-sm-0
= _('Open source software to collaborate on code')
%p
@@ -26,7 +28,10 @@
- if Gitlab::CurrentSettings.sign_in_text.present?
= markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text)
- .col-sm-5.new-session-forms-container
+
+ = render_if_exists 'layouts/devise_help_text'
+
+ .col-sm-5.order-1.order-sm-12.new-session-forms-container
= yield
%hr.footer-fixed
diff --git a/app/views/layouts/empty_mailer.html.haml b/app/views/layouts/empty_mailer.html.haml
new file mode 100644
index 00000000000..a25dcefd445
--- /dev/null
+++ b/app/views/layouts/empty_mailer.html.haml
@@ -0,0 +1,5 @@
+= html_header_message
+
+= yield
+
+= html_footer_message
diff --git a/app/views/layouts/empty_mailer.text.erb b/app/views/layouts/empty_mailer.text.erb
new file mode 100644
index 00000000000..6ab0dbead07
--- /dev/null
+++ b/app/views/layouts/empty_mailer.text.erb
@@ -0,0 +1,5 @@
+<%= text_header_message %>
+
+<%= yield -%>
+
+<%= text_footer_message %>
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index a9b85889846..f8b7d0c530a 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -17,6 +17,10 @@
- if logo_text.present?
%span.logo-text.d-none.d-lg-block.prepend-left-8
= logo_text
+ - if Gitlab.com?
+ = link_to 'https://next.gitlab.com', class: 'label-link js-canary-badge canary-badge bg-transparent hidden', target: :_blank do
+ %span.color-label.has-tooltip.badge.badge-pill.green-badge
+ = _('Next')
- if current_user
= render "layouts/nav/dashboard"
@@ -38,7 +42,7 @@
= link_to assigned_issues_dashboard_path, title: _('Issues'), class: 'dashboard-shortcuts-issues', aria: { label: _('Issues') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('issues', size: 16)
- issues_count = assigned_issuables_count(:issues)
- %span.badge.badge-pill.issues-count{ class: ('hidden' if issues_count.zero?) }
+ %span.badge.badge-pill.issues-count.green-badge{ class: ('hidden' if issues_count.zero?) }
= number_with_delimiter(issues_count)
- if header_link?(:merge_requests)
= nav_link(path: 'dashboard#merge_requests', html_options: { class: "user-counter" }) do
diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml
index cd9128c452b..5643a508ddc 100644
--- a/app/views/layouts/header/_help_dropdown.html.haml
+++ b/app/views/layouts/header/_help_dropdown.html.haml
@@ -2,8 +2,13 @@
- if current_user_menu?(:help)
%li
= link_to _("Help"), help_path
+ = render_if_exists "shared/learn_gitlab_menu_item"
%li.divider
%li
= link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback"
- if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile)
= render 'shared/user_dropdown_contributing_link'
+ = render_if_exists 'shared/user_dropdown_instance_review'
+ - if Gitlab.com?
+ %li.js-canary-link
+ = link_to _("Switch to GitLab Next"), "https://next.gitlab.com/"
diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml
index 5a66b02c048..438340464bd 100644
--- a/app/views/layouts/header/_new_dropdown.haml
+++ b/app/views/layouts/header/_new_dropdown.haml
@@ -38,4 +38,4 @@
%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
+ %li= link_to _('New snippet'), new_snippet_path, class: 'qa-global-new-snippet-link'
diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb
index 8e11174f8d7..1a06ea68bcd 100644
--- a/app/views/layouts/mailer.text.erb
+++ b/app/views/layouts/mailer.text.erb
@@ -1,4 +1,9 @@
+<%= text_header_message %>
+
<%= yield -%>
-- <%# signature marker %>
<%= _("You're receiving this email because of your account on %{host}.") % { host: Gitlab.config.gitlab.host } %>
+<%= render_if_exists 'layouts/mailer/additional_text' %>
+
+<%= text_footer_message %>
diff --git a/app/views/layouts/nav/_classification_level_banner.html.haml b/app/views/layouts/nav/_classification_level_banner.html.haml
new file mode 100644
index 00000000000..cc4caf079b8
--- /dev/null
+++ b/app/views/layouts/nav/_classification_level_banner.html.haml
@@ -0,0 +1,5 @@
+- if ::Gitlab::ExternalAuthorization.enabled? && @project
+ = content_for :header_content do
+ %span.badge.color-label.classification-label.has-tooltip{ title: s_('ExternalAuthorizationService|Classification label') }
+ = sprite_icon('lock-open', size: 8, css_class: 'inline')
+ = @project.external_authorization_classification_label
diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml
index f659c89dd30..54028dc8554 100644
--- a/app/views/layouts/nav/_dashboard.html.haml
+++ b/app/views/layouts/nav/_dashboard.html.haml
@@ -3,7 +3,7 @@
%ul.list-unstyled.navbar-sub-nav
- if dashboard_nav_link?(:projects)
= nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown", data: { track_label: "projects_dropdown", track_event: "click_dropdown" } }) do
- %button{ type: 'button', data: { toggle: "dropdown" } }
+ %button.btn{ type: 'button', data: { toggle: "dropdown" } }
= _('Projects')
= sprite_icon('angle-down', css_class: 'caret-down')
.dropdown-menu.frequent-items-dropdown-menu
@@ -11,7 +11,7 @@
- if dashboard_nav_link?(:groups)
= nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "home dropdown header-groups qa-groups-dropdown", data: { track_label: "groups_dropdown", track_event: "click_dropdown" } }) do
- %button{ type: 'button', data: { toggle: "dropdown" } }
+ %button.btn{ type: 'button', data: { toggle: "dropdown" } }
= _('Groups')
= sprite_icon('angle-down', css_class: 'caret-down')
.dropdown-menu.frequent-items-dropdown-menu
@@ -19,17 +19,17 @@
- if dashboard_nav_link?(:activity)
= nav_link(path: 'dashboard#activity', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do
- = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: _('Activity') do
+ = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity' do
= _('Activity')
- if dashboard_nav_link?(:milestones)
= nav_link(controller: 'dashboard/milestones', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do
- = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: _('Milestones') do
+ = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones' do
= _('Milestones')
- if dashboard_nav_link?(:snippets)
= nav_link(controller: 'dashboard/snippets', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do
- = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: _('Snippets') do
+ = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets qa-snippets-link' do
= _('Snippets')
- if any_dashboard_nav_link?([:groups, :milestones, :activity, :snippets])
@@ -41,47 +41,47 @@
%ul
- if dashboard_nav_link?(:activity)
= nav_link(path: 'dashboard#activity') do
- = link_to activity_dashboard_path, title: _('Activity') do
+ = link_to activity_dashboard_path do
= _('Activity')
- if dashboard_nav_link?(:milestones)
= nav_link(controller: 'dashboard/milestones') do
- = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: _('Milestones') do
+ = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones' do
= _('Milestones')
- if dashboard_nav_link?(:snippets)
= nav_link(controller: 'dashboard/snippets') do
- = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: _('Snippets') do
+ = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets' do
= _('Snippets')
-
- = render_if_exists 'dashboard/operations/nav_link'
+ %li.dropdown.d-lg-none
+ = render_if_exists 'dashboard/operations/nav_link_list'
- if can?(current_user, :read_instance_statistics)
- = nav_link(controller: [:conversational_development_index, :cohorts]) do
- = link_to instance_statistics_root_path, title: _('Instance Statistics'), aria: { label: _('Instance Statistics') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = nav_link(controller: [:conversational_development_index, :cohorts], html_options: { class: 'd-lg-none' }) do
+ = link_to instance_statistics_root_path do
= _('Instance Statistics')
- if current_user.admin?
= nav_link(controller: 'admin/dashboard') do
- = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = link_to admin_root_path, class: 'd-lg-none admin-icon qa-admin-area-link' do
= _('Admin Area')
- if Gitlab::Sherlock.enabled?
%li
- = link_to sherlock_transactions_path, class: 'admin-icon', title: _('Sherlock Transactions'),
- data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = link_to sherlock_transactions_path, class: 'd-lg-none admin-icon' do
= _('Sherlock Transactions')
-# Shortcut to Dashboard > Projects
- if dashboard_nav_link?(:projects)
%li.hidden
- = link_to dashboard_projects_path, title: _('Projects'), class: 'dashboard-shortcuts-projects' do
+ = link_to dashboard_projects_path, class: 'dashboard-shortcuts-projects' do
= _('Projects')
- if current_controller?('ide')
%li.line-separator.d-none.d-sm-block
= nav_link(controller: 'ide') do
- = link_to '#', class: 'dashboard-shortcuts-web-ide', title: _('Web IDE') do
+ = link_to '#', class: 'dashboard-shortcuts-web-ide' do
= _('Web IDE')
- = render_if_exists 'dashboard/operations/nav_link'
+ %li.dropdown{ class: 'd-none d-lg-block' }
+ = render_if_exists 'dashboard/operations/nav_link'
- if can?(current_user, :read_instance_statistics)
= nav_link(controller: [:conversational_development_index, :cohorts], html_options: { class: "d-none d-lg-block d-xl-block"}) do
= link_to instance_statistics_root_path, title: _('Instance Statistics'), aria: { label: _('Instance Statistics') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
@@ -95,3 +95,4 @@
= link_to sherlock_transactions_path, class: 'admin-icon d-none d-lg-block d-xl-block', title: _('Sherlock Transactions'),
data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('tachometer fw')
+ = render_if_exists 'layouts/nav/geo_primary_node_url'
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index 2fdd65f639b..83fe871285a 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -48,7 +48,7 @@
%span
= _('Gitaly Servers')
- = nav_link(controller: %w(system_info background_jobs logs health_check requests_profiles)) do
+ = nav_link(controller: admin_monitoring_nav_links) do
= link_to admin_system_info_path do
.nav-icon-container
= sprite_icon('monitor')
@@ -81,6 +81,7 @@
= link_to admin_requests_profiles_path, title: _('Requests Profiles') do
%span
= _('Requests Profiles')
+ = render_if_exists 'layouts/nav/ee/admin/new_monitoring_sidebar'
= nav_link(controller: :broadcast_messages) do
= link_to admin_broadcast_messages_path do
@@ -132,6 +133,21 @@
= _('Abuse Reports')
%span.badge.badge-pill.count.merge_counter.js-merge-counter.fly-out-badge= number_with_delimiter(AbuseReport.count(:all))
+ = render_if_exists 'layouts/nav/sidebar/licenses_link'
+
+ - if instance_clusters_enabled?
+ = nav_link(controller: :clusters) do
+ = link_to admin_clusters_path do
+ .nav-icon-container
+ = sprite_icon('cloud-gear')
+ %span.nav-item-name
+ = _('Kubernetes')
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :clusters, html_options: { class: "fly-out-top-item" } ) do
+ = link_to admin_clusters_path do
+ %strong.fly-out-top-item-name
+ = _('Kubernetes')
+
- if akismet_enabled?
= nav_link(controller: :spam_logs) do
= link_to admin_spam_logs_path do
@@ -145,6 +161,10 @@
%strong.fly-out-top-item-name
= _('Spam Logs')
+ = render_if_exists 'layouts/nav/sidebar/push_rules_link'
+
+ = render_if_exists 'layouts/nav/ee/admin/geo_sidebar'
+
= nav_link(controller: :deploy_keys) do
= link_to admin_deploy_keys_path do
.nav-icon-container
@@ -220,7 +240,7 @@
= _('Repository')
- if template_exists?('admin/application_settings/templates')
= nav_link(path: 'application_settings#templates') do
- = link_to templates_admin_application_settings_path, title: _('Templates') do
+ = link_to templates_admin_application_settings_path, title: _('Templates'), class: 'qa-admin-settings-template-item' do
%span
= _('Templates')
= nav_link(path: 'application_settings#ci_cd') do
@@ -232,7 +252,7 @@
%span
= _('Reporting')
= nav_link(path: 'application_settings#metrics_and_profiling') do
- = link_to metrics_and_profiling_admin_application_settings_path, title: _('Metrics and profiling') do
+ = link_to metrics_and_profiling_admin_application_settings_path, title: _('Metrics and profiling'), class: 'qa-admin-settings-metrics-and-profiling-item' do
%span
= _('Metrics and profiling')
= nav_link(path: 'application_settings#network') do
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index 21ea9f3b2f3..0fc5ebbea7e 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -1,6 +1,5 @@
- issues_count = group_issues_count(state: 'opened')
- merge_requests_count = group_merge_requests_count(state: 'opened')
-- issues_sub_menu_items = ['groups#issues', 'labels#index', 'milestones#index', 'boards#index', 'boards#show']
.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) }
.nav-sidebar-inner-scroll
@@ -20,13 +19,14 @@
= _('Overview')
%ul.sidebar-sub-level-items
- = nav_link(path: ['groups#show', 'groups#activity', 'groups#subgroups'], html_options: { class: "fly-out-top-item" } ) do
+ = nav_link(path: ['groups#show', 'groups#details', 'groups#activity', 'groups#subgroups'], html_options: { class: "fly-out-top-item" } ) do
= link_to group_path(@group) do
%strong.fly-out-top-item-name
= _('Overview')
%li.divider.fly-out-top-item
- = nav_link(path: ['groups#show', 'groups#subgroups'], html_options: { class: 'home' }) do
- = link_to group_path(@group), title: _('Group details') do
+
+ = nav_link(path: ['groups#show', 'groups#details', 'groups#subgroups'], html_options: { class: 'home' }) do
+ = link_to details_group_path(@group), title: _('Group details') do
%span
= _('Details')
@@ -40,14 +40,17 @@
- 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' } do
%span
- Contribution Analytics
+ = _('Contribution Analytics')
+
+ = render_if_exists 'layouts/nav/group_insights_link'
+ = render_if_exists 'groups/sidebar/dependency_proxy' # EE-specific
= render_if_exists "layouts/nav/ee/epic_link", group: @group
- if group_sidebar_link?(:issues)
- = nav_link(path: issues_sub_menu_items) do
+ = nav_link(path: group_issues_sub_menu_items) do
= link_to issues_group_path(@group) do
.nav-icon-container
= sprite_icon('issues')
diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml
index 1e3bb8f1224..7dd33f3c641 100644
--- a/app/views/layouts/nav/sidebar/_profile.html.haml
+++ b/app/views/layouts/nav/sidebar/_profile.html.haml
@@ -4,7 +4,7 @@
= link_to profile_path, title: _('Profile Settings') do
.avatar-container.s40.settings-avatar
= image_tag avatar_icon_for_user(current_user, 40), class: "avatar s40 avatar-tile", alt: current_user.name
- .sidebar-context-title User Settings
+ .sidebar-context-title= _('User Settings')
%ul.sidebar-top-level-items
= nav_link(path: 'profiles#show', html_options: {class: 'home'}) do
= link_to profile_path do
@@ -28,6 +28,8 @@
= link_to profile_account_path do
%strong.fly-out-top-item-name
= _('Account')
+
+ = render_if_exists 'layouts/nav/sidebar/profile_billing_link'
= nav_link(controller: 'oauth/applications') do
= link_to applications_profile_path do
.nav-icon-container
@@ -151,4 +153,6 @@
%strong.fly-out-top-item-name
= _('Authentication Log')
+ = render_if_exists 'layouts/nav/sidebar/profile_pipeline_quota_link'
+
= render 'shared/sidebar_toggle_button'
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 7b492efeb09..399305baec1 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -41,6 +41,8 @@
= link_to project_cycle_analytics_path(@project), title: _('Cycle Analytics'), class: 'shortcuts-project-cycle-analytics' do
%span= _('Cycle Analytics')
+ = render_if_exists 'layouts/nav/project_insights_link'
+
- if project_nav_tab? :files
= nav_link(controller: sidebar_repository_paths) do
= link_to project_tree_path(@project), class: 'shortcuts-tree qa-project-menu-repo' do
@@ -268,6 +270,8 @@
%span= _("Got it!")
= sprite_icon('thumb-up')
+ = render_if_exists 'layouts/nav/sidebar/project_feature_flags_link'
+
- if project_nav_tab? :container_registry
= nav_link(controller: %w[projects/registry/repositories]) do
= link_to project_container_registry_index_path(@project), class: 'shortcuts-container-registry' do
@@ -281,7 +285,9 @@
%strong.fly-out-top-item-name
= _('Registry')
- - if project_nav_tab?(:wiki)
+ = render_if_exists 'layouts/nav/sidebar/project_packages_link'
+
+ - if project_nav_tab? :wiki
- wiki_url = project_wiki_path(@project, :home)
= nav_link(controller: :wikis) do
= link_to wiki_url, class: 'shortcuts-wiki qa-wiki-link' do
@@ -355,12 +361,12 @@
= link_to project_settings_repository_path(@project), title: _('Repository') do
%span
= _('Repository')
- - if @project.feature_available?(:builds, current_user)
+ - if !@project.archived? && @project.feature_available?(:builds, current_user)
= nav_link(controller: :ci_cd) do
= link_to project_settings_ci_cd_path(@project), title: _('CI / CD') do
%span
= _('CI / CD')
- - if settings_operations_available?
+ - if !@project.archived? && settings_operations_available?
= nav_link(controller: [:operations]) do
= link_to project_settings_operations_path(@project), title: _('Operations') do
= _('Operations')
diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml
index 1c3e05e07f4..de487a94d40 100644
--- a/app/views/layouts/notify.html.haml
+++ b/app/views/layouts/notify.html.haml
@@ -7,6 +7,7 @@
= yield :head
%body
.content
+ = html_header_message
= yield
.footer{ style: "margin-top: 10px;" }
%p
@@ -30,3 +31,6 @@
adjust your notification settings.
= email_action @target_url
+
+ = render_if_exists 'layouts/email_additional_text'
+ = html_footer_message
diff --git a/app/views/layouts/notify.text.erb b/app/views/layouts/notify.text.erb
index 9dc490efa9a..0ee30c2a6cf 100644
--- a/app/views/layouts/notify.text.erb
+++ b/app/views/layouts/notify.text.erb
@@ -1,3 +1,5 @@
+<%= text_header_message %>
+
<%= yield -%>
-- <%# signature marker %>
@@ -10,3 +12,6 @@
<% end -%>
<%= "You're receiving this email because #{notification_reason_text(@reason)}." %>
+<%= render_if_exists 'layouts/mailer/additional_text' %>
+
+<%= text_footer_message -%>
diff --git a/app/views/notify/_note_email.text.erb b/app/views/notify/_note_email.text.erb
index 5a67214059c..fae8fa3ccf3 100644
--- a/app/views/notify/_note_email.text.erb
+++ b/app/views/notify/_note_email.text.erb
@@ -1,5 +1,6 @@
<% note = local_assigns.fetch(:note, @note) -%>
<% diff_limit = local_assigns.fetch(:diff_limit, nil) -%>
+<% target_url = local_assigns.fetch(:target_url, @target_url) -%>
<% discussion = note.discussion if note.part_of_discussion? -%>
<% if discussion && !discussion.individual_note? -%>
@@ -13,6 +14,9 @@
<%= " on #{discussion.file_path}" -%>
<% end -%>
<%= ":" -%>
+<% if discussion.diff_discussion? || !discussion.new_discussion? -%>
+<%= " #{target_url}" -%>
+<% end -%>
<% elsif Gitlab::CurrentSettings.email_author_in_body -%>
diff --git a/app/views/notify/_reassigned_issuable_email.html.haml b/app/views/notify/_reassigned_issuable_email.html.haml
new file mode 100644
index 00000000000..4ab40ff2659
--- /dev/null
+++ b/app/views/notify/_reassigned_issuable_email.html.haml
@@ -0,0 +1,10 @@
+%p
+ Assignee changed
+ - if previous_assignees.any?
+ from
+ %strong= sanitize_name(previous_assignees.map(&:name).to_sentence)
+ to
+ - if issuable.assignees.any?
+ %strong= sanitize_name(issuable.assignee_list)
+ - else
+ %strong Unassigned
diff --git a/app/views/notify/_removal_notification.html.haml b/app/views/notify/_removal_notification.html.haml
new file mode 100644
index 00000000000..590e0d569aa
--- /dev/null
+++ b/app/views/notify/_removal_notification.html.haml
@@ -0,0 +1,9 @@
+- if @domain.remove_at
+ %p
+ Unless you verify your domain by
+ %strong= @domain.remove_at.strftime('%F %T,')
+ it will be removed from your GitLab project.
+- else
+ %p
+ If you no longer wish to use this domain with GitLab Pages, please remove it
+ from your GitLab project and delete any related DNS records.
diff --git a/app/views/notify/closed_issue_email.html.haml b/app/views/notify/closed_issue_email.html.haml
index eb148d72da1..d3733ab3a09 100644
--- a/app/views/notify/closed_issue_email.html.haml
+++ b/app/views/notify/closed_issue_email.html.haml
@@ -1,2 +1,2 @@
%p
- Issue was closed by #{sanitize_name(@updated_by.name)}
+ = _("Issue was closed by %{name} %{reason}").html_safe % { name: sanitize_name(@updated_by.name), reason: closure_reason_text(@closed_via, format: formats.first) }
diff --git a/app/views/notify/closed_issue_email.text.haml b/app/views/notify/closed_issue_email.text.haml
index b1f0a3f37ec..ff2548a4b42 100644
--- a/app/views/notify/closed_issue_email.text.haml
+++ b/app/views/notify/closed_issue_email.text.haml
@@ -1,3 +1,3 @@
-Issue was closed by #{sanitize_name(@updated_by.name)}
+= _("Issue was closed by %{name} %{reason}").html_safe % { name: sanitize_name(@updated_by.name), reason: closure_reason_text(@closed_via, format: formats.first) }
Issue ##{@issue.iid}: #{project_issue_url(@issue.project, @issue)}
diff --git a/app/views/notify/closed_merge_request_email.text.haml b/app/views/notify/closed_merge_request_email.text.haml
index 1094d584a1c..6e84f9fb355 100644
--- a/app/views/notify/closed_merge_request_email.text.haml
+++ b/app/views/notify/closed_merge_request_email.text.haml
@@ -5,4 +5,4 @@ Merge Request url: #{project_merge_request_url(@merge_request.target_project, @m
= merge_path_description(@merge_request, 'to')
Author: #{sanitize_name(@merge_request.author_name)}
-Assignee: #{sanitize_name(@merge_request.assignee_name)}
+= assignees_label(@merge_request)
diff --git a/app/views/notify/issue_due_email.html.haml b/app/views/notify/issue_due_email.html.haml
index e81144b8fcb..08bc98ca05c 100644
--- a/app/views/notify/issue_due_email.html.haml
+++ b/app/views/notify/issue_due_email.html.haml
@@ -3,7 +3,7 @@
- if @issue.assignees.any?
%p
- Assignee: #{@issue.assignee_list}
+ = assignees_label(@issue)
%p
This issue is due on: #{@issue.due_date.to_s(:medium)}
diff --git a/app/views/notify/issue_due_email.text.erb b/app/views/notify/issue_due_email.text.erb
index 3c7a57a8a2e..ae50b703fe3 100644
--- a/app/views/notify/issue_due_email.text.erb
+++ b/app/views/notify/issue_due_email.text.erb
@@ -2,6 +2,6 @@ The following issue is due on <%= @issue.due_date %>:
Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %>
Author: <%= @issue.author_name %>
-Assignee: <%= @issue.assignee_list %>
+<%= assignees_label(@issue) %>
<%= @issue.description %>
diff --git a/app/views/notify/issue_moved_email.html.haml b/app/views/notify/issue_moved_email.html.haml
index 472c31e9a5e..b766cb1a523 100644
--- a/app/views/notify/issue_moved_email.html.haml
+++ b/app/views/notify/issue_moved_email.html.haml
@@ -1,6 +1,9 @@
%p
Issue was moved to another project.
-%p
- New issue:
- = link_to project_issue_url(@new_project, @new_issue) do
- = @new_issue.title
+- if @can_access_project
+ %p
+ New issue:
+ = link_to project_issue_url(@new_project, @new_issue) do
+ = @new_issue.title
+- else
+ You don't have access to the project.
diff --git a/app/views/notify/issue_moved_email.text.erb b/app/views/notify/issue_moved_email.text.erb
index 66ede43635b..985e689aa9d 100644
--- a/app/views/notify/issue_moved_email.text.erb
+++ b/app/views/notify/issue_moved_email.text.erb
@@ -1,4 +1,8 @@
Issue was moved to another project.
+<% if @can_access_project %>
New issue location:
<%= project_issue_url(@new_project, @new_issue) %>
+<% else %>
+You don't have access to the project.
+<% end %>
diff --git a/app/views/notify/links/ci/builds/_build.text.erb b/app/views/notify/links/ci/builds/_build.text.erb
index 773ae8174e9..afb02f97e5a 100644
--- a/app/views/notify/links/ci/builds/_build.text.erb
+++ b/app/views/notify/links/ci/builds/_build.text.erb
@@ -1 +1 @@
-Job #<%= build.id %> ( <%= pipeline_job_url(pipeline, build) %> )
+Job #<%= build.id %> ( <%= raw_project_job_url(pipeline.project, build) %> )
diff --git a/app/views/notify/member_access_granted_email.html.haml b/app/views/notify/member_access_granted_email.html.haml
index 18dec806539..1c50dba9c97 100644
--- a/app/views/notify/member_access_granted_email.html.haml
+++ b/app/views/notify/member_access_granted_email.html.haml
@@ -1,3 +1,10 @@
+- link_end = '</a>'.html_safe
+- source_type = member_source.model_name.singular
+- leave_link = polymorphic_url([member_source], leave: 1)
+- source_link = link_to(member_source.human_name, member_source.web_url, target: '_blank', rel: 'noopener noreferrer')
+
%p
- You have been granted #{member.human_access} access to the
- #{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular}.
+ = _('You have been granted %{access_level} access to the %{source_link} %{source_type}.').html_safe % { access_level: member.human_access, source_link: source_link, source_type: source_type }
+%p
+ - leave_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: leave_link }
+ = _('If this was a mistake you can %{leave_link_start}leave the %{source_type}%{link_end}.').html_safe % { source_type: source_type, leave_link_start: leave_link_start, link_end: link_end }
diff --git a/app/views/notify/member_access_granted_email.text.erb b/app/views/notify/member_access_granted_email.text.erb
index a9fb3a589a5..445009bb413 100644
--- a/app/views/notify/member_access_granted_email.text.erb
+++ b/app/views/notify/member_access_granted_email.text.erb
@@ -1,3 +1,8 @@
-You have been granted <%= member.human_access %> access to the <%= member_source.human_name %> <%= member_source.model_name.singular %>.
+<% source_type = member_source.model_name.singular %>
+<%= _('You have been granted %{access_level} access to the %{source_name} %{source_type}.') % { access_level: member.human_access, source_name: member_source.human_name, source_type: source_type } %>
<%= member_source.web_url %>
+
+<%= _('If this was a mistake you can leave the %{source_type}.') % { source_type: source_type } %>
+
+<%= polymorphic_url([member_source], leave: 1) %>
diff --git a/app/views/notify/merge_request_status_email.text.haml b/app/views/notify/merge_request_status_email.text.haml
index b9b9e0c3ad7..e3b24bbd405 100644
--- a/app/views/notify/merge_request_status_email.text.haml
+++ b/app/views/notify/merge_request_status_email.text.haml
@@ -5,4 +5,4 @@ Merge Request url: #{project_merge_request_url(@merge_request.target_project, @m
= merge_path_description(@merge_request, 'to')
Author: #{sanitize_name(@merge_request.author_name)}
-Assignee: #{sanitize_name(@merge_request.assignee_name)}
+= assignees_label(@merge_request)
diff --git a/app/views/notify/merge_request_unmergeable_email.text.haml b/app/views/notify/merge_request_unmergeable_email.text.haml
index 0c7bf1bb044..e9708a297d7 100644
--- a/app/views/notify/merge_request_unmergeable_email.text.haml
+++ b/app/views/notify/merge_request_unmergeable_email.text.haml
@@ -5,4 +5,4 @@ Merge Request url: #{project_merge_request_url(@merge_request.target_project, @m
= merge_path_description(@merge_request, 'to')
Author: #{sanitize_name(@merge_request.author_name)}
-Assignee: #{sanitize_name(@merge_request.assignee_name)}
+= assignees_label(@merge_request)
diff --git a/app/views/notify/merged_merge_request_email.text.haml b/app/views/notify/merged_merge_request_email.text.haml
index 045a43cbc84..d623e701a30 100644
--- a/app/views/notify/merged_merge_request_email.text.haml
+++ b/app/views/notify/merged_merge_request_email.text.haml
@@ -5,4 +5,4 @@ Merge Request url: #{project_merge_request_url(@merge_request.target_project, @m
= merge_path_description(@merge_request, 'to')
Author: #{sanitize_name(@merge_request.author_name)}
-Assignee: #{sanitize_name(@merge_request.assignee_name)}
+= assignees_label(@merge_request)
diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml
index e6cdaf85c0d..8aa7939dd0b 100644
--- a/app/views/notify/new_issue_email.html.haml
+++ b/app/views/notify/new_issue_email.html.haml
@@ -4,7 +4,7 @@
- if @issue.assignees.any?
%p
- Assignee: #{@issue.assignee_list}
+ = assignees_label(@issue)
- if @issue.description
%div
diff --git a/app/views/notify/new_issue_email.text.erb b/app/views/notify/new_issue_email.text.erb
index 58a2bcbe5eb..ff258711b48 100644
--- a/app/views/notify/new_issue_email.text.erb
+++ b/app/views/notify/new_issue_email.text.erb
@@ -2,6 +2,6 @@ New Issue was created.
Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %>
Author: <%= sanitize_name(@issue.author_name) %>
-Assignee: <%= @issue.assignee_list %>
+<%= assignees_label(@issue) %>
<%= @issue.description %>
diff --git a/app/views/notify/new_mention_in_issue_email.text.erb b/app/views/notify/new_mention_in_issue_email.text.erb
index 173091e4a80..8e95063b40f 100644
--- a/app/views/notify/new_mention_in_issue_email.text.erb
+++ b/app/views/notify/new_mention_in_issue_email.text.erb
@@ -2,6 +2,6 @@ You have been mentioned in an issue.
Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %>
Author: <%= sanitize_name(@issue.author_name) %>
-Assignee: <%= sanitize_name(@issue.assignee_list) %>
+<%= assignees_label(@issue) %>
<%= @issue.description %>
diff --git a/app/views/notify/new_mention_in_merge_request_email.text.erb b/app/views/notify/new_mention_in_merge_request_email.text.erb
index 96a4f3f9eac..3c78e257a88 100644
--- a/app/views/notify/new_mention_in_merge_request_email.text.erb
+++ b/app/views/notify/new_mention_in_merge_request_email.text.erb
@@ -4,6 +4,6 @@ You have been mentioned in Merge Request <%= @merge_request.to_reference %>
<%= merge_path_description(@merge_request, 'to') %>
Author: <%= sanitize_name(@merge_request.author_name) %>
-Assignee: <%= sanitize_name(@merge_request.assignee_name) %>
+= assignees_label(@merge_request)
<%= @merge_request.description %>
diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml
index db23447dd39..9ab648e2a64 100644
--- a/app/views/notify/new_merge_request_email.html.haml
+++ b/app/views/notify/new_merge_request_email.html.haml
@@ -3,11 +3,11 @@
#{link_to @merge_request.author_name, user_url(@merge_request.author)} created a merge request:
%p.details
- != merge_path_description(@merge_request, '&rarr;')
+ = merge_path_description(@merge_request, '→')
-- if @merge_request.assignee_id.present?
+- if @merge_request.assignees.any?
%p
- Assignee: #{sanitize_name(@merge_request.assignee_name)}
+ = assignees_label(@merge_request)
= render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter
diff --git a/app/views/notify/new_merge_request_email.text.erb b/app/views/notify/new_merge_request_email.text.erb
index 754f4bca1cd..e6c42f1cf5f 100644
--- a/app/views/notify/new_merge_request_email.text.erb
+++ b/app/views/notify/new_merge_request_email.text.erb
@@ -4,7 +4,7 @@ New Merge Request <%= @merge_request.to_reference %>
<%= merge_path_description(@merge_request, 'to') %>
Author: <%= @merge_request.author_name %>
-Assignee: <%= @merge_request.assignee_name %>
+<%= assignees_label(@merge_request) %>
<%= render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter %>
<%= @merge_request.description %>
diff --git a/app/views/notify/new_user_email.html.haml b/app/views/notify/new_user_email.html.haml
index dfbb5c75bd3..ec135ae994f 100644
--- a/app/views/notify/new_user_email.html.haml
+++ b/app/views/notify/new_user_email.html.haml
@@ -13,4 +13,5 @@
%p
= link_to "Click here to set your password", edit_password_url(@user, reset_password_token: @token)
%p
- = raw reset_token_expire_message
+ This link is valid for #{password_reset_token_valid_time}.
+ After it expires, you can #{link_to("request a new one", new_user_password_url(user_email: @user.email))}.
diff --git a/app/views/notify/new_user_email.text.erb b/app/views/notify/new_user_email.text.erb
index f3f20f3bfba..7e0db75472d 100644
--- a/app/views/notify/new_user_email.text.erb
+++ b/app/views/notify/new_user_email.text.erb
@@ -1,10 +1,17 @@
Hi <%= sanitize_name(@user.name) %>!
+<% if Gitlab::CurrentSettings.allow_signup? %>
+Your account has been created successfully.
+<% else %>
The Administrator created an account for you. Now you are a member of the company GitLab application.
+<% end %>
login.................. <%= @user.email %>
+
<% if @user.created_by_id %>
- <%= link_to "Click here to set your password", edit_password_url(@user, :reset_password_token => @token) %>
+Click here to set your password:
+<%= edit_password_url(@user, :reset_password_token => @token) %>
- <%= reset_token_expire_message %>
+This link is valid for <%= password_reset_token_valid_time %>. After it expires, you can request a new one here:
+<%= new_user_password_url(user_email: @user.email) %>
<% end %>
diff --git a/app/views/notify/pages_domain_disabled_email.html.haml b/app/views/notify/pages_domain_disabled_email.html.haml
index 34ce4238a12..224b79bfde8 100644
--- a/app/views/notify/pages_domain_disabled_email.html.haml
+++ b/app/views/notify/pages_domain_disabled_email.html.haml
@@ -10,6 +10,4 @@
If this domain has been disabled in error, please follow
= link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
to verify and re-enable your domain.
-%p
- If you no longer wish to use this domain with GitLab Pages, please remove it
- from your GitLab project and delete any related DNS records.
+= render 'removal_notification'
diff --git a/app/views/notify/pages_domain_verification_failed_email.html.haml b/app/views/notify/pages_domain_verification_failed_email.html.haml
index 0bb0eb09fd5..03b298f8e7c 100644
--- a/app/views/notify/pages_domain_verification_failed_email.html.haml
+++ b/app/views/notify/pages_domain_verification_failed_email.html.haml
@@ -12,6 +12,4 @@
Please visit
= link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
for more information about custom domain verification.
-%p
- If you no longer wish to use this domain with GitLab Pages, please remove it
- from your GitLab project and delete any related DNS records.
+= render 'removal_notification'
diff --git a/app/views/notify/reassigned_issue_email.html.haml b/app/views/notify/reassigned_issue_email.html.haml
index 6d25488a7e2..6b088927623 100644
--- a/app/views/notify/reassigned_issue_email.html.haml
+++ b/app/views/notify/reassigned_issue_email.html.haml
@@ -1,10 +1 @@
-%p
- Assignee changed
- - if @previous_assignees.any?
- from
- %strong= sanitize_name(@previous_assignees.map(&:name).to_sentence)
- to
- - if @issue.assignees.any?
- %strong= @issue.assignee_list
- - else
- %strong Unassigned
+= render 'reassigned_issuable_email', issuable: @issue, previous_assignees: @previous_assignees
diff --git a/app/views/notify/reassigned_merge_request_email.html.haml b/app/views/notify/reassigned_merge_request_email.html.haml
index e4f19bc3200..0aefca6b14a 100644
--- a/app/views/notify/reassigned_merge_request_email.html.haml
+++ b/app/views/notify/reassigned_merge_request_email.html.haml
@@ -1,10 +1 @@
-%p
- Assignee changed
- - if @previous_assignee
- from
- %strong= sanitize_name(@previous_assignee.name)
- to
- - if @merge_request.assignee_id
- %strong= sanitize_name(@merge_request.assignee_name)
- - else
- %strong Unassigned
+= render 'reassigned_issuable_email', issuable: @merge_request, previous_assignees: @previous_assignees
diff --git a/app/views/notify/reassigned_merge_request_email.text.erb b/app/views/notify/reassigned_merge_request_email.text.erb
index 96c770b5219..82ec7aa0fa4 100644
--- a/app/views/notify/reassigned_merge_request_email.text.erb
+++ b/app/views/notify/reassigned_merge_request_email.text.erb
@@ -2,5 +2,5 @@ Reassigned Merge Request <%= @merge_request.iid %>
<%= url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }]) %>
-Assignee changed <%= "from #{sanitize_name(@previous_assignee.name)}" if @previous_assignee -%>
- to <%= "#{@merge_request.assignee_id ? sanitize_name(@merge_request.assignee_name) : 'Unassigned'}" %>
+Assignee changed <%= "from #{sanitize_name(@previous_assignees.map(&:name).to_sentence)}" if @previous_assignees.any? -%>
+ to <%= "#{@merge_request.assignees.any? ? @merge_request.assignee_list : 'Unassigned'}" %>
diff --git a/app/views/profiles/_email_settings.html.haml b/app/views/profiles/_email_settings.html.haml
new file mode 100644
index 00000000000..fb4da08e129
--- /dev/null
+++ b/app/views/profiles/_email_settings.html.haml
@@ -0,0 +1,16 @@
+- form = local_assigns.fetch(:form)
+- readonly = @user.read_only_attribute?(:email)
+- email_change_disabled = local_assigns.fetch(:email_change_disabled, nil)
+- read_only_help_text = readonly ? s_("Profiles|Your email address was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:email) } : user_email_help_text(@user)
+- help_text = email_change_disabled ? s_("Your account uses dedicated credentials for the \"%{group_name}\" group and can only be updated through SSO.") % { group_name: @user.managing_group.name } : read_only_help_text
+
+= form.text_field :email, required: true, class: 'input-lg', value: (@user.email unless @user.temp_oauth_email?), help: help_text.html_safe, readonly: readonly || email_change_disabled
+= form.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email),
+ { help: s_("Profiles|This email will be displayed on your public profile"), include_blank: s_("Profiles|Do not show on profile") },
+ control_class: 'select2 input-lg', disabled: email_change_disabled
+- commit_email_link_url = help_page_path('user/profile/index', anchor: 'commit-email', target: '_blank')
+- commit_email_link_start = '<a href="%{url}">'.html_safe % { url: commit_email_link_url }
+- commit_email_docs_link = s_('Profiles|This email will be used for web based operations, such as edits and merges. %{commit_email_link_start}Learn more%{commit_email_link_end}').html_safe % { commit_email_link_start: commit_email_link_start, commit_email_link_end: '</a>'.html_safe }
+= form.select :commit_email, options_for_select(commit_email_select_options(@user), selected: selected_commit_email(@user)),
+ { help: commit_email_docs_link },
+ control_class: 'select2 input-lg', disabled: email_change_disabled
diff --git a/app/views/profiles/_event_table.html.haml b/app/views/profiles/_event_table.html.haml
index 9f525547dd9..977ff30d5a6 100644
--- a/app/views/profiles/_event_table.html.haml
+++ b/app/views/profiles/_event_table.html.haml
@@ -1,14 +1,12 @@
%h5.prepend-top-0
- History of authentications
+ = _('History of authentications')
%ul.content-list
- events.each do |event|
%li
%span.description
= audit_icon(event.details[:with], class: "append-right-5")
- Signed in with
- = event.details[:with]
- authentication
+ = _('Signed in with %{authentication} authentication') % { authentication: event.details[:with]}
%span.float-right= time_ago_with_tooltip(event.created_at)
= paginate events, theme: "gitlab"
diff --git a/app/views/profiles/accounts/_providers.html.haml b/app/views/profiles/accounts/_providers.html.haml
new file mode 100644
index 00000000000..068f9cc70f7
--- /dev/null
+++ b/app/views/profiles/accounts/_providers.html.haml
@@ -0,0 +1,21 @@
+%label.label-bold
+ = s_('Profiles|Connected Accounts')
+ %p= s_('Profiles|Click on icon to activate signin with one of the following services')
+ - providers.each do |provider|
+ - unlink_allowed = unlink_provider_allowed?(provider)
+ - link_allowed = link_provider_allowed?(provider)
+ - if unlink_allowed || link_allowed
+ .provider-btn-group
+ .provider-btn-image
+ = provider_image_tag(provider)
+ - if auth_active?(provider)
+ - if unlink_allowed
+ = link_to unlink_profile_account_path(provider: provider), method: :delete, class: 'provider-btn' do
+ = s_('Profiles|Disconnect')
+ - else
+ %a.provider-btn
+ = s_('Profiles|Active')
+ - elsif link_allowed
+ = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn not-active' 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/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index ee2c5a13b8a..e6380817c8f 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -29,24 +29,7 @@
%p
= s_('Profiles|Activate signin with one of the following services')
.col-lg-8
- %label.label-bold
- = s_('Profiles|Connected Accounts')
- %p= s_('Profiles|Click on icon to activate signin with one of the following services')
- - button_based_providers.each do |provider|
- .provider-btn-group
- .provider-btn-image
- = provider_image_tag(provider)
- - if auth_active?(provider)
- - if unlink_allowed?(provider)
- = link_to unlink_profile_account_path(provider: provider), method: :delete, class: 'provider-btn' do
- = s_('Profiles|Disconnect')
- - else
- %a.provider-btn
- = s_('Profiles|Active')
- - else
- = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn not-active' do
- = s_('Profiles|Connect')
- = render_if_exists 'profiles/accounts/group_saml_unlink_buttons', group_saml_identities: local_assigns[:group_saml_identities]
+ = render 'providers', providers: button_based_providers, group_saml_identities: local_assigns[:group_saml_identities]
%hr
- if current_user.can_change_username?
.row.prepend-top-default
diff --git a/app/views/profiles/active_sessions/_active_session.html.haml b/app/views/profiles/active_sessions/_active_session.html.haml
index 23ef31a0c85..bb31049111c 100644
--- a/app/views/profiles/active_sessions/_active_session.html.haml
+++ b/app/views/profiles/active_sessions/_active_session.html.haml
@@ -8,24 +8,19 @@
%div
%strong= active_session.ip_address
- if is_current_session
- %div This is your current session
+ %div
+ = _('This is your current session')
- else
%div
- Last accessed on
+ = _('Last accessed on')
= l(active_session.updated_at, format: :short)
%div
%strong= active_session.browser
- on
+ = s_('ProfileSession|on')
%strong= active_session.os
%div
- %strong Signed in
- on
+ %strong= _('Signed in')
+ = s_('ProfileSession|on')
= l(active_session.created_at, format: :short)
-
- - unless is_current_session
- .float-right
- = link_to profile_active_session_path(active_session.session_id), data: { confirm: 'Are you sure? The device will be signed out of GitLab.' }, method: :delete, class: "btn btn-danger prepend-left-10" do
- %span.sr-only Revoke
- Revoke
diff --git a/app/views/profiles/active_sessions/index.html.haml b/app/views/profiles/active_sessions/index.html.haml
index 8688a52843d..d651319fc3f 100644
--- a/app/views/profiles/active_sessions/index.html.haml
+++ b/app/views/profiles/active_sessions/index.html.haml
@@ -1,4 +1,4 @@
-- page_title 'Active Sessions'
+- page_title _('Active Sessions')
- @content_class = "limit-container-width" unless fluid_layout
.row.prepend-top-default
@@ -6,7 +6,7 @@
%h4.prepend-top-0
= page_title
%p
- This is a list of devices that have logged into your account. Revoke any sessions that you do not recognize.
+ = _('This is a list of devices that have logged into your account. Revoke any sessions that you do not recognize.')
.col-lg-8
.append-bottom-default
diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml
index a924369050b..275c0428d34 100644
--- a/app/views/profiles/audit_log.html.haml
+++ b/app/views/profiles/audit_log.html.haml
@@ -1,4 +1,4 @@
-- page_title "Authentication log"
+- page_title _('Authentication log')
- @content_class = "limit-container-width" unless fluid_layout
.row.prepend-top-default
@@ -6,6 +6,6 @@
%h4.prepend-top-0
= page_title
%p
- This is a security log of important events involving your account.
+ = _('This is a security log of important events involving your account.')
.col-lg-8
= render 'event_table', events: @events
diff --git a/app/views/profiles/chat_names/_chat_name.html.haml b/app/views/profiles/chat_names/_chat_name.html.haml
index 9e82e47c1e1..ff67f92ad07 100644
--- a/app/views/profiles/chat_names/_chat_name.html.haml
+++ b/app/views/profiles/chat_names/_chat_name.html.haml
@@ -21,7 +21,7 @@
- if chat_name.last_used_at
= time_ago_with_tooltip(chat_name.last_used_at)
- else
- Never
+ = _('Never')
%td
- = link_to 'Remove', profile_chat_name_path(chat_name), method: :delete, class: 'btn btn-danger float-right', data: { confirm: 'Are you sure you want to revoke this nickname?' }
+ = link_to _('Remove'), profile_chat_name_path(chat_name), method: :delete, class: 'btn btn-danger float-right', data: { confirm: _('Are you sure you want to revoke this nickname?') }
diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml
index 4b6e419af50..0c8098a97d5 100644
--- a/app/views/profiles/chat_names/index.html.haml
+++ b/app/views/profiles/chat_names/index.html.haml
@@ -1,4 +1,4 @@
-- page_title 'Chat'
+- page_title _('Chat')
- @content_class = "limit-container-width" unless fluid_layout
.row.prepend-top-default
@@ -6,7 +6,7 @@
%h4.prepend-top-0
= page_title
%p
- You can see your Chat accounts.
+ = _('You can see your chat accounts.')
.col-lg-8
%h5 Active chat names (#{@chat_names.size})
@@ -16,15 +16,15 @@
%table.table.chat-names
%thead
%tr
- %th Project
- %th Service
- %th Team domain
- %th Nickname
- %th Last used
+ %th= _('Project')
+ %th= _('Service')
+ %th= _('Team domain')
+ %th= _('Nickname')
+ %th= _('Last used')
%th
%tbody
= render @chat_names
- else
.settings-message.text-center
- You don't have any active chat names.
+ = _("You don't have any active chat names.")
diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml
index 1823f191fb3..c90a0b3e329 100644
--- a/app/views/profiles/emails/index.html.haml
+++ b/app/views/profiles/emails/index.html.haml
@@ -26,7 +26,9 @@
%li
Your Commit Email will be used for web based operations, such as edits and merges.
%li
- Your Notification Email will be used for account notifications.
+ Your Default Notification Email will be used for account notifications if a
+ = link_to 'group-specific email address', profile_notifications_path
+ is not set.
%li
Your Public Email will be displayed on your public profile.
%li
@@ -41,7 +43,7 @@
- if @primary_email === current_user.public_email
%span.badge.badge-info Public email
- if @primary_email === current_user.notification_email
- %span.badge.badge-info Notification email
+ %span.badge.badge-info Default notification email
- @emails.each do |email|
%li
= render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? }
diff --git a/app/views/profiles/gpg_keys/_form.html.haml b/app/views/profiles/gpg_keys/_form.html.haml
index 6c4cb614a2b..225487b2638 100644
--- a/app/views/profiles/gpg_keys/_form.html.haml
+++ b/app/views/profiles/gpg_keys/_form.html.haml
@@ -3,8 +3,8 @@
= form_errors(@gpg_key)
.form-group
- = f.label :key, class: 'label-bold'
- = f.text_area :key, class: "form-control", rows: 8, required: true, placeholder: "Don't paste the private part of the GPG key. Paste the public part which begins with '-----BEGIN PGP PUBLIC KEY BLOCK-----'."
+ = f.label :key, s_('Profiles|Key'), class: 'label-bold'
+ = f.text_area :key, class: "form-control", rows: 8, required: true, placeholder: _("Don't paste the private part of the GPG key. Paste the public part which begins with '-----BEGIN PGP PUBLIC KEY BLOCK-----'.")
.prepend-top-default
- = f.submit 'Add key', class: "btn btn-success"
+ = f.submit s_('Profiles|Add key'), class: "btn btn-success"
diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml
index d1fd7bc8e71..f8351644df5 100644
--- a/app/views/profiles/gpg_keys/_key.html.haml
+++ b/app/views/profiles/gpg_keys/_key.html.haml
@@ -9,17 +9,19 @@
%code= key.fingerprint
- if key.subkeys.present?
.subkeys
- %span.bold Subkeys:
+ %span.bold
+ = _('Subkeys')
+ = ':'
%ul.subkeys-list
- key.subkeys.each do |subkey|
%li
%code= subkey.fingerprint
.float-right
%span.key-created-at
- created #{time_ago_with_tooltip(key.created_at)}
- = link_to profile_gpg_key_path(key), data: { confirm: 'Are you sure? Removing this GPG key does not affect already signed commits.' }, method: :delete, class: "btn btn-danger prepend-left-10" do
- %span.sr-only Remove
+ = s_('Profiles|Created %{time_ago}'.html_safe) % { time_ago:time_ago_with_tooltip(key.created_at)}
+ = link_to profile_gpg_key_path(key), data: { confirm: _('Are you sure? Removing this GPG key does not affect already signed commits.') }, method: :delete, class: "btn btn-danger prepend-left-10" do
+ %span.sr-only= _('Remove')
= icon('trash')
- = link_to revoke_profile_gpg_key_path(key), data: { confirm: 'Are you sure? All commits that were signed with this GPG key will be unverified.' }, method: :put, class: "btn btn-danger prepend-left-10" do
- %span.sr-only Revoke
- Revoke
+ = link_to revoke_profile_gpg_key_path(key), data: { confirm: _('Are you sure? All commits that were signed with this GPG key will be unverified.') }, method: :put, class: "btn btn-danger prepend-left-10" do
+ %span.sr-only= _('Revoke')
+ = _('Revoke')
diff --git a/app/views/profiles/gpg_keys/_key_table.html.haml b/app/views/profiles/gpg_keys/_key_table.html.haml
index b9b60c218fd..ebbd1c8f672 100644
--- a/app/views/profiles/gpg_keys/_key_table.html.haml
+++ b/app/views/profiles/gpg_keys/_key_table.html.haml
@@ -6,6 +6,6 @@
- else
%p.settings-message.text-center
- if is_admin
- There are no GPG keys associated with this account.
+ = _('There are no GPG keys associated with this account.')
- else
- There are no GPG keys with access to your account.
+ = _('There are no GPG keys with access to your account.')
diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml
index 1d2e41cb437..f9f898a9225 100644
--- a/app/views/profiles/gpg_keys/index.html.haml
+++ b/app/views/profiles/gpg_keys/index.html.haml
@@ -1,4 +1,4 @@
-- page_title "GPG Keys"
+- page_title _('GPG Keys')
- @content_class = "limit-container-width" unless fluid_layout
.row.prepend-top-default
@@ -6,16 +6,16 @@
%h4.prepend-top-0
= page_title
%p
- GPG keys allow you to verify signed commits.
+ = _('GPG keys allow you to verify signed commits.')
.col-lg-8
%h5.prepend-top-0
- Add a GPG key
+ = _('Add a GPG key')
%p.profile-settings-content
- Before you can add a GPG key you need to
- = link_to 'generate it.', help_page_path('user/project/repository/gpg_signed_commits/index.md')
+ - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/repository/gpg_signed_commits/index.md') }
+ = _('Before you can add a GPG key you need to %{help_link_start}Generate it.%{help_link_end}'.html_safe) % {help_link_start: help_link_start, help_link_end:'</a>'.html_safe }
= render 'form'
%hr
%h5
- Your GPG keys (#{@gpg_keys.count})
+ = _('Your GPG keys (%{count})') % { count:@gpg_keys.count}
.append-bottom-default
= render 'key_table'
diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml
index 21eef08983c..7846cdbcd52 100644
--- a/app/views/profiles/keys/_form.html.haml
+++ b/app/views/profiles/keys/_form.html.haml
@@ -3,11 +3,11 @@
= form_errors(@key)
.form-group
- = f.label :key, class: 'label-bold'
+ = f.label :key, s_('Profiles|Key'), class: 'label-bold'
%p= _("Paste your public SSH key, which is usually contained in the file '~/.ssh/id_rsa.pub' and begins with 'ssh-rsa'. Don't use your private SSH key.")
= f.text_area :key, class: "form-control js-add-ssh-key-validation-input qa-key-public-key-field", rows: 8, required: true, placeholder: s_('Profiles|Typically starts with "ssh-rsa …"')
.form-group
- = f.label :title, class: 'label-bold'
+ = f.label :title, _('Title'), class: 'label-bold'
= f.text_field :title, class: "form-control input-lg qa-key-title-field", required: true, placeholder: s_('Profiles|e.g. My MacBook key')
%p.form-text.text-muted= _('Name your individual key via a title')
diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml
index ce20994b0f4..b9d73d89334 100644
--- a/app/views/profiles/keys/_key.html.haml
+++ b/app/views/profiles/keys/_key.html.haml
@@ -17,7 +17,8 @@
= key.last_used_at ? time_ago_with_tooltip(key.last_used_at) : 'n/a'
.float-right
%span.key-created-at
- created #{time_ago_with_tooltip(key.created_at)}
- = link_to path_to_key(key, is_admin), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-transparent prepend-left-10" do
- %span.sr-only Remove
- = icon('trash')
+ = s_('Profiles|Created %{time_ago}'.html_safe) % { time_ago:time_ago_with_tooltip(key.created_at)}
+ - if key.can_delete?
+ = link_to path_to_key(key, is_admin), data: { confirm: _('Are you sure?')}, method: :delete, class: "btn btn-transparent prepend-left-10" do
+ %span.sr-only= _('Remove')
+ = icon('trash')
diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml
index 88473c7f72d..0ef01dec493 100644
--- a/app/views/profiles/keys/_key_details.html.haml
+++ b/app/views/profiles/keys/_key_details.html.haml
@@ -3,25 +3,26 @@
.col-md-4
.card
.card-header
- SSH Key
+ = _('SSH Key')
%ul.content-list
%li
- %span.light Title:
+ %span.light= _('Title:')
%strong= @key.title
%li
- %span.light Created on:
+ %span.light= _('Created on:')
%strong= @key.created_at.to_s(:medium)
%li
- %span.light Last used on:
+ %span.light= _('Last used on:')
%strong= @key.last_used_at.try(:to_s, :medium) || 'N/A'
.col-md-8
= form_errors(@key, type: 'key') unless @key.valid?
%p
- %span.light Fingerprint:
+ %span.light= _('Fingerprint:')
%code.key-fingerprint= @key.fingerprint
%pre.well-pre
= @key.key
.col-md-12
.float-right
- = link_to 'Remove', path_to_key(@key, is_admin), data: {confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove delete-key qa-delete-key-button"
+ - if @key.can_delete?
+ = link_to _('Remove'), path_to_key(@key, is_admin), data: {confirm: _('Are you sure?')}, method: :delete, class: "btn btn-remove delete-key qa-delete-key-button"
diff --git a/app/views/profiles/keys/_key_table.html.haml b/app/views/profiles/keys/_key_table.html.haml
index e088140fdd2..4a6d8a1870d 100644
--- a/app/views/profiles/keys/_key_table.html.haml
+++ b/app/views/profiles/keys/_key_table.html.haml
@@ -6,6 +6,6 @@
- else
%p.settings-message.text-center
- if is_admin
- There are no SSH keys associated with this account.
+ = _('There are no SSH keys associated with this account.')
- else
- There are no SSH keys with access to your account.
+ = _('There are no SSH keys with access to your account.')
diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml
index 55ca8d0ebd4..da6aa0fce3a 100644
--- a/app/views/profiles/keys/index.html.haml
+++ b/app/views/profiles/keys/index.html.haml
@@ -1,4 +1,4 @@
-- page_title "SSH Keys"
+- page_title _('SSH Keys')
- @content_class = "limit-container-width" unless fluid_layout
.row.prepend-top-default
@@ -6,10 +6,10 @@
%h4.prepend-top-0
= page_title
%p
- SSH keys allow you to establish a secure connection between your computer and GitLab.
+ = _('SSH keys allow you to establish a secure connection between your computer and GitLab.')
.col-lg-8
%h5.prepend-top-0
- Add an SSH key
+ = _('Add an SSH key')
%p.profile-settings-content
- generate_link_url = help_page_path("ssh/README", anchor: 'generating-a-new-ssh-key-pair')
- existing_link_url = help_page_path("ssh/README", anchor: 'locating-an-existing-ssh-key-pair')
@@ -19,6 +19,6 @@
= render 'form'
%hr
%h5
- Your SSH keys (#{@keys.count})
+ = _('Your SSH keys (%{count})') % { count:@keys.count }
.append-bottom-default
= render 'key_table'
diff --git a/app/views/profiles/keys/show.html.haml b/app/views/profiles/keys/show.html.haml
index 28be6172219..360de7a0c11 100644
--- a/app/views/profiles/keys/show.html.haml
+++ b/app/views/profiles/keys/show.html.haml
@@ -1,5 +1,5 @@
- add_to_breadcrumbs "SSH Keys", profile_keys_path
- breadcrumb_title @key.title
-- page_title @key.title, "SSH Keys"
+- page_title @key.title, _('SSH Keys')
- @content_class = "limit-container-width" unless fluid_layout
= render "key_details"
diff --git a/app/views/profiles/notifications/_email_settings.html.haml b/app/views/profiles/notifications/_email_settings.html.haml
new file mode 100644
index 00000000000..34dcf8f5402
--- /dev/null
+++ b/app/views/profiles/notifications/_email_settings.html.haml
@@ -0,0 +1,6 @@
+- form = local_assigns.fetch(:form)
+.form-group
+ = form.label :notification_email, class: "label-bold"
+ = form.select :notification_email, @user.all_emails, { include_blank: false }, class: "select2", disabled: local_assigns.fetch(:email_change_disabled, nil)
+ .help-block
+ = local_assigns.fetch(:help_text, nil)
diff --git a/app/views/profiles/notifications/_group_settings.html.haml b/app/views/profiles/notifications/_group_settings.html.haml
index a12246bcdcc..cf17ee44145 100644
--- a/app/views/profiles/notifications/_group_settings.html.haml
+++ b/app/views/profiles/notifications/_group_settings.html.haml
@@ -1,12 +1,17 @@
-%li.notification-list-item
- %span.notification.fa.fa-holder.append-right-5
- - if setting.global?
- = notification_icon(current_user.global_notification_setting.level)
- - else
- = notification_icon(setting.level)
+.gl-responsive-table-row.notification-list-item
+ .table-section.section-40
+ %span.notification.fa.fa-holder.append-right-5
+ - if setting.global?
+ = notification_icon(current_user.global_notification_setting.level)
+ - else
+ = notification_icon(setting.level)
- %span.str-truncated
- = link_to group.name, group_path(group)
+ %span.str-truncated
+ = link_to group.name, group_path(group)
- .float-right
+ .table-section.section-30.text-right
= render 'shared/notifications/button', notification_setting: setting
+
+ .table-section.section-30
+ = form_for @user.notification_settings.find { |ns| ns.source == group }, url: profile_notifications_group_path(group), method: :put, html: { class: 'update-notifications' } do |f|
+ = f.select :notification_email, @user.all_emails, { include_blank: 'Global notification email' }, class: 'select2 js-group-notification-email'
diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml
index 712eb2a4573..1f311e9a4a4 100644
--- a/app/views/profiles/notifications/show.html.haml
+++ b/app/views/profiles/notifications/show.html.haml
@@ -1,4 +1,4 @@
-- page_title "Notifications"
+- page_title _('Notifications')
- @content_class = "limit-container-width" unless fluid_layout
%div
@@ -14,17 +14,15 @@
%h4.prepend-top-0
= page_title
%p
- You can specify notification level per group or per project.
+ = _('You can specify notification level per group or per project.')
%p
- By default, all projects and groups will use the global notifications setting.
+ = _('By default, all projects and groups will use the global notifications setting.')
.col-lg-8
%h5.prepend-top-0
- Global notification settings
+ = _('Global notification settings')
= form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications prepend-top-default' } do |f|
- .form-group
- = f.label :notification_email, class: "label-bold"
- = f.select :notification_email, @user.all_emails, { include_blank: false }, class: "select2"
+ = render_if_exists 'profiles/notifications/email_settings', form: f
= label_tag :global_notification_level, "Global notification level", class: "label-bold"
%br
@@ -37,19 +35,18 @@
= form_for @user, url: profile_notifications_path, method: :put do |f|
%label{ for: 'user_notified_of_own_activity' }
= f.check_box :notified_of_own_activity
- %span Receive notifications about your own activity
+ %span= _('Receive notifications about your own activity')
%hr
%h5
- Groups (#{@group_notifications.count})
+ = _('Groups (%{count})') % { count: @group_notifications.count }
%div
- %ul.bordered-list
- - @group_notifications.each do |setting|
- = render 'group_settings', setting: setting, group: setting.source
+ - @group_notifications.each do |setting|
+ = render 'group_settings', setting: setting, group: setting.source
%h5
- Projects (#{@project_notifications.count})
+ = _('Projects (%{count})') % { count: @project_notifications.count }
%p.account-well
- To specify the notification level per project of a group you belong to, you need to visit project page and change notification level there.
+ = _('To specify the notification level per project of a group you belong to, you need to visit project page and change notification level there.')
.append-bottom-default
%ul.bordered-list
- @project_notifications.each do |setting|
diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml
index 0b4b9841ea1..ac8c31189d0 100644
--- a/app/views/profiles/passwords/edit.html.haml
+++ b/app/views/profiles/passwords/edit.html.haml
@@ -1,5 +1,5 @@
-- breadcrumb_title "Edit Password"
-- page_title "Password"
+- breadcrumb_title _('Edit Password')
+- page_title _('Password')
- @content_class = "limit-container-width" unless fluid_layout
.row.prepend-top-default
@@ -7,28 +7,29 @@
%h4.prepend-top-0
= page_title
%p
- After a successful password update, you will be redirected to the login page where you can log in with your new password.
+ = _('After a successful password update, you will be redirected to the login page where you can log in with your new password.')
.col-lg-8
%h5.prepend-top-0
- Change your password
- - unless @user.password_automatically_set?
- or recover your current one
+ - if @user.password_automatically_set
+ = _('Change your password')
+ - else
+ = _('Change your password or recover your current one')
= form_for @user, url: profile_password_path, method: :put, html: {class: "update-password"} do |f|
= form_errors(@user)
- unless @user.password_automatically_set?
.form-group
- = f.label :current_password, class: 'label-bold'
+ = f.label :current_password, _('Current password'), class: 'label-bold'
= f.password_field :current_password, required: true, class: 'form-control'
%p.form-text.text-muted
- You must provide your current password in order to change it.
+ = _('You must provide your current password in order to change it.')
.form-group
- = f.label :password, 'New password', class: 'label-bold'
+ = f.label :password, _('New password'), class: 'label-bold'
= f.password_field :password, required: true, class: 'form-control'
.form-group
- = f.label :password_confirmation, class: 'label-bold'
+ = f.label :password_confirmation, _('Password confirmation'), class: 'label-bold'
= f.password_field :password_confirmation, required: true, class: 'form-control'
.prepend-top-default.append-bottom-default
- = f.submit 'Save password', class: "btn btn-success append-right-10"
+ = f.submit _('Save password'), class: "btn btn-success append-right-10"
- 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, class: "account-btn-link"
diff --git a/app/views/profiles/passwords/new.html.haml b/app/views/profiles/passwords/new.html.haml
index 4b84835429c..ce60455ab89 100644
--- a/app/views/profiles/passwords/new.html.haml
+++ b/app/views/profiles/passwords/new.html.haml
@@ -13,13 +13,18 @@
- unless @user.password_automatically_set?
.form-group.row
- = f.label :current_password, class: 'col-form-label col-sm-2'
- .col-sm-10= f.password_field :current_password, required: true, class: 'form-control'
+ .col-sm-2.col-form-label
+ = f.label :current_password, _('Current password')
+ .col-sm-10
+ = f.password_field :current_password, required: true, class: 'form-control'
.form-group.row
- = f.label :password, class: 'col-form-label col-sm-2'
- .col-sm-10= f.password_field :password, required: true, class: 'form-control'
+ .col-sm-2.col-form-label
+ = f.label :password, _('New password')
+ .col-sm-10
+ = f.password_field :password, required: true, class: 'form-control'
.form-group.row
- = f.label :password_confirmation, class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :password_confirmation, _('Password confirmation')
.col-sm-10
= f.password_field :password_confirmation, required: true, class: 'form-control'
.form-actions
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index bfe1c3ddf33..4ebfaff0860 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -1,11 +1,12 @@
-- page_title 'Preferences'
+- page_title _('Preferences')
- @content_class = "limit-container-width" unless fluid_layout
= form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row prepend-top-default js-preferences-form' } do |f|
.col-lg-4.application-theme
%h4.prepend-top-0
= s_('Preferences|Navigation theme')
- %p Customize the appearance of the application header and navigation sidebar.
+ %p
+ = s_('Preferences|Customize the appearance of the application header and navigation sidebar.')
.col-lg-8.application-theme
- Gitlab::Themes.each do |theme|
= label_tag do
@@ -18,11 +19,11 @@
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
- Syntax highlighting theme
+ = s_('Preferences|Syntax highlighting theme')
%p
- This setting allows you to customize the appearance of the syntax.
+ = s_('Preferences|This setting allows you to customize the appearance of the syntax.')
= succeed '.' do
- = link_to 'Learn more', help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank'
+ = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank'
.col-lg-8.syntax-theme
- Gitlab::ColorSchemes.each do |scheme|
= label_tag do
@@ -35,31 +36,31 @@
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
- Behavior
+ = s_('Preferences|Behavior')
%p
- This setting allows you to customize the behavior of the system layout and default views.
+ = s_('Preferences|This setting allows you to customize the behavior of the system layout and default views.')
= succeed '.' do
- = link_to 'Learn more', help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank'
+ = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank'
.col-lg-8
.form-group
= f.label :layout, class: 'label-bold' do
- Layout width
+ = s_('Preferences|Layout width')
= f.select :layout, layout_choices, {}, class: 'form-control'
.form-text.text-muted
- Choose between fixed (max. 1280px) and fluid (100%) application layout.
+ = s_('Preferences|Choose between fixed (max. 1280px) and fluid (100%%) application layout.')
.form-group
= f.label :dashboard, class: 'label-bold' do
- Default dashboard
+ = s_('Preferences|Default dashboard')
= f.select :dashboard, dashboard_choices, {}, class: 'form-control'
= render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific
.form-group
= f.label :project_view, class: 'label-bold' do
- Project overview content
+ = s_('Preferences|Project overview content')
= f.select :project_view, project_view_choices, {}, class: 'form-control'
.form-text.text-muted
- Choose what content you want to see on a project’s overview page.
+ = s_('Preferences|Choose what content you want to see on a project’s overview page.')
.col-sm-12
%hr
@@ -82,5 +83,31 @@
= f.label :first_day_of_week, class: 'label-bold' do
= _('First day of the week')
= f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'form-control'
+ - if Feature.enabled?(:user_time_settings)
+ .col-sm-12
+ %hr
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0= s_('Preferences|Time preferences')
+ %p= s_('Preferences|These settings will update how dates and times are displayed for you.')
+ .col-lg-8
+ .form-group
+ %h5= s_('Preferences|Time format')
+ .checkbox-icon-inline-wrapper.form-check
+ - time_format_label = capture do
+ = s_('Preferences|Display time in 24-hour format')
+ = f.check_box :time_format_in_24h, class: 'form-check-input'
+ = f.label :time_format_in_24h do
+ = time_format_label
+ %h5= s_('Preferences|Time display')
+ .checkbox-icon-inline-wrapper.form-check
+ - time_display_label = capture do
+ = s_('Preferences|Use relative times')
+ = f.check_box :time_display_relative, class: 'form-check-input'
+ = f.label :time_display_relative do
+ = time_display_label
+ .text-muted
+ = s_('Preferences|For example: 30 mins ago.')
+ .col-lg-4.profile-settings-sidebar
+ .col-lg-8
.form-group
= f.submit _('Save changes'), class: 'btn btn-success'
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 4d3d92d09c0..e36d5192a29 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -47,9 +47,9 @@
- if @user.status
= emoji_icon @user.status.emoji
%span#js-no-emoji-placeholder.no-emoji-placeholder{ class: ('hidden' if @user.status) }
- = sprite_icon('emoji_slightly_smiling_face', css_class: 'award-control-icon-neutral')
- = sprite_icon('emoji_smiley', css_class: 'award-control-icon-positive')
- = sprite_icon('emoji_smile', css_class: 'award-control-icon-super-positive')
+ = sprite_icon('slight-smile', css_class: 'award-control-icon-neutral')
+ = sprite_icon('smiley', css_class: 'award-control-icon-positive')
+ = sprite_icon('smile', css_class: 'award-control-icon-super-positive')
- reset_message_button = button_tag type: :button,
id: 'js-clear-user-status-button',
class: 'clear-user-status btn has-tooltip',
@@ -64,6 +64,18 @@
prepend: emoji_button,
append: reset_message_button,
placeholder: s_("Profiles|What's your status?")
+ - if Feature.enabled?(:user_time_settings)
+ %hr
+ .row.user-time-preferences
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0= s_("Profiles|Time settings")
+ %p= s_("Profiles|You can set your current timezone here")
+ .col-lg-8
+ -# TODO: might need an entry in user/profile.md to describe some of these settings
+ -# https://gitlab.com/gitlab-org/gitlab-ce/issues/60070
+ %h5= ("Time zone")
+ = dropdown_tag(_("Select a timezone"), options: { toggle_class: 'btn js-timezone-dropdown input-lg', title: _("Select a timezone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } )
+ %input.hidden{ :type => 'hidden', :id => 'user_timezone', :name => 'user[timezone]', value: @user.timezone }
%hr
.row
@@ -80,21 +92,10 @@
= f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9 qa-full-name' },
help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you") % { provider_label: attribute_provider_label(:name) }
- else
- = f.text_field :name, label: 'Full name', required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead"), wrapper: { class: 'col-md-9 qa-full-name' }, help: s_("Profiles|Enter your name, so people you know can recognize you")
- = f.text_field :id, readonly: true, label: 'User ID', wrapper: { class: 'col-md-3' }
+ = f.text_field :name, label: s_('Profiles|Full name'), required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead"), wrapper: { class: 'col-md-9 qa-full-name' }, help: s_("Profiles|Enter your name, so people you know can recognize you")
+ = f.text_field :id, readonly: true, label: s_('Profiles|User ID'), wrapper: { class: 'col-md-3' }
- - if @user.read_only_attribute?(:email)
- = f.text_field :email, required: true, class: 'input-lg', readonly: true, help: s_("Profiles|Your email address was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:email) }
- - else
- = f.text_field :email, required: true, class: 'input-lg', value: (@user.email unless @user.temp_oauth_email?),
- help: user_email_help_text(@user)
- = f.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email),
- { help: s_("Profiles|This email will be displayed on your public profile"), include_blank: s_("Profiles|Do not show on profile") },
- control_class: 'select2 input-lg'
- - commit_email_docs_link = link_to s_('Profiles|Learn more'), help_page_path('user/profile/index', anchor: 'commit-email', target: '_blank')
- = f.select :commit_email, options_for_select(commit_email_select_options(@user), selected: selected_commit_email(@user)),
- { help: s_("Profiles|This email will be used for web based operations, such as edits and merges. %{learn_more}").html_safe % { learn_more: commit_email_docs_link } },
- control_class: 'select2 input-lg'
+ = render_if_exists 'profiles/email_settings', form: f
= f.text_field :skype, class: 'input-md', placeholder: s_("Profiles|username")
= f.text_field :linkedin, class: 'input-md', help: s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename")
= f.text_field :twitter, class: 'input-md', placeholder: s_("Profiles|@username")
@@ -102,18 +103,18 @@
- if @user.read_only_attribute?(:location)
= f.text_field :location, readonly: true, help: s_("Profiles|Your location was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:location) }
- else
- = f.text_field :location, class: 'input-lg', placeholder: s_("Profiles|City, country")
- = f.text_field :organization, class: 'input-md', help: s_("Profiles|Who you represent or work for")
- = f.text_area :bio, rows: 4, maxlength: 250, help: s_("Profiles|Tell us about yourself in fewer than 250 characters")
+ = f.text_field :location, label: s_('Profiles|Location'), class: 'input-lg', placeholder: s_("Profiles|City, country")
+ = f.text_field :organization, label: s_('Profiles|Organization'), class: 'input-md', help: s_("Profiles|Who you represent or work for")
+ = f.text_area :bio, label: s_('Profiles|Bio'), rows: 4, maxlength: 250, help: s_("Profiles|Tell us about yourself in fewer than 250 characters")
%hr
- %h5= ("Private profile")
+ %h5= s_("Private profile")
.checkbox-icon-inline-wrapper
- private_profile_label = capture do
= s_("Profiles|Don't display activity-related personal information on your profiles")
- = f.check_box :private_profile, label: private_profile_label
+ = f.check_box :private_profile, label: private_profile_label, inline: true, wrapper_class: 'mr-0'
= link_to icon('question-circle'), help_page_path('user/profile/index.md', anchor: 'private-profile')
%h5= s_("Profiles|Private contributions")
- = f.check_box :include_private_contributions, label: 'Include private contributions on my profile'
+ = f.check_box :include_private_contributions, label: s_('Profiles|Include private contributions on my profile'), wrapper_class: 'mb-2', inline: true
.help-block
= s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information")
.prepend-top-default.append-bottom-default
diff --git a/app/views/profiles/two_factor_auths/_codes.html.haml b/app/views/profiles/two_factor_auths/_codes.html.haml
index 759d39cf5f5..be0af977011 100644
--- a/app/views/profiles/two_factor_auths/_codes.html.haml
+++ b/app/views/profiles/two_factor_auths/_codes.html.haml
@@ -1,8 +1,6 @@
%p.slead
- Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one
- time each to regain access to your account. Please save them in a safe place, or you
- %b will
- lose access to your account.
+ - lose_2fa_message = _('Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one time each to regain access to your account. Please save them in a safe place, or you %{b_start}will%{b_end} lose access to your account.') % { b_start:'<b>', b_end:'</b>' }
+ = lose_2fa_message.html_safe
.codes.card
%ul
@@ -11,5 +9,5 @@
%span.monospace= code
.d-flex
- = link_to 'Proceed', profile_account_path, class: 'btn btn-success append-right-10'
- = link_to 'Download codes', "data:text/plain;charset=utf-8,#{CGI.escape(@codes.join("\n"))}", download: "gitlab-recovery-codes.txt", class: 'btn btn-default'
+ = link_to _('Proceed'), profile_account_path, class: 'btn btn-success append-right-10'
+ = link_to _('Download codes'), "data:text/plain;charset=utf-8,#{CGI.escape(@codes.join("\n"))}", download: "gitlab-recovery-codes.txt", class: 'btn btn-default'
diff --git a/app/views/profiles/two_factor_auths/codes.html.haml b/app/views/profiles/two_factor_auths/codes.html.haml
index addf356697a..53907ebffab 100644
--- a/app/views/profiles/two_factor_auths/codes.html.haml
+++ b/app/views/profiles/two_factor_auths/codes.html.haml
@@ -1,5 +1,6 @@
-- page_title 'Recovery Codes', 'Two-factor Authentication'
+- page_title _('Recovery Codes'), _('Two-factor Authentication')
-%h3.page-title Two-factor Authentication Recovery codes
+%h3.page-title
+ = _('Two-factor Authentication Recovery codes')
%hr
= render 'codes'
diff --git a/app/views/profiles/two_factor_auths/create.html.haml b/app/views/profiles/two_factor_auths/create.html.haml
index e330aadac13..973eb8136c4 100644
--- a/app/views/profiles/two_factor_auths/create.html.haml
+++ b/app/views/profiles/two_factor_auths/create.html.haml
@@ -1,6 +1,6 @@
-- page_title 'Two-factor Authentication', 'Account'
+- page_title _('Two-factor Authentication'), _('Account')
.alert.alert-success
- Congratulations! You have enabled Two-factor Authentication!
+ = _('Congratulations! You have enabled Two-factor Authentication!')
= render 'codes'
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index d986c566928..5501e63e027 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -1,72 +1,68 @@
-- page_title 'Two-Factor Authentication', 'Account'
-- add_to_breadcrumbs("Two-Factor Authentication", profile_account_path)
+- page_title _('Two-Factor Authentication'), _('Account')
+- add_to_breadcrumbs(_('Two-Factor Authentication'), profile_account_path)
- @content_class = "limit-container-width" unless fluid_layout
.js-two-factor-auth{ 'data-two-factor-skippable' => "#{two_factor_skippable?}", 'data-two_factor_skip_url' => skip_profile_two_factor_auth_path }
.row.prepend-top-default
.col-lg-4
%h4.prepend-top-0
- Register Two-Factor Authenticator
+ = _('Register Two-Factor Authenticator')
%p
- Use an one time password authenticator on your mobile device or computer to enable two-factor authentication (2FA).
+ = _('Use an one time password authenticator on your mobile device or computer to enable two-factor authentication (2FA).')
.col-lg-8
- if current_user.two_factor_otp_enabled?
%p
- You've already enabled two-factor authentication using one time password authenticators. In order to register a different device, you must first disable two-factor authentication.
+ = _("You've already enabled two-factor authentication using one time password authenticators. In order to register a different device, you must first disable two-factor authentication.")
%p
- If you lose your recovery codes you can generate new ones, invalidating all previous codes.
+ = _('If you lose your recovery codes you can generate new ones, invalidating all previous codes.')
%div
- = link_to 'Disable two-factor authentication', profile_two_factor_auth_path,
+ = link_to _('Disable two-factor authentication'), profile_two_factor_auth_path,
method: :delete,
- data: { confirm: "Are you sure? This will invalidate your registered applications and U2F devices." },
+ data: { confirm: _('Are you sure? This will invalidate your registered applications and U2F devices.') },
class: 'btn btn-danger append-right-10'
= form_tag codes_profile_two_factor_auth_path, {style: 'display: inline-block', method: :post} do |f|
- = submit_tag 'Regenerate recovery codes', class: 'btn'
+ = submit_tag _('Regenerate recovery codes'), class: 'btn'
- else
%p
- Install a soft token authenticator like <a href="https://freeotp.github.io/">FreeOTP</a>
- or Google Authenticator from your application repository and scan this QR code.
- More information is available in the #{link_to('documentation', help_page_path('user/profile/account/two_factor_authentication'))}.
+ - 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.html_safe
.row.append-bottom-10
.col-md-4
= raw @qr_code
.col-md-8
.account-well
%p.prepend-top-0.append-bottom-0
- Can't scan the code?
+ = _("Can't scan the code?")
%p.prepend-top-0.append-bottom-0
- To add the entry manually, provide the following details to the application on your phone.
+ = _('To add the entry manually, provide the following details to the application on your phone.')
%p.prepend-top-0.append-bottom-0
- Account:
- = @account_string
+ = _('Account: %{account}') % { account: @account_string }
%p.prepend-top-0.append-bottom-0
- Key:
- = current_user.otp_secret.scan(/.{4}/).join(' ')
+ = _('Key: %{key}') %{ key: current_user.otp_secret.scan(/.{4}/).join(' ') }
%p.two-factor-new-manual-content
- Time based: Yes
+ = _('Time based: Yes')
= form_tag profile_two_factor_auth_path, method: :post do |f|
- if @error
.alert.alert-danger
= @error
.form-group
- = label_tag :pin_code, nil, class: "label-bold"
+ = label_tag :pin_code, _('Pin code'), class: "label-bold"
= text_field_tag :pin_code, nil, class: "form-control", required: true
.prepend-top-default
- = submit_tag 'Register with two-factor app', class: 'btn btn-success'
+ = submit_tag _('Register with two-factor app'), class: 'btn btn-success'
%hr
.row.prepend-top-default
.col-lg-4
%h4.prepend-top-0
- Register Universal Two-Factor (U2F) Device
+ = _('Register Universal Two-Factor (U2F) Device')
%p
- Use a hardware device to add the second factor of authentication.
+ = _('Use a hardware device to add the second factor of authentication.')
%p
- As U2F devices are only supported by a few browsers, we require that you set up a
- two-factor authentication app before a U2F device. That way you'll always be able to
- log in - even when you're using an unsupported browser.
+ = _("As U2F devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a U2F device. That way you'll always be able to log in - even when you're using an unsupported browser.")
.col-lg-8
- if @u2f_registration.errors.present?
= form_errors(@u2f_registration)
@@ -74,7 +70,8 @@
%hr
- %h5 U2F Devices (#{@u2f_registrations.length})
+ %h5
+ = _('U2F Devices (%{length})') % { length: @u2f_registrations.length }
- if @u2f_registrations.present?
.table-responsive
@@ -85,16 +82,16 @@
%col{ width: "20%" }
%thead
%tr
- %th Name
- %th Registered On
+ %th= _('Name')
+ %th= s_('2FADevice|Registered On')
%th
%tbody
- @u2f_registrations.each do |registration|
%tr
- %td= registration.name.presence || "<no name set>"
+ %td= registration.name.presence || _("<no name set>")
%td= registration.created_at.to_date.to_s(:medium)
- %td= link_to "Delete", profile_u2f_registration_path(registration), method: :delete, class: "btn btn-danger float-right", data: { confirm: "Are you sure you want to delete this device? This action cannot be undone." }
+ %td= link_to _('Delete'), profile_u2f_registration_path(registration), method: :delete, class: "btn btn-danger float-right", data: { confirm: _('Are you sure you want to delete this device? This action cannot be undone.') }
- else
.settings-message.text-center
- You don't have any U2F devices registered yet.
+ = _("You don't have any U2F devices registered yet.")
diff --git a/app/views/projects/_classification_policy_settings.html.haml b/app/views/projects/_classification_policy_settings.html.haml
new file mode 100644
index 00000000000..5a766ab024f
--- /dev/null
+++ b/app/views/projects/_classification_policy_settings.html.haml
@@ -0,0 +1,6 @@
+- if ::Gitlab::ExternalAuthorization.enabled?
+ .form-group.col-md-9
+ = f.label :external_authorization_classification_label, _('Classification Label (optional)'), class: 'label-bold'
+ = f.text_field :external_authorization_classification_label, class: "form-control"
+ %span.form-text.text-muted
+ = external_classification_label_help_message
diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml
index 409b6dba9ca..1056977886a 100644
--- a/app/views/projects/_export.html.haml
+++ b/app/views/projects/_export.html.haml
@@ -1,42 +1,33 @@
- return unless Gitlab::CurrentSettings.project_export_enabled?
- project = local_assigns.fetch(:project)
-- expanded = Rails.env.test?
-%section.settings.no-animate#js-export-project{ class: ('expanded' if expanded) }
- .settings-header
- %h4
- Export project
- %button.btn.js-settings-toggle{ type: 'button' }
- = expanded ? 'Collapse' : 'Expand'
- %p
- Export this project with all its related data in order to move your project to a new GitLab instance. Once the export is finished, you can import the file from the "New Project" page.
- .settings-content
- .bs-callout.bs-callout-info
- %p.append-bottom-0
- %p
- The following items will be exported:
- %ul
- %li Project and wiki repositories
- %li Project uploads
- %li Project configuration, including services
- %li Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities
- %li LFS objects
- %p
- The following items will NOT be exported:
- %ul
- %li Job traces and artifacts
- %li Container registry images
- %li CI variables
- %li Webhooks
- %li Any encrypted tokens
- %p
- Once the exported file is ready, you will receive a notification email with a download link, or you can download it from this page.
- - if project.export_status == :finished
- = link_to 'Download export', download_export_project_path(project),
- rel: 'nofollow', download: '', method: :get, class: "btn btn-default"
- = link_to 'Generate new export', generate_new_export_project_path(project),
- method: :post, class: "btn btn-default"
- - else
- = link_to 'Export project', export_project_path(project),
- method: :post, class: "btn btn-default"
+.sub-section
+ %h4= _('Export project')
+ %p= _('Export this project with all its related data in order to move your project to a new GitLab instance. Once the export is finished, you can import the file from the "New Project" page.')
+
+ .bs-callout.bs-callout-info
+ %p.append-bottom-0
+ %p= _('The following items will be exported:')
+ %ul
+ %li= _('Project and wiki repositories')
+ %li= _('Project uploads')
+ %li= _('Project configuration, including services')
+ %li= _('Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities')
+ %li= _('LFS objects')
+ %p= _('The following items will NOT be exported:')
+ %ul
+ %li= _('Job traces and artifacts')
+ %li= _('Container registry images')
+ %li= _('CI variables')
+ %li= _('Webhooks')
+ %li= _('Any encrypted tokens')
+ %p= _('Once the exported file is ready, you will receive a notification email with a download link, or you can download it from this page.')
+ - if project.export_status == :finished
+ = link_to _('Download export'), download_export_project_path(project),
+ rel: 'nofollow', download: '', method: :get, class: "btn btn-default"
+ = link_to _('Generate new export'), generate_new_export_project_path(project),
+ method: :post, class: "btn btn-default"
+ - else
+ = link_to _('Export project'), export_project_path(project),
+ method: :post, class: "btn btn-default"
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 22a721ee9ad..2b0c3985755 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -13,7 +13,12 @@
= render 'shared/commit_well', commit: commit, ref: ref, project: project
- if is_project_overview
- .project-buttons.append-bottom-default
+ .project-buttons.append-bottom-default{ class: ("js-keep-hidden-on-navigation" if vue_file_list_enabled?) }
= render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout)
- = render 'projects/tree/tree_content', tree: @tree, content_url: content_url
+ - if vue_file_list_enabled?
+ #js-tree-list{ data: { project_path: @project.full_path, project_short_path: @project.path, ref: ref, full_name: @project.name_with_namespace } }
+ - if @tree.readme
+ = render "projects/tree/readme", readme: @tree.readme
+ - else
+ = render 'projects/tree/tree_content', tree: @tree, content_url: content_url
diff --git a/app/views/projects/_flash_messages.html.haml b/app/views/projects/_flash_messages.html.haml
index 7a5fff96676..b2dab0b5348 100644
--- a/app/views/projects/_flash_messages.html.haml
+++ b/app/views/projects/_flash_messages.html.haml
@@ -5,4 +5,6 @@
- if current_user && can?(current_user, :download_code, project)
= render 'shared/no_ssh'
= render 'shared/no_password'
- = render 'shared/auto_devops_implicitly_enabled_banner', project: project
+ - unless project.empty_repo?
+ = render 'shared/auto_devops_implicitly_enabled_banner', project: project
+ = render_if_exists 'projects/above_size_limit_warning', project: project
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index bba303c906c..9f5241344a7 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -1,6 +1,7 @@
- empty_repo = @project.empty_repo?
- show_auto_devops_callout = show_auto_devops_callout?(@project)
-.project-home-panel{ class: ("empty-project" if empty_repo) }
+- max_project_topic_length = 15
+.project-home-panel{ class: [("empty-project" if empty_repo), ("js-keep-hidden-on-navigation" if vue_file_list_enabled?)] }
.row.append-bottom-8
.home-panel-title-row.col-md-12.col-lg-6.d-flex
.avatar-container.rect-avatar.s64.home-panel-avatar.append-right-default.float-none
@@ -11,7 +12,7 @@
= @project.name
%span.visibility-icon.text-secondary.prepend-left-4.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
= visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'})
- .home-panel-metadata.d-flex.align-items-center.text-secondary
+ .home-panel-metadata.d-flex.flex-wrap.text-secondary
- if can?(current_user, :read_project, @project)
%span.text-secondary
= s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id }
@@ -19,15 +20,21 @@
%span.access-request-links.prepend-left-8
= render 'shared/members/access_request_links', source: @project
- if @project.tag_list.present?
- %span.home-panel-topic-list.d-inline-flex.prepend-left-8.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_topics? ? @project.tag_list.join(', ') : nil }
+ %span.home-panel-topic-list.mt-2.w-100.d-inline-flex
= sprite_icon('tag', size: 16, css_class: 'icon append-right-4')
- @project.topics_to_show.each do |topic|
- %a{ class: 'badge badge-pill badge-secondary append-right-5 str-truncated-30', href: explore_projects_path(tag: topic) }
- = topic.titleize
+ - project_topics_classes = "badge badge-pill badge-secondary append-right-5"
+ - explore_project_topic_path = explore_projects_path(tag: topic)
+ - if topic.length > max_project_topic_length
+ %a{ class: "#{ project_topics_classes } str-truncated-30 has-tooltip", data: { container: "body" }, title: topic, href: explore_project_topic_path }
+ = topic.titleize
+ - else
+ %a{ class: project_topics_classes, href: explore_project_topic_path }
+ = topic.titleize
- if @project.has_extra_topics?
- .text-nowrap
+ .text-nowrap.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_topics? ? @project.topics_not_shown.join(', ') : nil }
= _("+ %{count} more") % { count: @project.count_of_extra_topics_not_shown }
@@ -50,7 +57,10 @@
- if can?(current_user, :download_code, @project)
%nav.project-stats
.nav-links.quick-links
- = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout)
+ - if @project.empty_repo?
+ = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors
+ - else
+ = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout)
.home-panel-home-desc.mt-1
- if @project.description.present?
@@ -70,6 +80,8 @@
- deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)')
= deleted_message % { project_name: fork_source_name(@project) }
+ = render_if_exists "projects/home_mirror"
+
- if @project.badges.present?
.project-badges.mb-2
- @project.badges.each do |badge|
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index 2b425f18389..28d4f8eb201 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -8,61 +8,67 @@
.import-buttons
- if gitlab_project_import_enabled?
.import_gitlab_project.has-tooltip{ data: { container: 'body' } }
- = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "gitlab_export" } do
+ = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit', **tracking_attrs(track_label, 'click_button', 'gitlab_export') do
= icon('gitlab', text: 'GitLab export')
- if github_import_enabled?
%div
- = link_to new_import_github_path, class: 'btn js-import-github', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "github" } do
+ = link_to new_import_github_path, class: 'btn js-import-github', **tracking_attrs(track_label, 'click_button', 'github') do
= icon('github', text: 'GitHub')
- if bitbucket_import_enabled?
%div
= link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}",
- data: { track_label: "#{track_label}", track_event: "click_button", track_property: "bitbucket_cloud" } do
+ **tracking_attrs(track_label, 'click_button', 'bitbucket_cloud') do
= icon('bitbucket', text: 'Bitbucket Cloud')
- unless bitbucket_import_configured?
= render 'bitbucket_import_modal'
- if bitbucket_server_import_enabled?
%div
- = link_to status_import_bitbucket_server_path, class: "btn import_bitbucket",
- data: { track_label: "#{track_label}", track_event: "click_button", track_property: "bitbucket_server" } do
+ = link_to status_import_bitbucket_server_path, class: "btn import_bitbucket", **tracking_attrs(track_label, 'click_button', 'bitbucket_server') do
= icon('bitbucket-square', text: 'Bitbucket Server')
%div
- if gitlab_import_enabled?
%div
= link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}",
- data: { track_label: "#{track_label}", track_event: "click_button", track_property: "gitlab_com" } do
+ **tracking_attrs(track_label, 'click_button', 'gitlab_com') do
= icon('gitlab', text: 'GitLab.com')
- unless gitlab_import_configured?
= render 'gitlab_import_modal'
- if google_code_import_enabled?
%div
- = link_to new_import_google_code_path, class: 'btn import_google_code', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "google_code" } do
+ = link_to new_import_google_code_path, class: 'btn import_google_code', **tracking_attrs(track_label, 'click_button', 'google_code') do
= icon('google', text: 'Google Code')
- if fogbugz_import_enabled?
%div
- = link_to new_import_fogbugz_path, class: 'btn import_fogbugz', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "fogbugz" } do
+ = link_to new_import_fogbugz_path, class: 'btn import_fogbugz', **tracking_attrs(track_label, 'click_button', 'fogbugz') do
= icon('bug', text: 'Fogbugz')
- if gitea_import_enabled?
%div
- = link_to new_import_gitea_path, class: 'btn import_gitea', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "gitea" } do
- = custom_icon('go_logo')
+ = link_to new_import_gitea_path, class: 'btn import_gitea', **tracking_attrs(track_label, 'click_button', 'gitea') do
+ = custom_icon('gitea_logo')
Gitea
- if git_import_enabled?
%div
- %button.btn.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active', data: { toggle_open_class: 'active', track_label: "#{track_label}" , track_event: "click_button", track_property: "repo_url" } } }
+ %button.btn.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active' }, **tracking_attrs(track_label, 'click_button', 'repo_url') }
= icon('git', text: 'Repo by URL')
- if manifest_import_enabled?
%div
- = link_to new_import_manifest_path, class: 'btn import_manifest', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "manifest_file" } do
+ = link_to new_import_manifest_path, class: 'btn import_manifest', **tracking_attrs(track_label, 'click_button', 'manifest_file') do
= icon('file-text-o', text: 'Manifest file')
+ - if phabricator_import_enabled?
+ %div
+ = link_to new_import_phabricator_path, class: 'btn import_phabricator', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "phabricator" } do
+ = custom_icon('issues')
+ = _("Phabricator Tasks")
+
+
.js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') }
= form_for @project, html: { class: 'new_project' } do |f|
%hr
diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml
index 03ba1104507..10575aa68b1 100644
--- a/app/views/projects/_md_preview.html.haml
+++ b/app/views/projects/_md_preview.html.haml
@@ -7,22 +7,22 @@
= _('This merge request is locked.')
= _('Only project members can comment.')
-.md-area
+.md-area.position-relative
.md-header
%ul.nav.nav-tabs.nav-links.clearfix
%li.md-header-tab.active
%button.js-md-write-button{ tabindex: -1 }
- Write
+ = _("Write")
%li.md-header-tab
%button.js-md-preview-button{ tabindex: -1 }
- Preview
+ = _("Preview")
%li.md-header-toolbar.active
= render 'projects/blob/markdown_buttons', show_fullscreen_button: true
.md-write-holder
= yield
- .md.md-preview-holder.js-md-preview.hide.md-preview{ data: { url: url } }
+ .md.md-preview-holder.js-md-preview.hide{ data: { url: url } }
.referenced-commands.hide
- if referenced_users
diff --git a/app/views/projects/_merge_request_merge_checks_settings.html.haml b/app/views/projects/_merge_request_merge_checks_settings.html.haml
new file mode 100644
index 00000000000..c21d333f21a
--- /dev/null
+++ b/app/views/projects/_merge_request_merge_checks_settings.html.haml
@@ -0,0 +1,19 @@
+- form = local_assigns.fetch(:form)
+
+.form-group
+ %b= s_('ProjectSettings|Merge checks')
+ %p.text-secondary= s_('ProjectSettings|These checks must pass before merge requests can be merged')
+ .form-check.mb-2.builds-feature
+ = form.check_box :only_allow_merge_if_pipeline_succeeds, class: 'form-check-input'
+ = form.label :only_allow_merge_if_pipeline_succeeds, class: 'form-check-label' do
+ = s_('ProjectSettings|Pipelines must succeed')
+ .descr.text-secondary
+ = s_('ProjectSettings|Pipelines need to be configured to enable this feature.')
+ = link_to icon('question-circle'),
+ help_page_path('ci/merge_request_pipelines/index.md',
+ anchor: 'pipelines-for-merge-requests'),
+ target: '_blank'
+ .form-check.mb-2
+ = form.check_box :only_allow_merge_if_all_discussions_are_resolved, class: 'form-check-input'
+ = form.label :only_allow_merge_if_all_discussions_are_resolved, class: 'form-check-label' do
+ = s_('ProjectSettings|All discussions must be resolved')
diff --git a/app/views/projects/_merge_request_merge_method_settings.html.haml b/app/views/projects/_merge_request_merge_method_settings.html.haml
index 935581643cd..47c311f42d0 100644
--- a/app/views/projects/_merge_request_merge_method_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_method_settings.html.haml
@@ -1,35 +1,33 @@
- form = local_assigns.fetch(:form)
.form-group
- = label_tag :merge_method_merge, class: 'label-bold' do
- Merge method
- .form-check
+ %b= s_('ProjectSettings|Merge method')
+ %p.text-secondary= s_('ProjectSettings|This will dictate the commit history when you merge a merge request')
+ .form-check.mb-2
= form.radio_button :merge_method, :merge, class: "js-merge-method-radio form-check-input"
= label_tag :project_merge_method_merge, class: 'form-check-label' do
- %strong Merge commit
- %br
- %span.descr
- A merge commit is created for every merge, and merging is allowed as long as there are no conflicts.
+ = s_('ProjectSettings|Merge commit')
+ .descr.text-secondary
+ = s_('ProjectSettings|Every merge creates a merge commit')
- .form-check
+ .form-check.mb-2
= form.radio_button :merge_method, :rebase_merge, class: "js-merge-method-radio form-check-input"
= label_tag :project_merge_method_rebase_merge, class: 'form-check-label' do
- %strong Merge commit with semi-linear history
- %br
- %span.descr
- A merge commit is created for every merge, but merging is only allowed if fast-forward merge is possible.
- This way you could make sure that if this merge request would build, after merging to target branch it would also build.
- %br
- %span.descr
- When fast-forward merge is not possible, the user is given the option to rebase.
+ = s_('ProjectSettings|Merge commit with semi-linear history')
+ .descr.text-secondary
+ = s_('ProjectSettings|Every merge creates a merge commit')
+ %br
+ = s_('ProjectSettings|Fast-forward merges only')
+ %br
+ = s_('ProjectSettings|When conflicts arise the user is given the option to rebase')
- .form-check
+ .form-check.mb-2
= form.radio_button :merge_method, :ff, class: "js-merge-method-radio qa-radio-button-merge-ff form-check-input"
= label_tag :project_merge_method_ff, class: 'form-check-label' do
- %strong Fast-forward merge
- %br
- %span.descr
- No merge commits are created and all merges are fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.
- %br
- %span.descr
- When fast-forward merge is not possible, the user is given the option to rebase.
+ = s_('ProjectSettings|Fast-forward merge')
+ .descr.text-secondary
+ = s_('ProjectSettings|No merge commits are created')
+ %br
+ = s_('ProjectSettings|Fast-forward merges only')
+ %br
+ = s_('ProjectSettings|When conflicts arise the user is given the option to rebase')
diff --git a/app/views/projects/_merge_request_merge_options_settings.html.haml b/app/views/projects/_merge_request_merge_options_settings.html.haml
new file mode 100644
index 00000000000..5ab475822de
--- /dev/null
+++ b/app/views/projects/_merge_request_merge_options_settings.html.haml
@@ -0,0 +1,14 @@
+- form = local_assigns.fetch(:form)
+
+.form-group
+ %b= s_('ProjectSettings|Merge options')
+ %p.text-secondary= s_('ProjectSettings|Additional merge request capabilities that influence how and when merges will be performed')
+ = render_if_exists 'projects/merge_pipelines_settings', form: form
+ .form-check.mb-2
+ = form.check_box :resolve_outdated_diff_discussions, class: 'form-check-input'
+ = form.label :resolve_outdated_diff_discussions, class: 'form-check-label' do
+ = s_('ProjectSettings|Automatically resolve merge request diff discussions when they become outdated')
+ .form-check.mb-2
+ = form.check_box :printing_merge_request_link_enabled, class: 'form-check-input'
+ = form.label :printing_merge_request_link_enabled, class: 'form-check-label' do
+ = s_('ProjectSettings|Show link to create/view merge request when pushing from the command line')
diff --git a/app/views/projects/_merge_request_merge_settings.html.haml b/app/views/projects/_merge_request_merge_settings.html.haml
deleted file mode 100644
index f178c94e008..00000000000
--- a/app/views/projects/_merge_request_merge_settings.html.haml
+++ /dev/null
@@ -1,23 +0,0 @@
-- form = local_assigns.fetch(:form)
-
-.form-group
- .form-check.builds-feature{ class: ("hidden" if @project && @project.project_feature.send(:builds_access_level) == 0) }
- = form.check_box :only_allow_merge_if_pipeline_succeeds, class: 'form-check-input'
- = form.label :only_allow_merge_if_pipeline_succeeds, class: 'form-check-label' do
- %strong Only allow merge requests to be merged if the pipeline succeeds
- %br
- %span.descr
- Pipelines need to be configured to enable this feature.
- = link_to icon('question-circle'), help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds'), target: '_blank'
- .form-check
- = form.check_box :only_allow_merge_if_all_discussions_are_resolved, class: 'form-check-input'
- = form.label :only_allow_merge_if_all_discussions_are_resolved, class: 'form-check-label' do
- %strong Only allow merge requests to be merged if all discussions are resolved
- .form-check
- = form.check_box :resolve_outdated_diff_discussions, class: 'form-check-input'
- = form.label :resolve_outdated_diff_discussions, class: 'form-check-label' do
- %strong Automatically resolve merge request diff discussions when they become outdated
- .form-check
- = form.check_box :printing_merge_request_link_enabled, class: 'form-check-input'
- = form.label :printing_merge_request_link_enabled, class: 'form-check-label' do
- %strong Show link to create/view merge request when pushing from the command line
diff --git a/app/views/projects/_merge_request_settings.html.haml b/app/views/projects/_merge_request_settings.html.haml
index c80e831dd33..f2ba38387a3 100644
--- a/app/views/projects/_merge_request_settings.html.haml
+++ b/app/views/projects/_merge_request_settings.html.haml
@@ -2,4 +2,6 @@
= render 'projects/merge_request_merge_method_settings', project: @project, form: form
-= render 'projects/merge_request_merge_settings', form: form
+= render 'projects/merge_request_merge_options_settings', project: @project, form: form
+
+= render 'projects/merge_request_merge_checks_settings', project: @project, form: form
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
index 276363df7da..e423631ec99 100644
--- a/app/views/projects/_new_project_fields.html.haml
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -1,4 +1,4 @@
-- visibility_level = params.dig(:project, :visibility_level) || default_project_visibility
+- visibility_level = selected_visibility_level(@project, params.dig(:project, :visibility_level))
- ci_cd_only = local_assigns.fetch(:ci_cd_only, false)
- hide_init_with_readme = local_assigns.fetch(:hide_init_with_readme, false)
- track_label = local_assigns.fetch(:track_label, 'blank_project')
@@ -12,21 +12,21 @@
.form-group.project-path.col-sm-6
= f.label :namespace_id, class: 'label-bold' do
%span= s_("Project URL")
- .input-group
+ .input-group.flex-nowrap
- if current_user.can_select_namespace?
- .input-group-prepend.has-tooltip{ title: root_url }
+ .input-group-prepend.flex-shrink-0.has-tooltip{ title: root_url }
.input-group-text
= root_url
- namespace_id = namespace_id_from(params)
= f.select(:namespace_id,
- namespaces_options(namespace_id || :current_user,
- display_path: true,
- extra_group: namespace_id),
+ namespaces_options_with_developer_maintainer_access(selected: namespace_id,
+ display_path: true,
+ extra_group: namespace_id),
{},
- { class: 'select2 js-select-namespace qa-project-namespace-select', tabindex: 1, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_path", track_value: "" }})
+ { class: 'select2 js-select-namespace qa-project-namespace-select block-truncated', tabindex: 1, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_path", track_value: "" }})
- else
- .input-group-prepend.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' }
+ .input-group-prepend.static-namespace.flex-shrink-0.has-tooltip{ title: user_url(current_user.username) + '/' }
.input-group-text.border-0
#{user_url(current_user.username)}/
= f.hidden_field :namespace_id, value: current_user.namespace_id
@@ -54,7 +54,7 @@
.form-group.row.initialize-with-readme-setting
%div{ :class => "col-sm-12" }
.form-check
- = check_box_tag 'project[initialize_with_readme]', '1', false, class: 'form-check-input', data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "init_with_readme" }
+ = check_box_tag 'project[initialize_with_readme]', '1', false, class: 'form-check-input qa-initialize-with-readme-checkbox', data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "init_with_readme" }
= label_tag 'project[initialize_with_readme]', class: 'form-check-label' do
.option-title
%strong Initialize repository with a README
diff --git a/app/views/projects/_wiki.html.haml b/app/views/projects/_wiki.html.haml
index de4653dad2c..6103d86bf5a 100644
--- a/app/views/projects/_wiki.html.haml
+++ b/app/views/projects/_wiki.html.haml
@@ -1,8 +1,7 @@
- if @wiki_home.present?
%div{ class: container_class }
- .prepend-top-default.append-bottom-default
- .wiki
- = render_wiki_content(@wiki_home)
+ .md.md-file.prepend-top-default.append-bottom-default
+ = render_wiki_content(@wiki_home)
- else
- can_create_wiki = can?(current_user, :create_wiki, @project)
.landing{ class: [('row-content-block row p-0 align-items-center' if can_create_wiki), ('content-block' unless can_create_wiki)] }
diff --git a/app/views/projects/_zen.html.haml b/app/views/projects/_zen.html.haml
index afc40ca4eab..c502b392384 100644
--- a/app/views/projects/_zen.html.haml
+++ b/app/views/projects/_zen.html.haml
@@ -8,6 +8,7 @@
= f.text_area attr,
class: classes,
placeholder: placeholder,
+ dir: 'auto',
data: { supports_quick_actions: supports_quick_actions,
supports_autocomplete: supports_autocomplete }
- else
diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml
index 09295940529..6a7cb1499c5 100644
--- a/app/views/projects/artifacts/browse.html.haml
+++ b/app/views/projects/artifacts/browse.html.haml
@@ -4,7 +4,7 @@
= render "projects/jobs/header"
- add_to_breadcrumbs(s_('CICD|Jobs'), project_jobs_path(@project))
-- add_to_breadcrumbs("##{@build.id}", project_jobs_path(@project))
+- add_to_breadcrumbs("##{@build.id}", project_job_path(@project, @build))
.tree-holder
.nav-block
diff --git a/app/views/projects/blob/_header_content.html.haml b/app/views/projects/blob/_header_content.html.haml
index 4bef45932d0..7ed71a7d43c 100644
--- a/app/views/projects/blob/_header_content.html.haml
+++ b/app/views/projects/blob/_header_content.html.haml
@@ -1,12 +1,12 @@
.file-header-content
= blob_icon blob.mode, blob.name
- %strong.file-title-name
+ %strong.file-title-name.qa-file-title-name
= blob.name
= copy_file_path_button(blob.path)
- %small
+ %small.mr-1
= number_to_human_size(blob.raw_size)
- if blob.stored_externally? && blob.external_storage == :lfs
diff --git a/app/views/projects/blob/_markdown_buttons.html.haml b/app/views/projects/blob/_markdown_buttons.html.haml
index 1d6acd86108..28d1ff97825 100644
--- a/app/views/projects/blob/_markdown_buttons.html.haml
+++ b/app/views/projects/blob/_markdown_buttons.html.haml
@@ -1,13 +1,13 @@
.md-header-toolbar.active
- = markdown_toolbar_button({ icon: "bold", data: { "md-tag" => "**" }, title: s_("MarkdownToolbar|Add bold text") })
- = markdown_toolbar_button({ icon: "italic", data: { "md-tag" => "*" }, title: s_("MarkdownToolbar|Add italic text") })
- = markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: s_("MarkdownToolbar|Insert a quote") })
- = markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: s_("MarkdownToolbar|Insert code") })
- = markdown_toolbar_button({ icon: "link", data: { "md-tag" => "[{text}](url)", "md-select" => "url" }, title: s_("MarkdownToolbar|Add a link") })
- = markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "* ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a bullet list") })
- = markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a numbered list") })
- = markdown_toolbar_button({ icon: "task-done", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a task list") })
- = markdown_toolbar_button({ icon: "table", data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a table") })
+ = markdown_toolbar_button({ icon: "bold", data: { "md-tag" => "**" }, title: _("Add bold text") })
+ = markdown_toolbar_button({ icon: "italic", data: { "md-tag" => "*" }, title: _("Add italic text") })
+ = markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: _("Insert a quote") })
+ = markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: _("Insert code") })
+ = markdown_toolbar_button({ icon: "link", data: { "md-tag" => "[{text}](url)", "md-select" => "url" }, title: _("Add a link") })
+ = markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "* ", "md-prepend" => true }, title: _("Add a bullet list") })
+ = markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: _("Add a numbered list") })
+ = markdown_toolbar_button({ icon: "task-done", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: _("Add a task list") })
+ = markdown_toolbar_button({ icon: "table", data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |", "md-prepend" => true }, title: _("Add a table") })
- if show_fullscreen_button
- %button.toolbar-btn.toolbar-fullscreen-btn.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: s_("MarkdownToolbar|Go full screen"), data: { container: "body" } }
+ %button.toolbar-btn.toolbar-fullscreen-btn.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: _("Go full screen"), data: { container: "body" } }
= sprite_icon("screen-full")
diff --git a/app/views/projects/blob/diff.html.haml b/app/views/projects/blob/diff.html.haml
index ea7a71792a3..4f3db61f688 100644
--- a/app/views/projects/blob/diff.html.haml
+++ b/app/views/projects/blob/diff.html.haml
@@ -15,14 +15,14 @@
%a{ href: "#", data: { linenumber: line_number_old }, disabled: true }
%td.new_line.diff-line-num{ data: { linenumber: line_number_new } }
%a{ href: "#", data: { linenumber: line_number_new }, disabled: true }
- %td.line_content.noteable_line{ class: line_class }= line
+ %td.line_content{ class: line_class }= line
- when :parallel
%td.old_line.diff-line-num{ data: { linenumber: line_number_old } }
%a{ href: "##{line_number_old}", data: { linenumber: line_number_old }, disabled: true }
- %td.line_content.noteable_line.left-side{ class: line_class }= line
+ %td.line_content.left-side{ class: line_class }= line
%td.new_line.diff-line-num{ data: { linenumber: line_number_new } }
%a{ href: "##{line_number_new}", data: { linenumber: line_number_new }, disabled: true }
- %td.line_content.noteable_line.right-side{ class: line_class }= line
+ %td.line_content.right-side{ class: line_class }= line
- if @form.unfold? && @form.bottom? && @form.to < @blob.lines.size
%tr.line_holder{ id: @form.to, class: line_class }
diff --git a/app/views/projects/blob/preview.html.haml b/app/views/projects/blob/preview.html.haml
index 66687f087ff..3e893343165 100644
--- a/app/views/projects/blob/preview.html.haml
+++ b/app/views/projects/blob/preview.html.haml
@@ -1,21 +1,20 @@
-.diff-file.file-holder
- .diff-content
- - if markup?(@blob.name)
- .file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
- = markup(@blob.name, @content)
- - else
- .file-content.code.js-syntax-highlight
- - unless @diff_lines.empty?
- %table.text-file
- - @diff_lines.each do |line|
- %tr.line_holder{ class: "#{line.type}" }
- - if line.type == "match"
- %td.old_line.diff-line-num= "..."
- %td.new_line.diff-line-num= "..."
- %td.line_content.match= line.text
- - else
- %td.old_line.diff-line-num
- %td.new_line.diff-line-num
- %td.line_content{ class: "#{line.type}" }= diff_line_content(line.text)
- - else
- .nothing-here-block No changes.
+- if markup?(@blob.name)
+ .file-content.md.md-file{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
+ = markup(@blob.name, @content)
+- else
+ .diff-file
+ .diff-content
+ - unless @diff_lines.empty?
+ %table.text-file.code.js-syntax-highlight
+ - @diff_lines.each do |line|
+ %tr.line_holder{ class: line.type }
+ - if line.type == "match"
+ %td.old_line.diff-line-num.match= "..."
+ %td.new_line.diff-line-num.match= "..."
+ %td.line_content.match= line.text
+ - else
+ %td.old_line.diff-line-num{ class: line.type }
+ %td.new_line.diff-line-num{ class: line.type }
+ %td.line_content{ class: line.type }= diff_line_content(line.text)
+ - else
+ .nothing-here-block No changes.
diff --git a/app/views/projects/blob/viewers/_dependency_manager.html.haml b/app/views/projects/blob/viewers/_dependency_manager.html.haml
index 87aa7c1dbf8..5970d41fdab 100644
--- a/app/views/projects/blob/viewers/_dependency_manager.html.haml
+++ b/app/views/projects/blob/viewers/_dependency_manager.html.haml
@@ -3,9 +3,4 @@
This project manages its dependencies using
%strong= viewer.manager_name
- - if viewer.package_name
- and defines a #{viewer.package_type} named
- %strong<
- = link_to_if viewer.package_url.present?, viewer.package_name, viewer.package_url, target: '_blank', rel: 'noopener noreferrer'
-
= link_to 'Learn more', viewer.manager_url, target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/projects/blob/viewers/_markup.html.haml b/app/views/projects/blob/viewers/_markup.html.haml
index 1a77eb078be..abc74b66e90 100644
--- a/app/views/projects/blob/viewers/_markup.html.haml
+++ b/app/views/projects/blob/viewers/_markup.html.haml
@@ -1,4 +1,4 @@
- blob = viewer.blob
- context = blob.respond_to?(:rendered_markup) ? { rendered: blob.rendered_markup } : {}
-.file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
+.file-content.md.md-file{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= markup(blob.name, blob.data, context)
diff --git a/app/views/projects/blob/viewers/_route_map.html.haml b/app/views/projects/blob/viewers/_route_map.html.haml
index 6d6bd79bc3c..07b9378ba97 100644
--- a/app/views/projects/blob/viewers/_route_map.html.haml
+++ b/app/views/projects/blob/viewers/_route_map.html.haml
@@ -6,4 +6,4 @@
This Route Map is invalid:
= viewer.validation_message
-= link_to 'Learn more', help_page_path('ci/environments', anchor: 'go-directly-from-source-files-to-public-pages-on-the-environment')
+= link_to 'Learn more', help_page_path('ci/environments', anchor: 'going-from-source-files-to-public-pages')
diff --git a/app/views/projects/blob/viewers/_route_map_loading.html.haml b/app/views/projects/blob/viewers/_route_map_loading.html.haml
index a5f73fb0197..f11c047e85a 100644
--- a/app/views/projects/blob/viewers/_route_map_loading.html.haml
+++ b/app/views/projects/blob/viewers/_route_map_loading.html.haml
@@ -1,4 +1,4 @@
= icon('spinner spin fw')
Validating Route Map…
-= link_to 'Learn more', help_page_path('ci/environments', anchor: 'go-directly-from-source-files-to-public-pages-on-the-environment')
+= link_to 'Learn more', help_page_path('ci/environments', anchor: 'going-from-source-files-to-public-pages')
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index 91c51d5e091..1074cd6bf4e 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -10,7 +10,7 @@
.branch-info
.branch-title
= sprite_icon('fork', size: 12)
- = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name prepend-left-8' do
+ = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name prepend-left-8 qa-branch-name' do
= branch.name
- if branch.name == @repository.root_ref
%span.badge.badge-primary.prepend-left-5 default
@@ -22,6 +22,8 @@
%span.badge.badge-success.prepend-left-5
= s_('Branches|protected')
+ = render_if_exists 'projects/branches/diverged_from_upstream'
+
.block-truncated
- if commit
= render 'projects/branches/commit', commit: commit, project: @project
diff --git a/app/views/projects/branches/_commit.html.haml b/app/views/projects/branches/_commit.html.haml
index 7892019bb15..e33e9509e3a 100644
--- a/app/views/projects/branches/_commit.html.haml
+++ b/app/views/projects/branches/_commit.html.haml
@@ -1,9 +1,9 @@
-.branch-commit
+.branch-commit.cgray
.icon-container.commit-icon
= custom_icon("icon_commit")
= link_to commit.short_id, project_commit_path(project, commit.id), class: "commit-sha"
&middot;
%span.str-truncated
- = link_to_markdown commit.title, project_commit_path(project, commit.id), class: "commit-row-message"
+ = link_to_markdown commit.title, project_commit_path(project, commit.id), class: "commit-row-message cgray"
&middot;
#{time_ago_with_tooltip(commit.committed_date)}
diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml
index 43f1cd01b67..d270e461ac8 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -1,5 +1,6 @@
- @no_container = true
- page_title _('Branches')
+- add_to_breadcrumbs(_('Repository'), project_tree_path(@project))
%div{ class: container_class }
.top-area.adjust
@@ -44,6 +45,8 @@
= link_to new_project_branch_path(@project), class: 'btn btn-success' do
= s_('Branches|New branch')
+ = render_if_exists 'projects/commits/mirror_status'
+
- if can?(current_user, :admin_project, @project)
- project_settings_link = link_to s_('Branches|project settings'), project_protected_branches_path(@project)
.row-content-block
diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml
index 159d9e44e17..09f05b30433 100644
--- a/app/views/projects/buttons/_clone.html.haml
+++ b/app/views/projects/buttons/_clone.html.haml
@@ -7,7 +7,7 @@
= sprite_icon("arrow-down", css_class: "icon")
%ul.p-3.dropdown-menu.dropdown-menu-right.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown.qa-clone-options
- if ssh_enabled?
- %li.pb-2
+ %li
%label.label-bold
= _('Clone with SSH')
.input-group
@@ -16,7 +16,7 @@
= clipboard_button(target: '#ssh_project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard")
= render_if_exists 'projects/buttons/geo'
- if http_enabled?
- %li
+ %li.pt-2
%label.label-bold
= _('Clone with %{http_label}') % { http_label: gitlab_config.protocol.upcase }
.input-group
@@ -24,5 +24,6 @@
.input-group-append
= clipboard_button(target: '#http_project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard")
= render_if_exists 'projects/buttons/geo'
+ = render_if_exists 'projects/buttons/kerberos_clone_field'
= render_if_exists 'shared/geo_info_modal', project: project
diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml
index 4eb53faa6ff..4762045ee96 100644
--- a/app/views/projects/buttons/_download.html.haml
+++ b/app/views/projects/buttons/_download.html.haml
@@ -7,31 +7,22 @@
= sprite_icon('download')
%span.sr-only= _('Select Archive Format')
= sprite_icon("arrow-down")
- %ul.dropdown-menu.dropdown-menu-right{ role: 'menu' }
- %li.dropdown-header
- #{ _('Source code') }
- %li
- = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'zip'), rel: 'nofollow', download: '' do
- %span= _('Download zip')
- %li
- = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar.gz'), rel: 'nofollow', download: '' do
- %span= _('Download tar.gz')
- %li
- = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar.bz2'), rel: 'nofollow', download: '' do
- %span= _('Download tar.bz2')
- %li
- = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar'), rel: 'nofollow', download: '' do
- %span= _('Download tar')
-
+ .dropdown-menu.dropdown-menu-right{ role: 'menu' }
+ %section
+ %h5.m-0.dropdown-bold-header= _('Download source code')
+ .dropdown-menu-content
+ = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: nil
+ - if directory? && Feature.enabled?(:git_archive_path, default_enabled: true)
+ %section.border-top.pt-1.mt-1
+ %h5.m-0.dropdown-bold-header= _('Download this directory')
+ .dropdown-menu-content
+ = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: @path
- if pipeline && pipeline.latest_builds_with_artifacts.any?
- %li.dropdown-header Artifacts
- - unless pipeline.latest?
- - latest_pipeline = project.pipeline_for(ref)
- %li
- .unclickable= ci_status_for_statuseable(latest_pipeline)
- %li.dropdown-header Previous Artifacts
- - pipeline.latest_builds_with_artifacts.each do |job|
- %li
- = link_to latest_succeeded_project_artifacts_path(project, "#{ref}/download", job: job.name), rel: 'nofollow', download: '' do
- %span
- #{s_('DownloadArtifacts|Download')} '#{job.name}'
+ %section.border-top.pt-1.mt-1
+ %h5.m-0.dropdown-bold-header= _('Download artifacts')
+ - unless pipeline.latest?
+ %span.unclickable= ci_status_for_statuseable(project.pipeline_for(ref))
+ %h6.m-0.dropdown-header= _('Previous Artifacts')
+ %ul
+ - pipeline.latest_builds_with_artifacts.each do |job|
+ %li= link_to job.name, latest_succeeded_project_artifacts_path(project, "#{ref}/download", job: job.name), rel: 'nofollow', download: ''
diff --git a/app/views/projects/buttons/_download_links.html.haml b/app/views/projects/buttons/_download_links.html.haml
new file mode 100644
index 00000000000..d344167a6c5
--- /dev/null
+++ b/app/views/projects/buttons/_download_links.html.haml
@@ -0,0 +1,5 @@
+- formats = [['zip', 'btn-primary'], ['tar.gz'], ['tar.bz2'], ['tar']]
+
+.btn-group.ml-0.w-100
+ - formats.each do |(fmt, extra_class)|
+ = link_to fmt, project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: fmt), rel: 'nofollow', download: '', class: "btn btn-xs #{extra_class}"
diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml
index 9d069c025ba..bdf7b933ab8 100644
--- a/app/views/projects/ci/builds/_build.html.haml
+++ b/app/views/projects/ci/builds/_build.html.haml
@@ -12,7 +12,7 @@
%td.status
= render "ci/status/badge", status: job.detailed_status(current_user), title: job.status_title
- %td.branch-commit
+ %td.branch-commit.cgray
- if can?(current_user, :read_build, job)
= link_to project_job_path(job.project, job) do
%span.build-link ##{job.id}
@@ -30,7 +30,7 @@
= custom_icon("icon_commit")
- if commit_sha
- = link_to job.short_sha, project_commit_path(job.project, job.sha), class: "commit-sha"
+ = link_to job.short_sha, project_commit_path(job.project, job.sha), class: "commit-sha mr-0"
- if job.stuck?
= icon('warning', class: 'text-warning has-tooltip', title: _('Job is stuck. Check runners.'))
@@ -53,9 +53,10 @@
%span.badge.badge-info= _('manual')
- if pipeline_link
- %td
- = link_to pipeline_path(pipeline) do
+ %td.pipeline-link
+ = link_to pipeline_path(pipeline), class: 'has-tooltip', title: _('Pipeline ID (IID)') do
%span.pipeline-id ##{pipeline.id}
+ %span.pipeline-iid (##{pipeline.iid})
%span by
- if pipeline.user
= user_avatar(user: pipeline.user, size: 20)
diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml
index 888be4ee282..ed3c9890efd 100644
--- a/app/views/projects/cleanup/_show.html.haml
+++ b/app/views/projects/cleanup/_show.html.haml
@@ -1,4 +1,4 @@
-- expanded = Rails.env.test?
+- expanded = expanded_by_default?
%section.settings.no-animate#cleanup{ class: ('expanded' if expanded) }
.settings-header
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index a0db48bf8ff..ef2777e6601 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -81,7 +81,7 @@
= link_to project_pipeline_path(@project, last_pipeline.id), class: "ci-status-icon-#{last_pipeline.status}" do
= ci_icon_for_status(last_pipeline.status)
#{ _('Pipeline') }
- = link_to "##{last_pipeline.id}", project_pipeline_path(@project, last_pipeline.id)
+ = link_to "##{last_pipeline.id} (##{last_pipeline.iid})", project_pipeline_path(@project, last_pipeline.id), class: "has-tooltip", title: _('Pipeline ID (IID)')
= ci_label_for_status(last_pipeline.status)
- if last_pipeline.stages_count.nonzero?
#{ n_(s_('Pipeline|with stage'), s_('Pipeline|with stages'), last_pipeline.stages_count) }
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index 0d3c6e7027c..87b9920e8b4 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -9,10 +9,13 @@
- commit_status = commit.present(current_user: current_user).status_for(ref)
- link = commit_path(project, commit, merge_request: merge_request)
+
+- show_project_name = local_assigns.fetch(:show_project_name, false)
+
%li.commit.flex-row.js-toggle-container{ id: "commit-#{commit.short_id}" }
.avatar-cell.d-none.d-sm-block
- = author_avatar(commit, size: 36, has_tooltip: false)
+ = author_avatar(commit, size: 40, has_tooltip: false)
.commit-detail.flex-list
.commit-content.qa-commit-content
@@ -20,12 +23,9 @@
= link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: "commit-row-message item-title"
- else
= link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title")
- %span.commit-row-message.d-block.d-sm-none
+ %span.commit-row-message.d-inline.d-sm-none
&middot;
= commit.short_id
- - if commit_status
- .d-block.d-sm-none
- = render_commit_status(commit, ref: ref)
- if commit.description?
%button.text-expander.js-toggle-button
= sprite_icon('ellipsis_h', size: 12)
@@ -35,12 +35,13 @@
- commit_timeago = time_ago_with_tooltip(commit.authored_date, placement: 'bottom')
- commit_text = _('%{commit_author_link} authored %{commit_timeago}') % { commit_author_link: commit_author_link, commit_timeago: commit_timeago }
#{ commit_text.html_safe }
+ = render_if_exists 'projects/commits/project_namespace', show_project_name: show_project_name, project: project
- if commit.description?
%pre.commit-row-description.js-toggle-content.append-bottom-8
= preserve(markdown_field(commit, :description))
- .commit-actions.flex-row.d-none.d-sm-flex
+ .commit-actions.flex-row
- if request.xhr?
= render partial: 'projects/commit/signature', object: commit.signature
- else
@@ -51,8 +52,8 @@
.js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id, ref: ref) } }
- .commit-sha-group
- .label.label-monospace
+ .commit-sha-group.d-none.d-sm-flex
+ .label.label-monospace.monospace
= commit.short_id
= clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard"), class: "btn btn-default", container: "body")
= link_to_browse_code(project, commit)
diff --git a/app/views/projects/commits/_inline_commit.html.haml b/app/views/projects/commits/_inline_commit.html.haml
index caaff082cc3..56bebeca581 100644
--- a/app/views/projects/commits/_inline_commit.html.haml
+++ b/app/views/projects/commits/_inline_commit.html.haml
@@ -3,6 +3,6 @@
= link_to commit.short_id, project_commit_path(project, commit), class: "commit-sha"
&nbsp;
%span.str-truncated
- = link_to_markdown_field(commit, :title, project_commit_path(project, commit.id), class: "commit-row-message")
+ = link_to_markdown_field(commit, :title, project_commit_path(project, commit.id), class: "commit-row-message cgray")
.float-right
#{time_ago_with_tooltip(commit.committed_date)}
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index 9d254463fb6..2db1efdd52f 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -30,6 +30,8 @@
= link_to project_commits_path(@project, @ref, rss_url_options), title: _("Commits feed"), class: 'btn' do
= icon("rss")
+ = render_if_exists 'projects/commits/mirror_status'
+
%div{ id: dom_id(@project) }
%ol#commits-list.list-unstyled.content_list
= render 'commits', project: @project, ref: @ref
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index bdf021fd87f..59f0afd59e6 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -10,7 +10,7 @@
.wrapper{ "v-show" => "!isLoading && !hasError" }
.card
.card-header
- {{ __('Pipeline Health') }}
+ {{ __('Recent Project Activity') }}
.content-block
.container-fluid
.row
diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml
index ff6a9d49a61..59efcde5825 100644
--- a/app/views/projects/default_branch/_show.html.haml
+++ b/app/views/projects/default_branch/_show.html.haml
@@ -1,4 +1,4 @@
-- expanded = Rails.env.test?
+- expanded = expanded_by_default?
%section.settings.no-animate#default-branch-settings{ class: ('expanded' if expanded) }
.settings-header
diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml
index 24d665761cc..fcf27351a21 100644
--- a/app/views/projects/deploy_keys/_index.html.haml
+++ b/app/views/projects/deploy_keys/_index.html.haml
@@ -1,4 +1,4 @@
-- expanded = Rails.env.test?
+- expanded = expanded_by_default?
%section.qa-deploy-keys-settings.settings.no-animate#js-deploy-keys-settings{ class: ('expanded' if expanded) }
.settings-header
%h4
diff --git a/app/views/projects/deployments/_actions.haml b/app/views/projects/deployments/_actions.haml
index f4c91377ecb..c84c376d57b 100644
--- a/app/views/projects/deployments/_actions.haml
+++ b/app/views/projects/deployments/_actions.haml
@@ -10,5 +10,5 @@
- actions.each do |action|
- next unless can?(current_user, :update_build, action)
%li
- = link_to [:play, @project.namespace.becomes(Namespace), @project, action], method: :post, rel: 'nofollow', class: 'btn' do
- %span= action.name.humanize
+ = link_to [:play, @project.namespace.becomes(Namespace), @project, action], method: :post, rel: 'nofollow' do
+ %span= action.name
diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml
index 282566eeadc..743aa60b3ba 100644
--- a/app/views/projects/deployments/_commit.html.haml
+++ b/app/views/projects/deployments/_commit.html.haml
@@ -1,17 +1,17 @@
.table-mobile-content
- .branch-commit
+ .branch-commit.cgray
- if deployment.ref
%span.icon-container
= deployment.tag? ? icon('tag') : sprite_icon('fork', css_class: 'sprite')
= link_to deployment.ref, project_ref_path(@project, deployment.ref), class: "ref-name"
.icon-container.commit-icon
= custom_icon("icon_commit")
- = link_to deployment.short_sha, project_commit_path(@project, deployment.sha), class: "commit-sha"
+ = link_to deployment.short_sha, project_commit_path(@project, deployment.sha), class: "commit-sha mr-0"
%p.commit-title.flex-truncate-parent
%span.flex-truncate-child
- if commit_title = deployment.commit_title
= author_avatar(deployment.commit, size: 20)
- = link_to_markdown commit_title, project_commit_path(@project, deployment.sha), class: "commit-row-message"
+ = link_to_markdown commit_title, project_commit_path(@project, deployment.sha), class: "commit-row-message cgray"
- else
= _("Can't find HEAD commit for this branch")
diff --git a/app/views/projects/deployments/_confirm_rollback_modal.html.haml b/app/views/projects/deployments/_confirm_rollback_modal.html.haml
new file mode 100644
index 00000000000..ff40e404e5f
--- /dev/null
+++ b/app/views/projects/deployments/_confirm_rollback_modal.html.haml
@@ -0,0 +1,23 @@
+- commit_sha = link_to deployment.short_sha, project_commit_path(@project, deployment.sha), class: "commit-sha has-tooltip", title: h(deployment.commit_title)
+.modal.ws-normal.fade{ tabindex: -1, id: "confirm-rollback-modal-#{deployment.id}" }
+ .modal-dialog
+ .modal-content
+ .modal-header
+ %h4.modal-title.d-flex.mw-100
+ - if deployment.last?
+ = s_("Environments|Re-deploy environment %{environment_name}?") % {environment_name: @environment.name}
+ - else
+ = s_("Environments|Rollback environment %{environment_name}?") % {environment_name: @environment.name}
+ .modal-body
+ - if deployment.last?
+ %p= s_('Environments|This action will relaunch the job for commit %{commit_id}, putting the environment in a previous version. Are you sure you want to continue?').html_safe % {commit_id: commit_sha}
+ - else
+ %p
+ = s_('Environments|This action will run the job defined by staging for commit %{commit_id}, putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?').html_safe % {commit_id: commit_sha}
+ .modal-footer
+ = button_tag _('Cancel'), type: 'button', class: 'btn btn-cancel', data: { dismiss: 'modal' }
+ = link_to [:retry, @project.namespace.becomes(Namespace), @project, deployment.deployable], method: :post, class: 'btn btn-danger' do
+ - if deployment.last?
+ = s_('Environments|Re-deploy')
+ - else
+ = s_('Environments|Rollback')
diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml
index 85bc8ec07e3..a11e23b6daa 100644
--- a/app/views/projects/deployments/_deployment.html.haml
+++ b/app/views/projects/deployments/_deployment.html.haml
@@ -18,7 +18,7 @@
- if deployment.user
%div
by
- = user_avatar(user: deployment.user, size: 20)
+ = user_avatar(user: deployment.user, size: 20, css_class: "mr-0 float-none")
.table-section.section-15{ role: 'gridcell' }
.table-mobile-header{ role: 'rowheader' }= _("Created")
diff --git a/app/views/projects/deployments/_rollback.haml b/app/views/projects/deployments/_rollback.haml
index 1bd538a08ff..d6bf8d564de 100644
--- a/app/views/projects/deployments/_rollback.haml
+++ b/app/views/projects/deployments/_rollback.haml
@@ -1,7 +1,8 @@
- if can?(current_user, :create_deployment, deployment)
- tooltip = deployment.last? ? s_('Environments|Re-deploy to environment') : s_('Environments|Rollback environment')
- = link_to [:retry, @project.namespace.becomes(Namespace), @project, deployment.deployable], method: :post, class: 'btn btn-build has-tooltip', title: tooltip do
+ = button_tag class: 'btn btn-default btn-build has-tooltip', type: 'button', data: { toggle: 'modal', target: "#confirm-rollback-modal-#{deployment.id}" }, title: tooltip do
- if deployment.last?
= sprite_icon('repeat')
- else
= sprite_icon('redo')
+ = render 'projects/deployments/confirm_rollback_modal', deployment: deployment
diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml
index 68f74f702ea..590fcdb0234 100644
--- a/app/views/projects/diffs/_content.html.haml
+++ b/app/views/projects/diffs/_content.html.haml
@@ -1,2 +1,2 @@
.diff-content
- = render 'projects/diffs/viewer', viewer: diff_file.rich_viewer || diff_file.simple_viewer
+ = render 'projects/diffs/viewer', viewer: diff_file.viewer
diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml
index ffdca500abe..d35443cca1e 100644
--- a/app/views/projects/diffs/_line.html.haml
+++ b/app/views/projects/diffs/_line.html.haml
@@ -30,7 +30,7 @@
= link_text
- else
%a{ href: "##{line_code}", data: { linenumber: link_text } }
- %td.line_content.noteable_line{ class: type }<
+ %td.line_content{ class: type }<
- if email
%pre= line.rich_text
- else
diff --git a/app/views/projects/diffs/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml
index 4b1d4b3ea17..9587ea4696b 100644
--- a/app/views/projects/diffs/_parallel_view.html.haml
+++ b/app/views/projects/diffs/_parallel_view.html.haml
@@ -1,7 +1,7 @@
/ Side-by-side diff view
-.text-file.diff-wrap-lines.code.js-syntax-highlight{ data: diff_view_data }
- %table
+.text-file{ data: diff_view_data }
+ %table.diff-wrap-lines.code.code-commit.js-syntax-highlight
- diff_file.parallel_diff_lines.each do |line|
- left = line[:left]
- right = line[:right]
@@ -23,7 +23,7 @@
- discussion_left = discussions_left.try(:first)
- if discussion_left && discussion_left.resolvable?
%diff-note-avatars{ "discussion-id" => discussion_left.id }
- %td.line_content.parallel.noteable_line.left-side{ id: left_line_code, class: left.type }= diff_line_content(left.rich_text)
+ %td.line_content.parallel.left-side{ id: left_line_code, class: left.type }= diff_line_content(left.rich_text)
- else
%td.old_line.diff-line-num.empty-cell
%td.line_content.parallel.left-side
@@ -44,7 +44,7 @@
- discussion_right = discussions_right.try(:first)
- if discussion_right && discussion_right.resolvable?
%diff-note-avatars{ "discussion-id" => discussion_right.id }
- %td.line_content.parallel.noteable_line.right-side{ id: right_line_code, class: right.type }= diff_line_content(right.rich_text)
+ %td.line_content.parallel.right-side{ id: right_line_code, class: right.type }= diff_line_content(right.rich_text)
- else
%td.old_line.diff-line-num.empty-cell
%td.line_content.parallel.right-side
diff --git a/app/views/projects/diffs/_replaced_image_diff.html.haml b/app/views/projects/diffs/_replaced_image_diff.html.haml
index 6dffc7c4390..566dfe798c6 100644
--- a/app/views/projects/diffs/_replaced_image_diff.html.haml
+++ b/app/views/projects/diffs/_replaced_image_diff.html.haml
@@ -35,10 +35,10 @@
.swipe.view.hide
.swipe-frame
- .frame.deleted
+ .frame.deleted.old-diff
= image_tag(old_blob_raw_url, alt: diff_file.old_path, lazy: false)
- .swipe-wrap
- = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "added js-image-frame #{class_name}", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.new_path }
+ .swipe-wrap.left-oriented
+ = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "added old-diff js-image-frame #{class_name}", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.new_path }
%span.swipe-bar
%span.top-handle
%span.bottom-handle
diff --git a/app/views/projects/diffs/_single_image_diff.html.haml b/app/views/projects/diffs/_single_image_diff.html.haml
index 454f814795a..daac543b939 100644
--- a/app/views/projects/diffs/_single_image_diff.html.haml
+++ b/app/views/projects/diffs/_single_image_diff.html.haml
@@ -10,5 +10,5 @@
.image.js-single-image{ data: diff_view_data }
.wrap
- single_class_name = diff_file.deleted_file? ? 'deleted' : 'added'
- = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "#{single_class_name} #{class_name} js-image-frame", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.file_path }
+ = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "#{single_class_name} #{class_name} old-diff js-image-frame", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.file_path }
%p.image-info= number_to_human_size(blob.size)
diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml
index 56427a74d56..641a0689c26 100644
--- a/app/views/projects/diffs/_text_file.html.haml
+++ b/app/views/projects/diffs/_text_file.html.haml
@@ -1,9 +1,9 @@
- too_big = diff_file.diff_lines.count > Commit::DIFF_SAFE_LINES
- if too_big
.suppressed-container
- %a.show-suppressed-diff.js-show-suppressed-diff= _("Changes suppressed. Click to show.")
+ %a.show-suppressed-diff.cursor-pointer.js-show-suppressed-diff= _("Changes suppressed. Click to show.")
-%table.text-file.diff-wrap-lines.code.js-syntax-highlight.commit-diff{ data: diff_view_data, class: too_big ? 'hide' : '' }
+%table.text-file.diff-wrap-lines.code.code-commit.js-syntax-highlight.commit-diff{ data: diff_view_data, class: too_big ? 'hide' : '' }
= render partial: "projects/diffs/line",
collection: diff_file.highlighted_diff_lines,
as: :line,
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 1a489bfa275..c15b84d0aac 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -1,227 +1,157 @@
-- breadcrumb_title "General Settings"
-- page_title "General"
+- breadcrumb_title _("General Settings")
+- page_title _("General")
- @content_class = "limit-container-width" unless fluid_layout
-- expanded = Rails.env.test?
-
-.project-edit-container
- %section.settings.general-settings.no-animate#js-general-project-settings{ class: ('expanded' if expanded) }
- .settings-header
- %h4
- General project
- %button.btn.js-settings-toggle{ type: 'button' }
- = expanded ? 'Collapse' : 'Expand'
- %p
- Update your project name, description, avatar, and other general settings.
- .settings-content
- .project-edit-errors
- = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "edit-project" }, authenticity_token: true do |f|
- %input{ name: 'update_section', type: 'hidden', value: 'js-general-project-settings' }
- %fieldset
- .row
- .form-group.col-md-9
- = f.label :name, class: 'label-bold', for: 'project_name_edit' do
- Project name
- = f.text_field :name, class: "form-control", id: "project_name_edit"
-
- .form-group.col-md-3
- = f.label :id, class: 'label-bold' do
- Project ID
- = f.text_field :id, class: 'form-control', readonly: true
-
- .form-group
- = f.label :description, class: 'label-bold' do
- Project description
- %span.light (optional)
- = f.text_area :description, class: "form-control", rows: 3, maxlength: 250
-
- = render_if_exists 'projects/classification_policy_settings', f: f
-
- = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :project
-
- .form-group
- = f.label :tag_list, "Topics", class: 'label-bold'
- = f.text_field :tag_list, value: @project.tag_list.join(', '), maxlength: 2000, class: "form-control"
- %p.form-text.text-muted Separate topics with commas.
- %fieldset.features
- %h5.prepend-top-0= _("Project avatar")
- .form-group
- - if @project.avatar?
- .avatar-container.rect-avatar.s160.append-bottom-15
- = project_icon(@project, alt: '', class: 'avatar project-avatar s160', width: 160, height: 160)
- - if @project.avatar_in_git
- %p.light
- = _("Project avatar in repository: %{link}").html_safe % { link: @project.avatar_in_git }
- .prepend-top-5.append-bottom-10
- %button.btn.js-choose-project-avatar-button{ type: 'button' }= _("Choose file...")
- %span.file_name.prepend-left-default.js-filename= _("No file chosen")
- = f.file_field :avatar, class: "js-project-avatar-input hidden"
- .form-text.text-muted= _("The maximum file size allowed is 200KB.")
- - if @project.avatar?
- %hr
- = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _("Avatar will be removed. Are you sure?") }, method: :delete, class: "btn btn-danger btn-inverted"
- = f.submit 'Save changes', class: "btn btn-success js-btn-success-general-project-settings"
-
- %section.settings.sharing-permissions.no-animate#js-shared-permissions{ class: ('expanded' if expanded) }
- .settings-header
- %h4
- Permissions
- %button.btn.js-settings-toggle{ type: 'button' }
- = expanded ? 'Collapse' : 'Expand'
- %p
- Enable or disable certain project features and choose access levels.
- .settings-content
- = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f|
- %input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' }
- -# haml-lint:disable InlineJavaScript
- %script.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"
-
- = render_if_exists 'projects/issues_settings'
-
- %section.qa-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
- %h4
- Merge request
- %button.btn.js-settings-toggle{ type: 'button' }
- = expanded ? 'Collapse' : 'Expand'
- %p
- Customize your merge request restrictions.
- .settings-content
- = render_if_exists 'shared/promotions/promote_mr_features'
-
- = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "merge-request-settings-form" }, authenticity_token: true do |f|
- %input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' }
- = render 'projects/merge_request_settings', form: f
- = f.submit 'Save changes', class: "btn btn-success qa-save-merge-request-changes"
-
- = render_if_exists 'projects/merge_request_approvals_settings', expanded: expanded
-
- = render_if_exists 'projects/service_desk_settings'
-
- %section.settings.no-animate{ class: ('expanded' if expanded) }
- .settings-header
- %h4
- = s_('ProjectSettings|Badges')
- %button.btn.js-settings-toggle{ type: 'button' }
- = expanded ? 'Collapse' : 'Expand'
- %p
- = s_('ProjectSettings|Customize your project badges.')
- = link_to s_('ProjectSettings|Learn more about badges.'), help_page_path('user/project/badges')
- .settings-content
- = render 'shared/badges/badge_settings'
-
- = render 'export', project: @project
-
- %section.qa-advanced-settings.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded) }
- .settings-header
- %h4
- Advanced
- %button.btn.js-settings-toggle{ type: 'button' }
- = expanded ? 'Collapse' : 'Expand'
- %p
- Perform advanced options such as housekeeping, archiving, renaming, transferring, or removing your project.
- .settings-content
+- expanded = expanded_by_default?
+
+%section.settings.general-settings.no-animate.expanded#js-general-settings
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Naming, topics, avatar')
+ %button.btn.btn-default.js-settings-toggle{ type: 'button' }= _('Collapse')
+ %p= _('Update your project name, topics, description and avatar.')
+ .settings-content= render 'projects/settings/general'
+
+%section.settings.sharing-permissions.no-animate#js-shared-permissions{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Visibility, project features, permissions')
+ %button.btn.btn-default.js-settings-toggle{ type: 'button' }= expanded ? _('Collapse') : _('Expand')
+ %p= _('Choose visibility level, enable/disable project features (issues, repository, wiki, snippets) and set permissions.')
+
+ .settings-content
+ = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f|
+ %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"
+
+%section.qa-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
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Merge requests')
+ %button.btn.btn-default.js-settings-toggle{ type: 'button' }= expanded ? _('Collapse') : _('Expand')
+ %p= _('Choose your merge method, options, checks, and set up a default merge request description template.')
+
+ .settings-content
+ = render_if_exists 'shared/promotions/promote_mr_features'
+
+ = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f|
+ %input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' }
+ = render 'projects/merge_request_settings', form: f
+ = f.submit _('Save changes'), class: "btn btn-success qa-save-merge-request-changes"
+
+= render_if_exists 'projects/merge_request_approvals_settings', expanded: expanded
+
+
+%section.settings.no-animate{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
+ = s_('ProjectSettings|Badges')
+ %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded ? _('Collapse') : _('Expand')
+ %p
+ = s_('ProjectSettings|Customize your project badges.')
+ = link_to s_('ProjectSettings|Learn more about badges.'), help_page_path('user/project/badges')
+ .settings-content
+ = render 'shared/badges/badge_settings'
+
+= render_if_exists 'projects/settings/default_issue_template'
+
+= render_if_exists 'projects/service_desk_settings'
+
+%section.qa-advanced-settings.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Advanced')
+ %button.btn.btn-default.js-settings-toggle{ type: 'button' }= expanded ? _('Collapse') : _('Expand')
+ %p= _('Housekeeping, export, path, transfer, remove, archive.')
+
+ .settings-content
+ .sub-section
+ %h4= _('Housekeeping')
+ %p= _('Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.')
+ = link_to _('Run housekeeping'), housekeeping_project_path(@project),
+ method: :post, class: "btn btn-default"
+
+ = render 'export', project: @project
+
+ - if can? current_user, :archive_project, @project
.sub-section
- %h4 Housekeeping
- %p
- Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.
- = link_to 'Run housekeeping', housekeeping_project_path(@project),
- method: :post, class: "btn btn-default"
- - if can? current_user, :archive_project, @project
- .sub-section
- %h4.warning-title
- - if @project.archived?
- Unarchive project
- - else
- Archive project
+ %h4.warning-title
- 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.
- = link_to 'Unarchive project', unarchive_project_path(@project),
- data: { confirm: "Are you sure that you want to unarchive this project?" },
- method: :post, class: "btn btn-success"
+ = _('Unarchive project')
- 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.
- = link_to 'Archive project', archive_project_path(@project),
- data: { confirm: "Are you sure that you want to archive this project?" },
- method: :post, class: "btn btn-warning"
- .sub-section.rename-repository
- %h4.warning-title
- Rename repository
- = render 'projects/errors'
- = form_for([@project.namespace.becomes(Namespace), @project]) do |f|
- .form-group.project_name_holder
- = f.label :name, class: 'label-bold' do
- Project name
- .form-group
- = f.text_field :name, class: "form-control"
+ = _('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?") },
+ 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?") },
+ method: :post, class: "btn btn-warning"
+ .sub-section.rename-repository
+ %h4.warning-title= _('Change path')
+ = render 'projects/errors'
+ = form_for([@project.namespace.becomes(Namespace), @project]) do |f|
+ .form-group
+ = f.label :path, _('Path'), class: 'label-bold'
+ .form-group
+ .input-group
+ .input-group-prepend
+ .input-group-text
+ #{Gitlab::Utils.append_path(root_url, @project.namespace.full_path)}/
+ = f.text_field :path, class: 'form-control qa-project-path-field h-auto'
+ %ul
+ %li= _("Be careful. Renaming a project's repository can have unintended side effects.")
+ %li= _('You will need to update your local repositories to point to the new location.')
+ - if @project.deployment_platform.present?
+ %li= _('Your deployment services will be broken, you will need to manually fix the services after renaming.')
+ = f.submit _('Change path'), class: "btn btn-warning qa-change-path-button"
+
+ - if can?(current_user, :change_namespace, @project)
+ .sub-section
+ %h4.danger-title= _('Transfer project')
+ = form_for([@project.namespace.becomes(Namespace), @project], url: transfer_project_path(@project), method: :put, remote: true, html: { class: 'js-project-transfer-form' } ) do |f|
.form-group
- = f.label :path, class: 'label-bold' do
- %span Path
+ = label_tag :new_namespace_id, nil, class: 'label-bold' do
+ %span= _('Select a new namespace')
.form-group
- .input-group
- .input-group-prepend
- .input-group-text
- #{Gitlab::Utils.append_path(root_url, @project.namespace.full_path)}/
- = f.text_field :path, class: 'form-control'
+ = select_tag :new_namespace_id, namespaces_options(nil), include_blank: true, class: 'select2'
%ul
- %li Be careful. Renaming a project's repository can have unintended side effects.
- %li You will need to update your local repositories to point to the new location.
- - if @project.deployment_platform.present?
- %li Your deployment services will be broken, you will need to manually fix the services after renaming.
- = f.submit 'Rename project', class: "btn btn-warning"
- - if can?(current_user, :change_namespace, @project)
- .sub-section
- %h4.danger-title
- Transfer project
- = form_for([@project.namespace.becomes(Namespace), @project], url: transfer_project_path(@project), method: :put, remote: true, html: { class: 'js-project-transfer-form' } ) do |f|
- .form-group
- = label_tag :new_namespace_id, nil, class: 'label-bold' do
- %span Select a new namespace
- .form-group
- = select_tag :new_namespace_id, namespaces_options(nil), include_blank: true, class: 'select2'
- %ul
- %li Be careful. Changing the project's namespace can have unintended side effects.
- %li You can only transfer the project to namespaces you manage.
- %li You will need to update your local repositories to point to the new location.
- %li Project visibility level will be changed to match namespace rules when transferring to a group.
- = f.submit 'Transfer project', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => transfer_project_message(@project) }
- - if @project.forked? && can?(current_user, :remove_fork_project, @project)
- .sub-section
- %h4.danger-title
- Remove fork relationship
+ %li= _("Be careful. Changing the project's namespace can have unintended side effects.")
+ %li= _('You can only transfer the project to namespaces you manage.')
+ %li= _('You will need to update your local repositories to point to the new location.')
+ %li= _('Project visibility level will be changed to match namespace rules when transferring to a group.')
+ = f.submit 'Transfer project', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => transfer_project_message(@project) }
+
+ - if @project.forked? && can?(current_user, :remove_fork_project, @project)
+ .sub-section
+ %h4.danger-title= _('Remove fork relationship')
+ %p
+ = _('This will remove the fork relationship to source project')
+ = succeed "." do
+ - if @project.fork_source
+ = link_to(fork_source_name(@project), project_path(@project.fork_source))
+ - else
+ = fork_source_name(@project)
+ = form_for([@project.namespace.becomes(Namespace), @project], url: remove_fork_project_path(@project), method: :delete, remote: true, html: { class: 'transfer-project' }) do |f|
%p
- This will remove the fork relationship to source project
- = succeed "." do
- - if @project.fork_source
- = link_to(fork_source_name(@project), project_path(@project.fork_source))
- - else
- = fork_source_name(@project)
- = form_for([@project.namespace.becomes(Namespace), @project], url: remove_fork_project_path(@project), method: :delete, remote: true, html: { class: 'transfer-project' }) do |f|
- %p
- %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_message(@project) }
- - if can?(current_user, :remove_project, @project)
- .sub-section
- %h4.danger-title
- Remove project
+ %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_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
- 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) }
+ %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) }
.save-project-loader.hide
.center
%h2
%i.fa.fa-spinner.fa-spin
- Saving project.
- %p Please wait a moment, this page will automatically refresh when ready.
+ = _('Saving project.')
+ %p= _('Please wait a moment, this page will automatically refresh when ready.')
= render 'shared/confirm_modal', phrase: @project.path
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index 081990ac9b7..9fa31c147eb 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -7,89 +7,64 @@
%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
= render "home_panel"
- .project-empty-note-panel
- %h4.append-bottom-20
- = _('The repository for this project is empty')
+ %h4.prepend-top-0.append-bottom-8
+ = _('The repository for this project is empty')
- - if @project.can_current_user_push_code?
- %p
- - link_to_cli = link_to _('command line instructions'), '#repo-command-line-instructions'
- = _('If you already have files you can push them using the %{link_to_cli} below.').html_safe % { link_to_cli: link_to_cli }
- %p
- %em
- - link_to_protected_branches = link_to _('Learn more about protected branches'), help_page_path('user/project/protected_branches')
- = _('Note that the master branch is automatically protected. %{link_to_protected_branches}').html_safe % { link_to_protected_branches: link_to_protected_branches }
-
- %hr
- %p
- - link_to_auto_devops_settings = link_to(s_('AutoDevOps|enable Auto DevOps'), project_settings_ci_cd_path(@project, anchor: 'autodevops-settings'))
- - link_to_add_kubernetes_cluster = link_to(s_('AutoDevOps|add a Kubernetes cluster'), new_project_cluster_path(@project))
- = s_('AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}.').html_safe % { link_to_auto_devops_settings: link_to_auto_devops_settings, link_to_add_kubernetes_cluster: link_to_add_kubernetes_cluster }
+ - if @project.can_current_user_push_code?
+ %p.append-bottom-0
+ = _('You can create files directly in GitLab using one of the following options.')
- %hr
- %p
- = _('Otherwise it is recommended you start with one of the options below.')
- .prepend-top-20
-
- %nav.project-buttons
- .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller.qa-quick-actions
- .fade-left= icon('angle-left')
- .fade-right= icon('angle-right')
- .nav-links.scrolling-tabs.quick-links
- = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons
+ .project-buttons.qa-quick-actions
+ = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons
- if can?(current_user, :push_code, @project)
- %div
- .prepend-top-20
- .empty_wrapper
- %h3#repo-command-line-instructions.page-title-empty
- = _('Command line instructions')
- .git-empty.js-git-empty
- %fieldset
- %h5= _('Git global setup')
- %pre.bg-light
- :preserve
- git config --global user.name "#{h git_user_name}"
- git config --global user.email "#{h git_user_email}"
-
- %fieldset
- %h5= _('Create a new repository')
- %pre.bg-light
- :preserve
- git clone #{ content_tag(:span, default_url_to_repo, class: 'js-clone')}
- cd #{h @project.path}
- touch README.md
- git add README.md
- git commit -m "add README"
- - if @project.can_current_user_push_to_default_branch?
- %span><
- git push -u origin master
+ .empty-wrapper.prepend-top-32
+ %h3#repo-command-line-instructions.page-title-empty
+ = _('Command line instructions')
+ %p
+ = _('You can also upload existing files from your computer using the instructions below.')
+ .git-empty.js-git-empty
+ %fieldset
+ %h5= _('Git global setup')
+ %pre.bg-light
+ :preserve
+ git config --global user.name "#{h git_user_name}"
+ git config --global user.email "#{h git_user_email}"
- %fieldset
- %h5= _('Existing folder')
- %pre.bg-light
- :preserve
- cd existing_folder
- git init
- git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')}
- git add .
- git commit -m "Initial commit"
- - if @project.can_current_user_push_to_default_branch?
- %span><
- git push -u origin master
+ %fieldset
+ %h5= _('Create a new repository')
+ %pre.bg-light
+ :preserve
+ git clone #{ content_tag(:span, default_url_to_repo, class: 'js-clone')}
+ cd #{h @project.path}
+ touch README.md
+ git add README.md
+ git commit -m "add README"
+ - if @project.can_current_user_push_to_default_branch?
+ %span><
+ git push -u origin master
- %fieldset
- %h5= _('Existing Git repository')
- %pre.bg-light
- :preserve
- cd existing_repo
- git remote rename origin old-origin
- git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')}
- - if @project.can_current_user_push_to_default_branch?
- %span><
- git push -u origin --all
- git push -u origin --tags
+ %fieldset
+ %h5= _('Push an existing folder')
+ %pre.bg-light
+ :preserve
+ cd existing_folder
+ git init
+ git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')}
+ git add .
+ git commit -m "Initial commit"
+ - if @project.can_current_user_push_to_default_branch?
+ %span><
+ git push -u origin master
- - if can? current_user, :remove_project, @project
- .prepend-top-20
- = link_to _('Remove project'), [@project.namespace.becomes(Namespace), @project], data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-inverted btn-remove float-right"
+ %fieldset
+ %h5= _('Push an existing Git repository')
+ %pre.bg-light
+ :preserve
+ cd existing_repo
+ git remote rename origin old-origin
+ git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')}
+ - if @project.can_current_user_push_to_default_branch?
+ %span><
+ git push -u origin --all
+ git push -u origin --tags
diff --git a/app/views/projects/environments/_form.html.haml b/app/views/projects/environments/_form.html.haml
index cbd5c54cecc..1fbe34cfff3 100644
--- a/app/views/projects/environments/_form.html.haml
+++ b/app/views/projects/environments/_form.html.haml
@@ -17,5 +17,5 @@
= f.url_field :external_url, class: 'form-control'
.form-actions
- = f.submit _('Save'), class: 'btn btn-save'
+ = f.submit _('Save'), class: 'btn btn-success'
= link_to _('Cancel'), project_environments_path(@project), class: 'btn btn-cancel'
diff --git a/app/views/projects/forks/error.html.haml b/app/views/projects/forks/error.html.haml
index e8a89b8c6fc..b37dba8b35d 100644
--- a/app/views/projects/forks/error.html.haml
+++ b/app/views/projects/forks/error.html.haml
@@ -1,24 +1,20 @@
-- page_title "Fork project"
+- page_title _("Fork project")
- if @forked_project && !@forked_project.saved?
.alert.alert-danger.alert-block
%h4
= sprite_icon('fork', size: 16)
- Fork Error!
+ = _("Fork Error!")
%p
- You tried to fork
- = link_to_project @project
- but it failed for the following reason:
-
+ = _("You tried to fork %{link_to_the_project} but it failed for the following reason:").html_safe % { link_to_the_project: link_to_project(@project) }
- if @forked_project && @forked_project.errors.any?
%p
&ndash;
- error = @forked_project.errors.full_messages.first
- if error.include?("already been taken")
- Name has already been taken
+ = _("Name has already been taken")
- else
= error
%p
- = link_to new_project_fork_path(@project), title: "Fork", class: "btn" do
- Try to fork again
+ = link_to _("Try to fork again"), new_project_fork_path(@project), title: _("Fork"), class: "btn"
diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml
index c63c34c4ebb..0397a7034c7 100644
--- a/app/views/projects/forks/index.html.haml
+++ b/app/views/projects/forks/index.html.haml
@@ -5,12 +5,12 @@
.nav-controls
= form_tag request.original_url, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f|
- = search_field_tag :filter_projects, nil, placeholder: 'Search forks', class: 'projects-list-filter project-filter-form-field form-control input-short',
+ = search_field_tag :filter_projects, nil, placeholder: _('Search forks'), class: 'projects-list-filter project-filter-form-field form-control input-short',
spellcheck: false, data: { 'filter-selector' => 'span.namespace-name' }
.dropdown
%button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
- %span.light sort:
+ %span.light= _("sort:")
- if @sort.present?
= sort_options_hash[@sort]
- else
@@ -30,13 +30,12 @@
- if current_user && can?(current_user, :fork_project, @project)
- if current_user.already_forked?(@project) && current_user.manageable_namespaces.size < 2
- = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: 'Go to your fork', class: 'btn btn-success' do
+ = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'btn btn-success' do
= sprite_icon('fork', size: 12)
- %span Fork
+ %span= _('Fork')
- else
- = link_to new_project_fork_path(@project), title: "Fork project", class: 'btn btn-success' do
+ = link_to new_project_fork_path(@project), title: _("Fork project"), class: 'btn btn-success' do
= sprite_icon('fork', size: 12)
- %span Fork
-
+ %span= _('Fork')
= render 'projects', projects: @forks
diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml
index a603b1024eb..bf03353a565 100644
--- a/app/views/projects/forks/new.html.haml
+++ b/app/views/projects/forks/new.html.haml
@@ -1,13 +1,11 @@
-- page_title "Fork project"
+- page_title _("Fork project")
.row.prepend-top-default
.col-lg-3
%h4.prepend-top-0
- Fork project
+ = _("Fork project")
%p
- A fork is a copy of a project.
- %br
- Forking a repository allows you to make changes without affecting the original project.
+ = _("A fork is a copy of a project.<br />Forking a repository allows you to make changes without affecting the original project.").html_safe
.col-lg-9
- if @namespaces.present?
.fork-thumbnail-container.js-fork-content
@@ -17,13 +15,13 @@
= render 'fork_button', namespace: namespace
- else
%strong
- No available namespaces to fork the project.
+ = _("No available namespaces to fork the project.")
%p.prepend-top-default
- You must have permission to create a project in a namespace before forking.
+ = _("You must have permission to create a project in a namespace before forking.")
.save-project-loader.hide.js-fork-content
%h2.text-center
= icon('spinner spin')
- Forking repository
+ = _("Forking repository")
%p.text-center
- Please wait a moment, this page will automatically refresh when ready.
+ = _("Please wait a moment, this page will automatically refresh when ready.")
diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml
index 7614d40ba1f..1118b44d7a2 100644
--- a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml
+++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml
@@ -5,11 +5,11 @@
- pipeline_link = local_assigns.fetch(:pipeline_link, false)
- stage = local_assigns.fetch(:stage, false)
-%tr.generic_commit_status{ class: ('retried' if retried) }
+%tr.generic-commit-status{ class: ('retried' if retried) }
%td.status
= render 'ci/status/badge', status: generic_commit_status.detailed_status(current_user)
- %td.generic_commit_status-link
+ %td.generic-commit-status-link
- if can?(current_user, :read_commit_status, generic_commit_status) && generic_commit_status.target_url
= link_to generic_commit_status.target_url do
%span.build-link ##{generic_commit_status.id}
diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml
index f1b14d4c4d1..4b2417ff43b 100644
--- a/app/views/projects/graphs/show.html.haml
+++ b/app/views/projects/graphs/show.html.haml
@@ -22,6 +22,6 @@
= s_('ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits.') % { branch_name: @ref }
%input#brush_change{ :type => "hidden" }
.graphs.row
- #contributors-master
+ #contributors-master.svg-w-100
#contributors.clearfix
- %ol.contributors-list.row
+ %ol.contributors-list.svg-w-100.row
diff --git a/app/views/projects/issues/_closed_by_box.html.haml b/app/views/projects/issues/_closed_by_box.html.haml
deleted file mode 100644
index 38469ed4774..00000000000
--- a/app/views/projects/issues/_closed_by_box.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-.issue-closed-by-widget.second-block
- - pluralized_mr_this = merge_request_count > 1 ? "these" : "this"
- - pluralized_mr_is = merge_request_count > 1 ? "are" : "is"
- When #{pluralized_mr_this} merge #{"request".pluralize(merge_request_count)} #{pluralized_mr_is} accepted, this issue will be closed automatically.
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index ce7c7091c93..9293aa1b309 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -6,7 +6,7 @@
.issuable-info-container
.issuable-main-info
.issue-title.title
- %span.issue-title-text
+ %span.issue-title-text{ dir: "auto" }
- if issue.confidential?
%span.has-tooltip{ title: _('Confidential') }
= confidential_icon(issue)
@@ -36,8 +36,10 @@
= issue.due_date.to_s(:medium)
- if issue.labels.any?
&nbsp;
- - labels_sorted_by_title(issue.labels).each do |label|
- = link_to_label(label, subject: issue.project, css_class: 'label-link')
+ - presented_labels_sorted_by_title(issue.labels, issue.project).each do |label|
+ = link_to_label(label, css_class: 'label-link')
+
+ = render_if_exists "projects/issues/issue_weight", issue: issue
.issuable-meta
%ul.controls
@@ -46,7 +48,7 @@
CLOSED
- if issue.assignees.any?
%li
- = render 'shared/issuable/assignees', project: @project, issue: issue
+ = render 'shared/issuable/assignees', project: @project, issuable: issue
= render 'shared/issuable_meta_data', issuable: issue
diff --git a/app/views/projects/issues/_merge_requests.html.haml b/app/views/projects/issues/_merge_requests.html.haml
deleted file mode 100644
index 310e339ac8d..00000000000
--- a/app/views/projects/issues/_merge_requests.html.haml
+++ /dev/null
@@ -1,36 +0,0 @@
-- if @merge_requests.any?
- .card-slim.mt-3
- .card-header
- %h2.card-title.mt-0.mb-0.h5.merge-requests-title
- %span.mr-1.bold
- = _('Related merge requests')
- .d-inline-flex.lh-100.align-middle
- .mr-count-badge
- .mr-count-badge-count
- = sprite_icon('merge-request', size: 16, css_class: 'mr-1 text-secondary')
- = @merge_requests.count
- %ul.content-list.related-items-list
- - has_any_head_pipeline = @merge_requests.any?(&:head_pipeline_id)
- - @merge_requests.each do |merge_request|
- - merge_request = merge_request.present(current_user: current_user)
- %li.list-item.py-0.px-0
- .item-body.issuable-info-container.py-lg-3.px-lg-3.pl-md-3
- .item-contents
- .item-title.d-flex.align-items-center.mr-title
- = render partial: 'projects/issues/merge_requests_status', locals: { merge_request: merge_request, css_class: 'd-none d-xl-block append-right-8' }
- = link_to merge_request.title, merge_request_path(merge_request), { class: 'mr-title-link'}
- .item-meta
- = render partial: 'projects/issues/merge_requests_status', locals: { merge_request: merge_request, css_class: 'd-xl-none d-lg-block append-right-5' }
- %span.d-flex.align-items-center.append-right-8.mr-item-path.item-path-id.mt-0
- %span.path-id-text.bold.text-truncate{ data: { toggle: 'tooltip'}, title: merge_request.target_project.full_path }
- = merge_request.target_project.full_path
- = merge_request.to_reference
- %span.mr-ci-status.flex-md-grow-1.justify-content-end.d-flex.ml-md-2
- - if merge_request.can_read_pipeline?
- = render_pipeline_status(merge_request.head_pipeline, tooltip_placement: 'bottom')
- - elsif has_any_head_pipeline
- = icon('blank fw')
-
- - if @closed_by_merge_requests.present?
- %p
- = render partial: 'projects/issues/closed_by_box', locals: {merge_request_count: @merge_requests.count}
diff --git a/app/views/projects/issues/_merge_requests_status.html.haml b/app/views/projects/issues/_merge_requests_status.html.haml
deleted file mode 100644
index 90838a75214..00000000000
--- a/app/views/projects/issues/_merge_requests_status.html.haml
+++ /dev/null
@@ -1,25 +0,0 @@
-- time_format = '%b %e, %Y %l:%M%P %Z%z'
-
-- if merge_request.merged?
- - mr_status_date = merge_request.merged_at
- - mr_status_title = _('Merged')
- - mr_status_icon = 'merge'
- - mr_status_class = 'merged'
-- elsif merge_request.closed?
- - mr_status_date = merge_request.closed_event&.created_at
- - mr_status_title = _('Closed')
- - mr_status_icon = 'issue-close'
- - mr_status_class = 'closed'
-- else
- - mr_status_date = merge_request.created_at
- - mr_status_title = mr_status_date ? _('Opened') : _('Open')
- - mr_status_icon = 'issue-open-m'
- - mr_status_class = 'open'
-
-- if mr_status_date
- - mr_status_tooltip = "<div><span class=\"bold\">#{mr_status_title}</span> #{time_ago_in_words(mr_status_date)} ago</div><span class=\"text-tertiary\">#{l(mr_status_date.to_time, format: time_format)}</span>"
-- else
- - mr_status_tooltip = "<div><span class=\"bold\">#{mr_status_title}</span></div>"
-
-%span.mr-status-wrapper.suggestion-help-hover{ class: css_class, data: { toggle: 'tooltip', placement: 'bottom', html: 'true', title: mr_status_tooltip } }
- = sprite_icon(mr_status_icon, size: 16, css_class: "merge-request-status #{mr_status_class}")
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index fbd70cd1906..457b2936278 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -8,18 +8,18 @@
- create_branch_path = project_branches_path(@project, branch_name: @issue.to_branch_name, ref: @project.default_branch, issue_iid: @issue.iid)
- refs_path = refs_namespace_project_path(@project.namespace, @project, search: '')
- .create-mr-dropdown-wrap.d-inline-block{ data: { can_create_path: can_create_path, create_mr_path: create_mr_path, create_branch_path: create_branch_path, refs_path: refs_path } }
- .btn-group.unavailable
+ .create-mr-dropdown-wrap.d-inline-block.full-width-mobile{ data: { can_create_path: can_create_path, create_mr_path: create_mr_path, create_branch_path: create_branch_path, refs_path: refs_path } }
+ .btn-group.btn-group-sm.unavailable
%button.btn.btn-grouped{ type: 'button', disabled: 'disabled' }
= icon('spinner', class: 'fa-spin')
%span.text
Checking branch availability…
- .btn-group.available.hidden
+ .btn-group.btn-group-sm.available.hidden
%button.btn.js-create-merge-request.btn-success.btn-inverted{ type: 'button', data: { action: data_action } }
= value
- %button.btn.create-merge-request-dropdown-toggle.dropdown-toggle.btn-success.btn-inverted.js-dropdown-toggle{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' }, display: 'static' } }
+ %button.btn.create-merge-request-dropdown-toggle.dropdown-toggle.btn-success.btn-inverted.js-dropdown-toggle.flex-grow-0{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' }, display: 'static' } }
= icon('caret-down')
.droplab-dropdown
diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml
index ffdd96870ef..6da4956a036 100644
--- a/app/views/projects/issues/_related_branches.html.haml
+++ b/app/views/projects/issues/_related_branches.html.haml
@@ -8,7 +8,7 @@
- pipeline = @project.pipeline_for(branch, target.sha) if target
- if can?(current_user, :read_pipeline, pipeline)
%span.related-branch-ci-status
- = render_pipeline_status(pipeline)
+ = render 'ci/status/icon', status: pipeline.detailed_status(current_user)
%span.related-branch-info
%strong
= link_to branch, project_compare_path(@project, from: @project.default_branch, to: branch), class: "ref-name"
diff --git a/app/views/projects/issues/new.html.haml b/app/views/projects/issues/new.html.haml
index 9a081a42b6f..d1601d7fd10 100644
--- a/app/views/projects/issues/new.html.haml
+++ b/app/views/projects/issues/new.html.haml
@@ -1,9 +1,8 @@
-- add_to_breadcrumbs "Issues", project_issues_path(@project)
-- breadcrumb_title "New"
-- page_title "New Issue"
+- add_to_breadcrumbs _("Issues"), project_issues_path(@project)
+- breadcrumb_title _("New")
+- page_title _("New Issue")
-%h3.page-title
- New Issue
+%h3.page-title= _("New Issue")
%hr
= render "form"
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 3a674da6e87..d55afee4523 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -1,7 +1,7 @@
- @content_class = "limit-container-width" unless fluid_layout
-- add_to_breadcrumbs "Issues", project_issues_path(@project)
+- add_to_breadcrumbs _("Issues"), project_issues_path(@project)
- breadcrumb_title @issue.to_reference
-- page_title "#{@issue.title} (#{@issue.to_reference})", "Issues"
+- page_title "#{@issue.title} (#{@issue.to_reference})", _("Issues")
- page_description @issue.description
- page_card_attributes @issue.card_attributes
@@ -15,7 +15,7 @@
.issuable-status-box.status-box.status-box-issue-closed{ class: issue_button_visibility(@issue, false) }
= sprite_icon('mobile-issue-close', size: 16, css_class: 'd-block d-sm-none')
.d-none.d-sm-block
- - if @issue.moved?
+ - if @issue.moved? && can?(current_user, :read_issue, @issue.moved_to)
- moved_link_start = "<a href=\"#{issue_path(@issue.moved_to)}\" class=\"text-white text-underline\">".html_safe
- moved_link_end = '</a>'.html_safe
= s_('IssuableStatus|Closed (%{moved_link_start}moved%{moved_link_end})').html_safe % {moved_link_start: moved_link_start,
@@ -72,16 +72,18 @@
%h2.title= markdown_field(@issue, :title)
- if @issue.description.present?
.description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' }
- .wiki= markdown_field(@issue, :description)
+ .md= markdown_field(@issue, :description)
%textarea.hidden.js-task-list-field= @issue.description
= edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago')
- #merge-requests{ data: { url: referenced_merge_requests_project_issue_path(@project, @issue) } }
- // This element is filled in using JavaScript.
+ = render_if_exists 'projects/issues/related_issues'
- #related-branches{ data: { url: related_branches_project_issue_path(@project, @issue) } }
- // This element is filled in using JavaScript.
+ #js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: @issue.iid)), project_namespace: @project.namespace.path, project_path: @project.path } }
+
+ - if can?(current_user, :download_code, @project)
+ #related-branches{ data: { url: related_branches_project_issue_path(@project, @issue) } }
+ -# This element is filled in using JavaScript.
.content-block.emoji-block.emoji-block-sticky
.row
diff --git a/app/views/projects/jobs/_table.html.haml b/app/views/projects/jobs/_table.html.haml
index d124d3ebfc1..b08223546f7 100644
--- a/app/views/projects/jobs/_table.html.haml
+++ b/app/views/projects/jobs/_table.html.haml
@@ -16,7 +16,7 @@
%th Runner
%th Stage
%th Name
- %th
+ %th Timing
%th Coverage
%th
diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml
index 475bae887ec..81a53f22f67 100644
--- a/app/views/projects/jobs/show.html.haml
+++ b/app/views/projects/jobs/show.html.haml
@@ -8,6 +8,7 @@
%div{ class: container_class }
#js-job-vue-app{ data: { endpoint: project_job_path(@project, @build, format: :json),
+ deployment_help_url: help_page_path('user/project/clusters/index.html', anchor: 'troubleshooting-failed-deployment-jobs'),
runner_help_url: help_page_path('ci/runners/README.html', anchor: 'setting-maximum-job-timeout-for-a-runner'),
runner_settings_url: project_runners_path(@build.project, anchor: 'js-runners-settings'),
build_options: javascript_build_options } }
diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml
index bb7c297ba1f..511d7a82d1b 100644
--- a/app/views/projects/labels/index.html.haml
+++ b/app/views/projects/labels/index.html.haml
@@ -11,31 +11,30 @@
= render 'shared/labels/nav', labels_or_filters: labels_or_filters, can_admin_label: can_admin_label
.labels-container.prepend-top-10
- - if can_admin_label
- - if search.blank?
- %p.text-muted
- = _('Labels can be applied to issues and merge requests.')
- %br
- = _('Star a label to make it a priority label. Order the prioritized labels to change their relative priority, by dragging.')
- -# Only show it in the first page
- - hide = @available_labels.empty? || (params[:page].present? && params[:page] != '1')
- .prioritized-labels{ class: ('hide' if hide) }
- %h5.prepend-top-10= _('Prioritized Labels')
- .content-list.manage-labels-list.js-prioritized-labels{ "data-url" => set_priorities_project_labels_path(@project) }
- #js-priority-labels-empty-state.priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty? && search.blank?}" }
- = render 'shared/empty_states/priority_labels'
- - if @prioritized_labels.present?
- = render partial: 'shared/label', subject: @project, collection: @prioritized_labels, as: :label, locals: { force_priority: true }
- - elsif search.present?
- .nothing-here-block
- = _('No prioritised labels with such name or description')
+ - if can_admin_label && search.blank?
+ %p.text-muted
+ = _('Labels can be applied to issues and merge requests.')
+ %br
+ = _('Star a label to make it a priority label. Order the prioritized labels to change their relative priority, by dragging.')
+
+ -# Only show it in the first page
+ - hide = @available_labels.empty? || (params[:page].present? && params[:page] != '1')
+ .prioritized-labels{ class: [('hide' if hide), ('is-not-draggable' unless can_admin_label)] }
+ %h5.prepend-top-10= _('Prioritized Labels')
+ .content-list.manage-labels-list.js-prioritized-labels{ data: { url: set_priorities_project_labels_path(@project), sortable: can_admin_label } }
+ #js-priority-labels-empty-state.priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty? && search.blank?}" }
+ = render 'shared/empty_states/priority_labels'
+ - if @prioritized_labels.present?
+ = render partial: 'shared/label', collection: @prioritized_labels, as: :label, locals: { force_priority: true, subject: @project }
+ - elsif search.present?
+ .nothing-here-block
+ = _('No prioritised labels with such name or description')
- if @labels.present?
.other-labels
- - if can_admin_label
- %h5{ class: ('hide' if hide) }= _('Other Labels')
+ %h5{ class: ('hide' if hide) }= _('Other Labels')
.content-list.manage-labels-list.js-other-labels
- = render partial: 'shared/label', subject: @project, collection: @labels, as: :label
+ = render partial: 'shared/label', collection: @labels, as: :label, locals: { subject: @project }
= paginate @labels, theme: 'gitlab'
- elsif search.present?
.other-labels
diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml
index 90916191d97..67e5e4ca62d 100644
--- a/app/views/projects/merge_requests/_merge_request.html.haml
+++ b/app/views/projects/merge_requests/_merge_request.html.haml
@@ -34,8 +34,8 @@
= merge_request.target_branch
- if merge_request.labels.any?
&nbsp;
- - labels_sorted_by_title(merge_request.labels).each do |label|
- = link_to_label(label, subject: merge_request.project, type: :merge_request, css_class: 'label-link')
+ - presented_labels_sorted_by_title(merge_request.labels, merge_request.project).each do |label|
+ = link_to_label(label, type: :merge_request, css_class: 'label-link')
.issuable-meta
%ul.controls
@@ -48,14 +48,14 @@
CLOSED
- if can?(current_user, :read_pipeline, merge_request.head_pipeline)
%li.issuable-pipeline-status.d-none.d-sm-inline-block
- = render_pipeline_status(merge_request.head_pipeline)
+ = render 'ci/status/icon', status: merge_request.head_pipeline.detailed_status(current_user)
- if merge_request.open? && merge_request.broken?
%li.issuable-pipeline-broken.d-none.d-sm-inline-block
= link_to merge_request_path(merge_request), class: "has-tooltip", title: _('Cannot be merged automatically') do
= icon('exclamation-triangle')
- - if merge_request.assignee
+ - if merge_request.assignees.any?
%li
- = link_to_member(merge_request.source_project, merge_request.assignee, name: false, title: _('Assigned to :name'))
+ = render 'shared/issuable/assignees', project: merge_request.project, issuable: merge_request
= render_if_exists 'projects/merge_requests/approvals_count', merge_request: merge_request
= render 'shared/issuable_meta_data', issuable: merge_request
diff --git a/app/views/projects/merge_requests/_merge_requests.html.haml b/app/views/projects/merge_requests/_merge_requests.html.haml
index bd6f1c05949..57fbd360d46 100644
--- a/app/views/projects/merge_requests/_merge_requests.html.haml
+++ b/app/views/projects/merge_requests/_merge_requests.html.haml
@@ -1,5 +1,5 @@
%ul.content-list.mr-list.issuable-list
- - if @merge_requests.exists?
+ - if @merge_requests.present?
= render @merge_requests
- else
= render 'shared/empty_states/merge_requests'
diff --git a/app/views/projects/merge_requests/_mr_box.html.haml b/app/views/projects/merge_requests/_mr_box.html.haml
index 1a9ab288683..7f2c9dcacfd 100644
--- a/app/views/projects/merge_requests/_mr_box.html.haml
+++ b/app/views/projects/merge_requests/_mr_box.html.haml
@@ -5,7 +5,7 @@
%div
- if @merge_request.description.present?
.description{ class: can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : '' }
- .wiki
+ .md
= markdown_field(@merge_request, :description)
%textarea.hidden.js-task-list-field
= @merge_request.description
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index 3cd83feb842..92e34b3ceda 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -1,8 +1,9 @@
- can_update_merge_request = can?(current_user, :update_merge_request, @merge_request)
+- can_reopen_merge_request = can?(current_user, :reopen_merge_request, @merge_request)
- if @merge_request.closed_without_fork?
.alert.alert-danger
- %p The source project of this merge request has been removed.
+ The source project of this merge request has been removed.
.detail-page-header
.detail-page-header-body
@@ -33,10 +34,11 @@
- if can_update_merge_request
%li{ class: [merge_request_button_visibility(@merge_request, true), 'js-close-item'] }
= link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request'
+ - if can_reopen_merge_request
%li{ class: merge_request_button_visibility(@merge_request, false) }
= link_to 'Reopen', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: 'reopen-mr-link', title: 'Reopen merge request'
- if can_update_merge_request
= link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "d-none d-sm-none d-md-block btn btn-grouped js-issuable-edit qa-edit-button"
- = render 'shared/issuable/close_reopen_button', issuable: @merge_request, can_update: can_update_merge_request, can_reopen: can_update_merge_request
+ = render 'shared/issuable/close_reopen_button', issuable: @merge_request, can_update: can_update_merge_request, can_reopen: can_reopen_merge_request
diff --git a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
index 8181267184a..55c89f137c5 100644
--- a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
+++ b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
@@ -6,7 +6,7 @@
.form-group.row
.col-md-4
%h4= _('Resolve conflicts on source branch')
- .resolve-info
+ .resolve-info{ "v-pre": true }
= translation.html_safe
.col-md-8
%label.label-bold{ "for" => "commit-message" }
diff --git a/app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml b/app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml
index d828cb6cf9e..7bd5c437942 100644
--- a/app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml
+++ b/app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml
@@ -1,5 +1,5 @@
%inline-conflict-lines{ "inline-template" => "true", ":file" => "file" }
- %table
+ %table.diff-wrap-lines.code.code-commit.js-syntax-highlight
%tr.line_holder.diff-inline{ "v-for" => "line in file.inlineLines" }
%td.diff-line-num.new_line{ ":class" => "lineCssClass(line)", "v-if" => "!line.isHeader" }
%a {{line.new_line}}
diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml
index 09aeb81671a..f48390aa046 100644
--- a/app/views/projects/merge_requests/conflicts/show.html.haml
+++ b/app/views/projects/merge_requests/conflicts/show.html.haml
@@ -26,9 +26,9 @@
%strong {{file.filePath}}
= render partial: 'projects/merge_requests/conflicts/file_actions'
.diff-content.diff-wrap-lines
- .diff-wrap-lines.code.file-content.js-syntax-highlight{ "v-show" => "!isParallel && file.resolveMode === 'interactive' && file.type === 'text'" }
+ .file-content{ "v-show" => "!isParallel && file.resolveMode === 'interactive' && file.type === 'text'" }
= render partial: "projects/merge_requests/conflicts/components/inline_conflict_lines"
- .diff-wrap-lines.code.file-content.js-syntax-highlight{ "v-show" => "isParallel && file.resolveMode === 'interactive' && file.type === 'text'" }
+ .file-content{ "v-show" => "isParallel && file.resolveMode === 'interactive' && file.type === 'text'" }
%parallel-conflict-lines{ ":file" => "file" }
%div{ "v-show" => "file.resolveMode === 'edit' || file.type === 'text-editor'" }
= render partial: "projects/merge_requests/conflicts/components/diff_file_editor"
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 5111c9fab8d..a201fafb949 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -31,29 +31,26 @@
.merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') }
.merge-request-tabs-container
- .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
- .fade-left= icon('angle-left')
- .fade-right= icon('angle-right')
- %ul.merge-request-tabs.nav-tabs.nav.nav-links.scrolling-tabs
- %li.notes-tab.qa-notes-tab
- = tab_link_for @merge_request, :show, force_link: @commit.present? do
- Discussion
- %span.badge.badge-pill= @merge_request.related_notes.user.count
- - if @merge_request.source_project
- %li.commits-tab
- = tab_link_for @merge_request, :commits do
- Commits
- %span.badge.badge-pill= @commits_count
- - if @pipelines.any?
- %li.pipelines-tab
- = tab_link_for @merge_request, :pipelines do
- Pipelines
- %span.badge.badge-pill.js-pipelines-mr-count= @pipelines.size
- %li.diffs-tab.qa-diffs-tab
- = tab_link_for @merge_request, :diffs do
- Changes
- %span.badge.badge-pill= @merge_request.diff_size
- .d-inline-flex.flex-wrap
+ %ul.merge-request-tabs.nav-tabs.nav.nav-links
+ %li.notes-tab.qa-notes-tab
+ = tab_link_for @merge_request, :show, force_link: @commit.present? do
+ = _("Discussion")
+ %span.badge.badge-pill= @merge_request.related_notes.user.count
+ - if @merge_request.source_project
+ %li.commits-tab
+ = tab_link_for @merge_request, :commits do
+ = _("Commits")
+ %span.badge.badge-pill= @commits_count
+ - if @pipelines.any?
+ %li.pipelines-tab
+ = tab_link_for @merge_request, :pipelines do
+ = _("Pipelines")
+ %span.badge.badge-pill.js-pipelines-mr-count= @pipelines.size
+ %li.diffs-tab.qa-diffs-tab
+ = tab_link_for @merge_request, :diffs do
+ = _("Changes")
+ %span.badge.badge-pill= @merge_request.diff_size
+ .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 } }
#js-vue-discussion-counter
@@ -82,7 +79,8 @@
help_page_path: suggest_changes_help_path,
current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json,
project_path: project_path(@merge_request.project),
- changes_empty_state_illustration: image_path('illustrations/merge_request_changes_empty.svg') } }
+ changes_empty_state_illustration: image_path('illustrations/merge_request_changes_empty.svg'),
+ is_fluid_layout: fluid_layout.to_s } }
.mr-loading-status
= spinner
diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml
index 0542b349e44..1cee8be604a 100644
--- a/app/views/projects/milestones/show.html.haml
+++ b/app/views/projects/milestones/show.html.haml
@@ -54,13 +54,12 @@
%div
- if @milestone.description.present?
- .description
- .wiki
- = markdown_field(@milestone, :description)
+ .description.md
+ = markdown_field(@milestone, :description)
= render_if_exists 'shared/milestones/burndown', milestone: @milestone, project: @project
- - if can?(current_user, :read_issue, @project) && @milestone.total_items_count(current_user).zero?
+ - if can?(current_user, :read_issue, @project) && @milestone.total_issues_count(current_user).zero?
.alert.alert-success.prepend-top-default
%span= _('Assign some issues to this milestone.')
- elsif @milestone.complete?(current_user) && @milestone.active?
diff --git a/app/views/projects/mirrors/_authentication_method.html.haml b/app/views/projects/mirrors/_authentication_method.html.haml
index 293a2e3ebfe..ee82d68d398 100644
--- a/app/views/projects/mirrors/_authentication_method.html.haml
+++ b/app/views/projects/mirrors/_authentication_method.html.haml
@@ -1,14 +1,12 @@
- mirror = f.object
-- is_push = local_assigns.fetch(:is_push, false)
- auth_options = [[_('Password'), 'password'], [_('SSH public key'), 'ssh_public_key']]
-- regen_data = { auth_method: 'ssh_public_key', regenerate_ssh_private_key: true }
-- ssh_public_key_present = mirror.ssh_public_key.present?
.form-group
= f.label :auth_method, _('Authentication method'), class: 'label-bold'
= f.select :auth_method,
options_for_select(auth_options, mirror.auth_method),
{}, { class: "form-control js-mirror-auth-type qa-authentication-method" }
+ = f.hidden_field :auth_method, value: "password", class: "js-hidden-mirror-auth-type"
.form-group
.collapse.js-well-changing-auth
@@ -16,21 +14,3 @@
.well-password-auth.collapse.js-well-password-auth
= f.label :password, _("Password"), class: "label-bold"
= f.password_field :password, value: mirror.password, class: 'form-control qa-password', autocomplete: 'new-password'
- - unless is_push
- .well-ssh-auth.collapse.js-well-ssh-auth
- %p.js-ssh-public-key-present{ class: ('collapse' unless ssh_public_key_present) }
- = _('Here is the public SSH key that needs to be added to the remote server. For more information, please refer to the documentation.')
- %p.js-ssh-public-key-pending{ class: ('collapse' if ssh_public_key_present) }
- = _('An SSH key will be automatically generated when the form is submitted. For more information, please refer to the documentation.')
-
- .clearfix.js-ssh-public-key-wrap{ class: ('collapse' unless ssh_public_key_present) }
- %code.prepend-top-10.ssh-public-key
- = mirror.ssh_public_key
- = clipboard_button(text: mirror.ssh_public_key, title: _("Copy SSH public key to clipboard"), class: 'prepend-top-10 btn-copy-ssh-public-key')
-
- = button_tag type: 'button',
- data: { endpoint: project_mirror_path(@project), project_data: { import_data_attributes: regen_data } },
- class: "btn btn-inverted btn-warning prepend-top-10 js-btn-regenerate-ssh-key#{ ' collapse' unless ssh_public_key_present }" do
- = icon('spinner spin', class: 'js-spinner d-none')
- = _('Regenerate key')
- = render 'projects/mirrors/regenerate_public_ssh_key_confirm_modal'
diff --git a/app/views/projects/mirrors/_disabled_mirror_badge.html.haml b/app/views/projects/mirrors/_disabled_mirror_badge.html.haml
new file mode 100644
index 00000000000..356cb43f07f
--- /dev/null
+++ b/app/views/projects/mirrors/_disabled_mirror_badge.html.haml
@@ -0,0 +1 @@
+.badge.badge-warning.qa-disabled-mirror-badge{ data: { toggle: 'tooltip', html: 'true' }, title: _('Disabled mirrors can only be enabled by instance owners. It is recommended that you delete them.') }= _('Disabled')
diff --git a/app/views/projects/mirrors/_instructions.html.haml b/app/views/projects/mirrors/_instructions.html.haml
index 35a6885318a..33e5a6e67c3 100644
--- a/app/views/projects/mirrors/_instructions.html.haml
+++ b/app/views/projects/mirrors/_instructions.html.haml
@@ -7,7 +7,7 @@
%li
- minutes = Gitlab.config.gitlab_shell.git_timeout / 60
= _("The update action will time out after %{number_of_minutes} minutes. For big repositories, use a clone/push combination.") % { number_of_minutes: minutes }
- %li= _('The Git LFS objects will <strong>not</strong> be synced.').html_safe
+ %li= mirror_lfs_sync_message
%li
= _('This user will be the author of all events in the activity feed that are the result of an update,
like new branches being created or new commits being pushed to existing branches.')
diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml
index 21b105e6f80..e68fa5d08c7 100644
--- a/app/views/projects/mirrors/_mirror_repos.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos.html.haml
@@ -1,4 +1,4 @@
-- expanded = Rails.env.test?
+- expanded = expanded_by_default?
- protocols = Gitlab::UrlSanitizer::ALLOWED_SCHEMES.join('|')
%section.settings.project-mirror-settings.js-mirror-settings.no-animate.qa-mirroring-repositories-settings#js-push-remote-settings{ class: ('expanded' if expanded) }
@@ -11,7 +11,7 @@
= link_to _('Read more'), help_page_path('workflow/repository_mirroring'), target: '_blank'
.settings-content
- = form_for @project, url: project_mirror_path(@project), html: { class: 'gl-show-field-errors js-mirror-form', autocomplete: 'false', data: mirrors_form_data_attributes } do |f|
+ = form_for @project, url: project_mirror_path(@project), html: { class: 'gl-show-field-errors js-mirror-form', autocomplete: 'new-password', data: mirrors_form_data_attributes } do |f|
.panel.panel-default
.panel-heading
%h3.panel-title= _('Mirror a repository')
@@ -20,7 +20,7 @@
.form-group.has-feedback
= label_tag :url, _('Git repository URL'), class: 'label-light'
- = text_field_tag :url, nil, class: 'form-control js-mirror-url js-repo-url qa-mirror-repository-url-input', placeholder: _('Input your repository URL'), required: true, pattern: "(#{protocols}):\/\/.+"
+ = text_field_tag :url, nil, class: 'form-control js-mirror-url js-repo-url qa-mirror-repository-url-input', placeholder: _('Input your repository URL'), required: true, pattern: "(#{protocols}):\/\/.+", autocomplete: 'new-password'
= render 'projects/mirrors/instructions'
@@ -29,7 +29,7 @@
.form-check.append-bottom-10
= check_box_tag :only_protected_branches, '1', false, class: 'js-mirror-protected form-check-input'
= label_tag :only_protected_branches, _('Only mirror protected branches'), class: 'form-check-label'
- = link_to icon('question-circle'), help_page_path('user/project/protected_branches')
+ = link_to icon('question-circle'), help_page_path('user/project/protected_branches'), target: '_blank'
.panel-footer
= f.submit _('Mirror repository'), class: 'btn btn-success js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror
@@ -49,17 +49,19 @@
%tbody.js-mirrors-table-body
= render_if_exists 'projects/mirrors/table_pull_row'
- @project.remote_mirrors.each_with_index do |mirror, index|
- - if mirror.enabled
- %tr.qa-mirrored-repository-row
- %td.qa-mirror-repository-url= mirror.safe_url
- %td= _('Push')
- %td.qa-mirror-last-update-at= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never')
- %td
- - if mirror.last_error.present?
- .badge.mirror-error-badge{ data: { toggle: 'tooltip', html: 'true' }, title: html_escape(mirror.last_error.try(:strip)) }= _('Error')
- %td.mirror-action-buttons
- .btn-group.mirror-actions-group.pull-right{ role: 'group' }
- - if mirror.ssh_key_auth?
- = clipboard_button(text: mirror.ssh_public_key, class: 'btn btn-default', title: _('Copy SSH public key'))
- = render 'shared/remote_mirror_update_button', remote_mirror: mirror
- %button.js-delete-mirror.btn.btn-danger{ type: 'button', data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' }, title: _('Remove') }= icon('trash-o')
+ - next if mirror.new_record?
+ %tr.qa-mirrored-repository-row{ class: ('bg-secondary' if mirror.disabled?) }
+ %td.qa-mirror-repository-url= mirror.safe_url
+ %td= _('Push')
+ %td.qa-mirror-last-update-at= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never')
+ %td
+ - if mirror.disabled?
+ = render 'projects/mirrors/disabled_mirror_badge'
+ - if mirror.last_error.present?
+ .badge.mirror-error-badge{ data: { toggle: 'tooltip', html: 'true' }, title: html_escape(mirror.last_error.try(:strip)) }= _('Error')
+ %td
+ .btn-group.mirror-actions-group.pull-right{ role: 'group' }
+ - if mirror.ssh_key_auth?
+ = clipboard_button(text: mirror.ssh_public_key, class: 'btn btn-default', title: _('Copy SSH public key'))
+ = render 'shared/remote_mirror_update_button', remote_mirror: mirror
+ %button.js-delete-mirror.qa-delete-mirror.btn.btn-danger{ type: 'button', data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' }, title: _('Remove') }= icon('trash-o')
diff --git a/app/views/projects/mirrors/_mirror_repos_push.html.haml b/app/views/projects/mirrors/_mirror_repos_push.html.haml
index 1d9c83653fe..b7c885b4a63 100644
--- a/app/views/projects/mirrors/_mirror_repos_push.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos_push.html.haml
@@ -5,4 +5,4 @@
= rm_f.hidden_field :url, class: 'js-mirror-url-hidden', required: true, pattern: "(#{protocols}):\/\/.+"
= rm_f.hidden_field :only_protected_branches, class: 'js-mirror-protected-hidden'
= render partial: 'projects/mirrors/ssh_host_keys', locals: { f: rm_f }
- = render partial: 'projects/mirrors/authentication_method', locals: { f: rm_f, is_push: true }
+ = render partial: 'projects/mirrors/authentication_method', locals: { f: rm_f }
diff --git a/app/views/projects/mirrors/_ssh_host_keys.html.haml b/app/views/projects/mirrors/_ssh_host_keys.html.haml
index f61aa6ecd11..7762fb4b844 100644
--- a/app/views/projects/mirrors/_ssh_host_keys.html.haml
+++ b/app/views/projects/mirrors/_ssh_host_keys.html.haml
@@ -3,7 +3,7 @@
- verified_at = mirror.ssh_known_hosts_verified_at
.form-group.js-ssh-host-keys-section{ class: ('collapse' unless mirror.ssh_mirror_url?) }
- %button.btn.btn-inverted.btn-success.inline.js-detect-host-keys.append-right-10{ type: 'button' }
+ %button.btn.btn-inverted.btn-secondary.inline.js-detect-host-keys.append-right-10{ type: 'button' }
= icon('spinner spin', class: 'js-spinner d-none')
= _('Detect host keys')
.fingerprint-ssh-info.js-fingerprint-ssh-info.prepend-top-10.append-bottom-10{ class: ('collapse' unless mirror.ssh_mirror_url?) }
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index ff7c36c2d5b..d7e16dbd40c 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -16,6 +16,7 @@
= _('A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}.').html_safe % { among_other_things_link: among_other_things_link }
%p
= _('All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings.')
+ = render_if_exists 'projects/new_ci_cd_banner_external_repo'
%p
- pages_getting_started_guide = link_to _('Pages getting started guide'), help_page_path("user/project/pages/getting_started_part_two", anchor: "fork-a-project-to-get-started-from"), target: '_blank'
= _('Information about additional Pages templates and how to install them can be found in our %{pages_getting_started_guide}.').html_safe % { pages_getting_started_guide: pages_getting_started_guide }
@@ -42,13 +43,19 @@
%a.nav-link{ href: '#import-project-pane', id: 'import-project-tab', data: { toggle: 'tab', track_label: 'import_project', track_event: "click_tab" }, role: 'tab' }
%span.d-none.d-sm-block Import project
%span.d-block.d-sm-none Import
+ = render_if_exists 'projects/new_ci_cd_only_project_tab', active_tab: active_tab
.tab-content.gitlab-tab-content
.tab-pane{ id: 'blank-project-pane', class: active_when(active_tab == 'blank'), role: 'tabpanel' }
= form_for @project, html: { class: 'new_project' } do |f|
= render 'new_project_fields', f: f, project_name_id: "blank-project-name"
- .tab-pane.no-padding{ id: 'create-from-template-pane', class: active_when(active_tab == 'template'), role: 'tabpanel' }
+ #create-from-template-pane.tab-pane.px-0.pb-0{ class: active_when(active_tab == 'template'), role: 'tabpanel' }
+ .card-slim.m-4.p-4
+ %div
+ - contributing_templates_url = 'https://gitlab.com/gitlab-org/project-templates/contributing'
+ - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: contributing_templates_url }
+ = _('Learn how to %{link_start}contribute to the built-in templates%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
= form_for @project, html: { class: 'new_project' } do |f|
.project-template
.form-group
@@ -63,6 +70,8 @@
%h4 No import options available
%p Contact an administrator to enable options for importing your project.
+ = render_if_exists 'projects/new_ci_cd_only_project_pane', active_tab: active_tab
+
.save-project-loader.d-none
.center
%h2
diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml
index eb6838cec8d..044adb75bea 100644
--- a/app/views/projects/notes/_actions.html.haml
+++ b/app/views/projects/notes/_actions.html.haml
@@ -41,9 +41,9 @@
.note-actions-item
= button_tag title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji} has-tooltip btn btn-transparent", data: { position: 'right', container: 'body' } do
= icon('spinner spin')
- %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face')
- %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley')
- %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile')
+ %span{ class: 'link-highlight award-control-icon-neutral' }= sprite_icon('slight-smile')
+ %span{ class: 'link-highlight award-control-icon-positive' }= sprite_icon('smiley')
+ %span{ class: 'link-highlight award-control-icon-super-positive' }= sprite_icon('smile')
- if note_editable
.note-actions-item
diff --git a/app/views/projects/notes/_more_actions_dropdown.html.haml b/app/views/projects/notes/_more_actions_dropdown.html.haml
index 8de84f82e9f..8a6e5fde99b 100644
--- a/app/views/projects/notes/_more_actions_dropdown.html.haml
+++ b/app/views/projects/notes/_more_actions_dropdown.html.haml
@@ -11,7 +11,7 @@
- unless is_current_user
%li
= link_to new_abuse_report_path(user_id: note.author.id, ref_url: noteable_note_url(note)) do
- = _('Report abuse to GitLab')
+ = _('Report abuse to admin')
- if note_editable
%li
= link_to note_url(note), method: :delete, data: { confirm: 'Are you sure you want to delete this comment?' }, remote: true, class: 'js-note-delete' do
diff --git a/app/views/projects/pages/_https_only.html.haml b/app/views/projects/pages/_https_only.html.haml
index ce3ef29c32e..74478ee011c 100644
--- a/app/views/projects/pages/_https_only.html.haml
+++ b/app/views/projects/pages/_https_only.html.haml
@@ -3,7 +3,7 @@
.form-check
= f.check_box :pages_https_only, class: 'form-check-input', disabled: pages_https_only_disabled?
= f.label :pages_https_only, class: pages_https_only_label_class do
- %strong Force domains with SSL certificates to use HTTPS
+ %strong Force HTTPS (requires valid certificates)
- unless pages_https_only_disabled?
.prepend-top-10
diff --git a/app/views/projects/pages_domains/_form.html.haml b/app/views/projects/pages_domains/_form.html.haml
index b7b46c56c37..33f2166480b 100644
--- a/app/views/projects/pages_domains/_form.html.haml
+++ b/app/views/projects/pages_domains/_form.html.haml
@@ -1,29 +1,80 @@
- if @domain.errors.any?
- #error_explanation
- .alert.alert-danger
- - @domain.errors.full_messages.each do |msg|
- %p= msg
+ .alert.alert-danger
+ - @domain.errors.full_messages.each do |msg|
+ = msg
.form-group.row
- = f.label :domain, class: 'col-form-label col-sm-2' do
- = _("Domain")
+ .col-sm-2.col-form-label
+ = f.label :domain, _("Domain")
.col-sm-10
- = f.text_field :domain, required: true, autocomplete: 'off', class: 'form-control', disabled: @domain.persisted?
+ = f.text_field :domain, required: true, autocomplete: "off", class: "form-control", disabled: @domain.persisted?
- if Gitlab.config.pages.external_https
- .form-group.row
- = f.label :certificate, class: 'col-form-label col-sm-2' do
- = _("Certificate (PEM)")
- .col-sm-10
- = f.text_area :certificate, rows: 5, class: 'form-control'
- %span.help-inline= _("Upload a certificate for your domain with all intermediates")
-
- .form-group.row
- = f.label :key, class: 'col-form-label col-sm-2' do
- = _("Key (PEM)")
- .col-sm-10
- = f.text_area :key, rows: 5, class: 'form-control'
- %span.help-inline= _("Upload a private key for your certificate")
+
+ - auto_ssl_available = Feature.enabled?(:pages_auto_ssl)
+ - auto_ssl_enabled = @domain.auto_ssl_enabled?
+ - auto_ssl_available_and_enabled = auto_ssl_available && auto_ssl_enabled
+
+ - if auto_ssl_available
+ .form-group.row
+ .col-sm-2.col-form-label
+ %label{ for: "pages_domain_auto_ssl_enabled_button" }
+ - lets_encrypt_link_url = "https://letsencrypt.org/"
+ - lets_encrypt_link_start = "<a href=\"%{lets_encrypt_link_url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-nowrap\">".html_safe % { lets_encrypt_link_url: lets_encrypt_link_url }
+ - lets_encrypt_link_end = "</a>".html_safe
+ = _("Automatic certificate management using %{lets_encrypt_link_start}Let's Encrypt%{lets_encrypt_link_end}").html_safe % { lets_encrypt_link_start: lets_encrypt_link_start, lets_encrypt_link_end: lets_encrypt_link_end }
+
+ .col-sm-10.js-auto-ssl-toggle-container
+ %button{ type: "button", id: "pages_domain_auto_ssl_enabled_button",
+ class: "js-project-feature-toggle project-feature-toggle mt-2 #{"is-checked" if auto_ssl_available_and_enabled}",
+ "aria-label": _("Automatic certificate management using Let's Encrypt") }
+ = f.hidden_field :auto_ssl_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")
+ %p.text-secondary.mt-3
+ - docs_link_url = help_page_path("user/project/pages/lets_encrypt_for_gitlab_pages.md", anchor: "lets-encrypt-for-gitlab-pages")
+ - docs_link_start = "<a href=\"%{docs_link_url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-nowrap\">".html_safe % { docs_link_url: docs_link_url }
+ - docs_link_end = "</a>".html_safe
+ = _("Let's Encrypt is a free, automated, and open certificate authority (CA) that gives digital certificates in order to enable HTTPS (SSL/TLS) for websites. Learn more about Let's Encrypt configuration by following the %{docs_link_start}documentation on GitLab Pages%{docs_link_end}.").html_safe % { docs_link_url: docs_link_url, docs_link_start: docs_link_start, docs_link_end: docs_link_end }
+
+ .js-shown-if-auto-ssl{ class: ("d-none" unless auto_ssl_available_and_enabled) }
+ .form-group.row
+ .col-sm-2.col-form-label
+ = f.label :certificate, _("Certificate (PEM)")
+ .col-sm-10
+ - if auto_ssl_available_and_enabled && !@domain.certificate.empty?
+ = f.text_area :certificate,
+ rows: 5,
+ class: "form-control",
+ disabled: true
+ %span.help-inline.text-muted= _("This certificate is automatically managed by Let's Encrypt")
+ - else
+ %p.text-secondary.form-control-plaintext= _("The certificate will be shown here once it has been obtained from Let's Encrypt. This process may take up to an hour to complete.")
+
+ .js-shown-unless-auto-ssl{ class: ("d-none" if auto_ssl_available_and_enabled) }
+ .form-group.row
+ .col-sm-2.col-form-label
+ = f.label :certificate, _("Certificate (PEM)")
+ .col-sm-10
+ = f.text_area :certificate,
+ rows: 5,
+ class: "form-control js-enabled-unless-auto-ssl",
+ value: (@domain.certificate unless auto_ssl_available_and_enabled),
+ disabled: auto_ssl_available_and_enabled
+ %span.help-inline.text-muted= _("Upload a certificate for your domain with all intermediates")
+
+ .form-group.row
+ .col-sm-2.col-form-label
+ = f.label :key, _("Key (PEM)")
+ .col-sm-10
+ = f.text_area :key,
+ rows: 5,
+ class: "form-control js-enabled-unless-auto-ssl",
+ value: (@domain.key unless auto_ssl_available_and_enabled),
+ disabled: auto_ssl_available_and_enabled
+ %span.help-inline.text-muted= _("Upload a private key for your certificate")
+
- else
.nothing-here-block
= _("Support for custom certificates is disabled. Ask your system's administrator to enable it.")
diff --git a/app/views/projects/pages_domains/_helper_text.html.haml b/app/views/projects/pages_domains/_helper_text.html.haml
new file mode 100644
index 00000000000..5a79fefabfc
--- /dev/null
+++ b/app/views/projects/pages_domains/_helper_text.html.haml
@@ -0,0 +1,9 @@
+- docs_link_url = help_page_path("user/project/pages/getting_started_part_three.md", anchor: "adding-certificates-to-your-project")
+- docs_link_start = "<a href=\"%{docs_link_url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-nowrap\">".html_safe % { docs_link_url: docs_link_url }
+- docs_link_end = "</a>".html_safe
+
+-# Hiding behind a feature flag to avoid any changes to this feature's implemention
+-# when the :pages_auto_ssl feature flag is disabled. This check should be removed
+-# once the :pages_auto_ssl feature flag is removed.
+- if Feature.enabled?(:pages_auto_ssl)
+ %p= _("Learn more about adding certificates to your project by following the %{docs_link_start}documentation on GitLab Pages%{docs_link_end}.").html_safe % { docs_link_url: docs_link_url, docs_link_start: docs_link_start, docs_link_end: docs_link_end }
diff --git a/app/views/projects/pages_domains/edit.html.haml b/app/views/projects/pages_domains/edit.html.haml
index e11387ae742..7c0777e5496 100644
--- a/app/views/projects/pages_domains/edit.html.haml
+++ b/app/views/projects/pages_domains/edit.html.haml
@@ -3,6 +3,7 @@
- page_title @domain.domain
%h3.page-title
= @domain.domain
+= render 'projects/pages_domains/helper_text'
%hr.clearfix
%div
= form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'fieldset-form' } do |f|
diff --git a/app/views/projects/pages_domains/new.html.haml b/app/views/projects/pages_domains/new.html.haml
index c7cefa87c76..e23ccb5d4c6 100644
--- a/app/views/projects/pages_domains/new.html.haml
+++ b/app/views/projects/pages_domains/new.html.haml
@@ -2,6 +2,7 @@
- page_title _('New Pages Domain')
%h3.page-title
= _("New Pages Domain")
+= render 'projects/pages_domains/helper_text'
%hr.clearfix
%div
= form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'fieldset-form' } do |f|
diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml
index 1121cf06b5c..396e5da87bc 100644
--- a/app/views/projects/pipeline_schedules/_form.html.haml
+++ b/app/views/projects/pipeline_schedules/_form.html.haml
@@ -1,4 +1,4 @@
-= form_for [@project.namespace.becomes(Namespace), @project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "js-pipeline-schedule-form" } do |f|
+= form_for [@project.namespace.becomes(Namespace), @project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "js-pipeline-schedule-form pipeline-schedule-form" } do |f|
= form_errors(@schedule)
.form-group.row
.col-md-9
@@ -11,12 +11,12 @@
.form-group.row
.col-md-9
= f.label :cron_timezone, _('Cron Timezone'), class: 'label-bold'
- = dropdown_tag(_("Select a timezone"), options: { toggle_class: 'btn js-timezone-dropdown', title: _("Select a timezone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } )
+ = dropdown_tag(_("Select a timezone"), options: { toggle_class: 'btn js-timezone-dropdown w-100', dropdown_class: 'w-100', title: _("Select a timezone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } )
= f.text_field :cron_timezone, value: @schedule.cron_timezone, id: 'schedule_cron_timezone', class: 'hidden', name: 'schedule[cron_timezone]', required: true
.form-group.row
.col-md-9
= f.label :ref, _('Target Branch'), class: 'label-bold'
- = dropdown_tag(_("Select target branch"), options: { toggle_class: 'btn js-target-branch-dropdown', dropdown_class: 'git-revision-dropdown', title: _("Select target branch"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: @project.repository.branch_names, default_branch: @project.default_branch } } )
+ = dropdown_tag(_("Select target branch"), options: { toggle_class: 'btn js-target-branch-dropdown w-100', dropdown_class: 'git-revision-dropdown w-100', title: _("Select target branch"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: @project.repository.branch_names, default_branch: @project.default_branch } } )
= f.text_field :ref, value: @schedule.ref, id: 'schedule_ref', class: 'hidden', name: 'schedule[ref]', required: true
.form-group.row.js-ci-variable-list-section
.col-md-9
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index 9c2efd6aa35..5d307d6a70d 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -10,13 +10,7 @@
.icon-container
= icon('clock-o')
= pluralize @pipeline.total_size, "job"
- - if @pipeline.ref
- from
- - if @pipeline.ref_exists?
- = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name"
- - else
- %span.ref-name
- = @pipeline.ref
+ = @pipeline.ref_text
- if @pipeline.duration
in
= time_interval_in_words(@pipeline.duration)
@@ -48,9 +42,9 @@
content: "<a class='autodevops-link' href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>",
} }
Auto DevOps
- - if @pipeline.merge_request?
- %span.js-pipeline-url-mergerequest.badge.badge-info.has-tooltip{ title: "This pipeline is run in a merge request context" }
- merge request
+ - if @pipeline.detached_merge_request_pipeline?
+ %span.js-pipeline-url-mergerequest.badge.badge-info.has-tooltip{ title: "This pipeline is run on the source branch" }
+ detached
- if @pipeline.stuck?
%span.js-pipeline-url-stuck.badge.badge-warning
stuck
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index 66e202103a9..c04f076a3ab 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -2,15 +2,15 @@
%ul.pipelines-tabs.nav-links.no-top.no-bottom.mobile-separator.nav.nav-tabs
%li.js-pipeline-tab-link
= link_to project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-pipeline', action: 'pipelines', toggle: 'tab' }, class: 'pipeline-tab' do
- = _("Pipeline")
+ = _('Pipeline')
%li.js-builds-tab-link
= link_to builds_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do
- = _("Jobs")
+ = _('Jobs')
%span.badge.badge-pill.js-builds-counter= pipeline.total_size
- if @pipeline.failed_builds.present?
%li.js-failures-tab-link
= link_to failures_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do
- = _("Failed Jobs")
+ = _('Failed Jobs')
%span.badge.badge-pill.js-failures-counter= @pipeline.failed_builds.count
= render_if_exists "projects/pipelines/tabs_holder", pipeline: @pipeline, project: @project
@@ -24,41 +24,41 @@
%table.table.ci-table.pipeline
%thead
%tr
- %th Status
- %th Job ID
- %th Name
+ %th= _('Status')
+ %th= _('Job ID')
+ %th= _('Name')
%th
- %th Coverage
+ %th= _('Coverage')
%th
= render partial: "projects/stage/stage", collection: pipeline.legacy_stages, as: :stage
- elsif pipeline.project.builds_enabled? && !pipeline.ci_yaml_file
.bs-callout.bs-callout-warning
- \.gitlab-ci.yml not found in this commit
+ = _("%{gitlab_ci_yml} not found in this commit") % { gitlab_ci_yml: ".gitlab-ci.yml" }
- if @pipeline.failed_builds.present?
#js-tab-failures.build-failures.tab-pane.build-page
%table.table.responsive-table.ci-table.responsive-table-sm-rounded
%thead
%th.table-th-transparent
- %th.table-th-transparent= _("Name")
- %th.table-th-transparent= _("Stage")
- %th.table-th-transparent= _("Failure")
+ %th.table-th-transparent= _('Name')
+ %th.table-th-transparent= _('Stage')
+ %th.table-th-transparent= _('Failure')
%tbody
- @pipeline.failed_builds.each_with_index do |build, index|
- job = build.present(current_user: current_user)
%tr.build-state.responsive-table-border-start
- %td.responsive-table-cell.ci-status-icon-failed{ data: { column: "Status"} }
+ %td.responsive-table-cell.ci-status-icon-failed{ data: { column: _('Status')} }
.d-none.d-md-block.build-icon
= custom_icon("icon_status_#{build.status}")
.d-md-none.build-badge
= render "ci/status/badge", link: false, status: job.detailed_status(current_user)
- %td.responsive-table-cell.build-name{ data: { column: _("Name")} }
+ %td.responsive-table-cell.build-name{ data: { column: _('Name')} }
= link_to build.name, pipeline_job_url(pipeline, build)
- %td.responsive-table-cell.build-stage{ data: { column: _("Stage")} }
+ %td.responsive-table-cell.build-stage{ data: { column: _('Stage')} }
= build.stage.titleize
- %td.responsive-table-cell.build-failure{ data: { column: _("Failure")} }
+ %td.responsive-table-cell.build-failure{ data: { column: _('Failure')} }
= build.present.callout_failure_message
%td.responsive-table-cell.build-actions
- if can?(current_user, :update_build, job)
diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml
index ec17eddba79..4d1d078661d 100644
--- a/app/views/projects/pipelines/charts.html.haml
+++ b/app/views/projects/pipelines/charts.html.haml
@@ -1,11 +1,7 @@
- @no_container = true
-- page_title _("CI / CD Charts")
+- page_title _('CI / CD Charts')
%div{ class: container_class }
- .sub-header-block
- .oneline
- = _("A collection of graphs regarding Continuous Integration")
-
#charts.ci-charts
.row
.col-md-6
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index c0ee81fe28d..4e4638085fd 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -1,5 +1,7 @@
- @no_container = true
-- page_title "Pipelines"
+- page_title _('Pipelines')
+
+= render_if_exists "shared/shared_runners_minutes_limit_flash_message"
%div{ 'class' => container_class }
#pipelines-list-vue{ data: { endpoint: project_pipelines_path(@project, format: :json),
diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml
index f1cdc0a70dd..bfcaa09ae8c 100644
--- a/app/views/projects/pipelines/new.html.haml
+++ b/app/views/projects/pipelines/new.html.haml
@@ -1,16 +1,16 @@
-- breadcrumb_title "Pipelines"
-- page_title s_("Pipeline|Run Pipeline")
+- breadcrumb_title _('Pipelines')
+- page_title s_('Pipeline|Run Pipeline')
- settings_link = link_to _('CI/CD settings'), project_settings_ci_cd_path(@project)
%h3.page-title
- = s_("Pipeline|Run Pipeline")
+ = s_('Pipeline|Run Pipeline')
%hr
= form_for @pipeline, as: :pipeline, url: project_pipelines_path(@project), html: { id: "new-pipeline-form", class: "js-new-pipeline-form js-requires-input" } do |f|
= form_errors(@pipeline)
.form-group.row
.col-sm-12
- = f.label :ref, s_('Pipeline|Create for'), class: 'col-form-label'
+ = f.label :ref, s_('Pipeline|Run for'), class: 'col-form-label'
= hidden_field_tag 'pipeline[ref]', params[:ref] || @project.default_branch
= dropdown_tag(params[:ref] || @project.default_branch,
options: { toggle_class: 'js-branch-select wide monospace',
@@ -28,8 +28,8 @@
= (s_("Pipeline|Specify variable values to be used in this run. The values specified in %{settings_link} will be used by default.") % {settings_link: settings_link}).html_safe
.form-actions
- = f.submit s_('Pipeline|Create pipeline'), class: 'btn btn-success js-variables-save-button', tabindex: 3
- = link_to 'Cancel', project_pipelines_path(@project), class: 'btn btn-default float-right'
+ = f.submit s_('Pipeline|Run Pipeline'), class: 'btn btn-success js-variables-save-button', tabindex: 3
+ = link_to _('Cancel'), project_pipelines_path(@project), class: 'btn btn-default float-right'
-# haml-lint:disable InlineJavaScript
%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index 193d437dad1..8a6d7b082e3 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -1,7 +1,7 @@
- @no_container = true
-- add_to_breadcrumbs "Pipelines", project_pipelines_path(@project)
+- add_to_breadcrumbs _('Pipelines'), project_pipelines_path(@project)
- breadcrumb_title "##{@pipeline.id}"
-- page_title "Pipeline"
+- page_title _('Pipeline')
.js-pipeline-container{ class: container_class, data: { controller_action: "#{controller.action_name}" } }
#js-pipeline-header-vue.pipeline-header-container
@@ -11,11 +11,13 @@
- if @pipeline.builds.empty? && @pipeline.yaml_errors.present?
.bs-callout.bs-callout-danger
- %h4 Found errors in your .gitlab-ci.yml:
+ %h4= _('Found errors in your %{gitlab_ci_yml}:') % { gitlab_ci_yml: '.gitlab-ci.yml' }
%ul
- @pipeline.yaml_errors.split(",").each do |error|
%li= error
- You can test your .gitlab-ci.yml in #{link_to "CI Lint", project_ci_lint_path(@project)}.
+ - lint_link_url = project_ci_lint_path(@project)
+ - lint_link_start = '<a href="%{url}">'.html_safe % { url: lint_link_url }
+ = s_('You can also test your %{gitlab_ci_yml} in %{lint_link_start}CI Lint%{lint_link_end}').html_safe % { gitlab_ci_yml: '.gitlab-ci.yml', lint_link_start: lint_link_start, lint_link_end: '</a>'.html_safe }
- else
= render "projects/pipelines/with_tabs", pipeline: @pipeline
diff --git a/app/views/projects/project_members/_groups.html.haml b/app/views/projects/project_members/_groups.html.haml
index b5d397e3065..00321014f91 100644
--- a/app/views/projects/project_members/_groups.html.haml
+++ b/app/views/projects/project_members/_groups.html.haml
@@ -1,6 +1,6 @@
.card.project-members-groups
.card-header
- = _("Groups with access to <strong>%{project_name}</strong>").html_safe % { project_name: sanitize_project_name(@project.name) }
+ = _("Groups with access to <strong>%{project_name}</strong>").html_safe % { project_name: sanitize(@project.name, tags: []) }
%span.badge.badge-pill= group_links.size
%ul.content-list.members-list
= render partial: 'shared/members/group', collection: group_links, as: :group_link
diff --git a/app/views/projects/project_members/_new_project_member.html.haml b/app/views/projects/project_members/_new_project_member.html.haml
index 0590578c3fe..efabb7f7b19 100644
--- a/app/views/projects/project_members/_new_project_member.html.haml
+++ b/app/views/projects/project_members/_new_project_member.html.haml
@@ -19,4 +19,5 @@
= text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Expiration date'
%i.clear-icon.js-clear-input
= f.submit _("Add to project"), class: "btn btn-success qa-add-member-button"
- = link_to _("Import"), import_project_project_members_path(@project), class: "btn btn-default", title: _("Import members from another project")
+ - if can_import_members?
+ = link_to _("Import"), import_project_project_members_path(@project), class: "btn btn-default", title: _("Import members from another project")
diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml
index e0dd386fc5d..f220299ec30 100644
--- a/app/views/projects/project_members/_team.html.haml
+++ b/app/views/projects/project_members/_team.html.haml
@@ -4,7 +4,7 @@
.card
.card-header.flex-project-members-panel
%span.flex-project-title
- = _("Members of <strong>%{project_name}</strong>").html_safe % { project_name: sanitize_project_name(project.name) }
+ = _("Members of <strong>%{project_name}</strong>").html_safe % { project_name: sanitize(project.name, tags: []) }
%span.badge.badge-pill= members.total_count
= form_tag project_project_members_path(project), method: :get, class: 'form-inline member-search-form flex-project-members-form' do
.form-group
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
index 242ff91f539..cc98ba64f08 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -1,29 +1,35 @@
- page_title _("Members")
+- can_admin_project_members = can?(current_user, :admin_project_member, @project)
.row.prepend-top-default
.col-lg-12
- %h4
- = _("Project members")
- - if can?(current_user, :admin_project_member, @project)
- %p
- = _("You can invite a new member to <strong>%{project_name}</strong> or invite another group.").html_safe % { project_name: sanitize_project_name(@project.name) }
- - else
- %p
- = _("Members can be added by project <i>Maintainers</i> or <i>Owners</i>").html_safe
+ - if project_can_be_shared?
+ %h4
+ = _("Project members")
+ - if can_admin_project_members
+ %p= share_project_description(@project)
+ - else
+ %p
+ = _("Members can be added by project <i>Maintainers</i> or <i>Owners</i>").html_safe
+
.light
- - if can?(current_user, :admin_project_member, @project)
- %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' }
- %li.nav-tab{ role: 'presentation' }
- %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member")
- - if @project.allowed_to_share_with_group?
+ - if can_admin_project_members && project_can_be_shared?
+ - if !membership_locked? && @project.allowed_to_share_with_group?
+ %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' }
%li.nav-tab{ role: 'presentation' }
+ %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member")
+ %li.nav-tab{ role: 'presentation', class: ('active' if membership_locked?) }
%a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite group")
- .tab-content.gitlab-tab-content
- .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' }
- = render 'projects/project_members/new_project_member', tab_title: _('Invite member')
- .tab-pane{ id: 'invite-group-pane', role: 'tabpanel' }
- = render 'projects/project_members/new_project_group', tab_title: _('Invite group')
+ .tab-content.gitlab-tab-content
+ .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' }
+ = render 'projects/project_members/new_project_member', tab_title: _('Invite member')
+ .tab-pane{ id: 'invite-group-pane', role: 'tabpanel', class: ('active' if membership_locked?) }
+ = render 'projects/project_members/new_project_group', tab_title: _('Invite group')
+ - elsif !membership_locked?
+ .invite-member= render 'projects/project_members/new_project_member', tab_title: _('Invite member')
+ - elsif @project.allowed_to_share_with_group?
+ .invite-group= render 'projects/project_members/new_project_group', tab_title: _('Invite group')
= render 'shared/members/requests', membership_source: @project, requesters: @requesters
.clearfix
diff --git a/app/views/projects/protected_branches/_protected_branch.html.haml b/app/views/projects/protected_branches/_protected_branch.html.haml
index b12ae995ece..366d7a7a2eb 100644
--- a/app/views/projects/protected_branches/_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/_protected_branch.html.haml
@@ -1,2 +1,2 @@
= render layout: 'projects/protected_branches/shared/protected_branch', locals: { protected_branch: protected_branch } do
- = render partial: 'projects/protected_branches/update_protected_branch', locals: { protected_branch: protected_branch }
+ = render_if_exists 'projects/protected_branches/update_protected_branch', protected_branch: protected_branch
diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
index d617d85afc2..3644a623d2c 100644
--- a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
@@ -6,8 +6,8 @@
.card-body
= form_errors(@protected_branch)
.form-group.row
- = f.label :name, class: 'col-md-2 text-right' do
- Branch:
+ .col-md-2.text-right
+ = f.label :name, 'Branch:'
.col-md-10
= render partial: "projects/protected_branches/shared/dropdown", locals: { f: f }
.form-text.text-muted
diff --git a/app/views/projects/protected_branches/shared/_index.html.haml b/app/views/projects/protected_branches/shared/_index.html.haml
index 539b184e5c2..63748d8d85f 100644
--- a/app/views/projects/protected_branches/shared/_index.html.haml
+++ b/app/views/projects/protected_branches/shared/_index.html.haml
@@ -1,4 +1,4 @@
-- expanded = Rails.env.test?
+- expanded = expanded_by_default?
%section.qa-protected-branches-settings.settings.no-animate#js-protected-branches-settings{ class: ('expanded' if expanded) }
.settings-header
diff --git a/app/views/projects/protected_branches/shared/_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_protected_branch.html.haml
index bb7998f739d..81dcab1d1ab 100644
--- a/app/views/projects/protected_branches/shared/_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/shared/_protected_branch.html.haml
@@ -1,6 +1,6 @@
- can_admin_project = can?(current_user, :admin_project, @project)
-%tr.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) } }
+%tr.qa-protected-branch.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) } }
%td
%span.ref-name= protected_branch.name
diff --git a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
index cbf1938664c..020e6e187a6 100644
--- a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
@@ -6,8 +6,8 @@
.card-body
= form_errors(@protected_tag)
.form-group.row
- = f.label :name, class: 'col-md-2 text-right' do
- Tag:
+ .col-md-2.text-right
+ = f.label :name, 'Tag:'
.col-md-10.protected-tags-dropdown
= render partial: "projects/protected_tags/shared/dropdown", locals: { f: f }
.form-text.text-muted
diff --git a/app/views/projects/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml
index 9a50a51e4be..b0c87ac8c17 100644
--- a/app/views/projects/protected_tags/shared/_index.html.haml
+++ b/app/views/projects/protected_tags/shared/_index.html.haml
@@ -1,4 +1,4 @@
-- expanded = Rails.env.test?
+- expanded = expanded_by_default?
%section.settings.no-animate#js-protected-tags-settings{ class: ('expanded' if expanded) }
.settings-header
diff --git a/app/views/projects/registry/repositories/_tag.html.haml b/app/views/projects/registry/repositories/_tag.html.haml
index a4cde53e8c6..9594c9184a2 100644
--- a/app/views/projects/registry/repositories/_tag.html.haml
+++ b/app/views/projects/registry/repositories/_tag.html.haml
@@ -1,7 +1,7 @@
%tr.tag
%td
= escape_once(tag.name)
- = clipboard_button(text: "docker pull #{tag.location}")
+ = clipboard_button(text: "#{tag.location}")
%td
- if tag.revision
%span.has-tooltip{ title: "#{tag.revision}" }
diff --git a/app/views/projects/serverless/functions/index.html.haml b/app/views/projects/serverless/functions/index.html.haml
index 635580eac5c..9c69aedfbfc 100644
--- a/app/views/projects/serverless/functions/index.html.haml
+++ b/app/views/projects/serverless/functions/index.html.haml
@@ -5,7 +5,10 @@
- status_path = project_serverless_functions_path(@project, format: :json)
- clusters_path = project_clusters_path(@project)
-.serverless-functions-page.js-serverless-functions-page{ data: { status_path: status_path, installed: @installed, clusters_path: clusters_path, help_path: help_page_path('user/project/clusters/serverless/index') } }
+.serverless-functions-page.js-serverless-functions-page{ data: { status_path: status_path,
+ installed: @installed,
+ clusters_path: clusters_path,
+ help_path: help_page_path('user/project/clusters/serverless/index') } }
%div{ class: [container_class, ('limit-container-width' unless fluid_layout)] }
.js-serverless-functions-notice
diff --git a/app/views/projects/serverless/functions/show.html.haml b/app/views/projects/serverless/functions/show.html.haml
index 29737b7014a..d1fe208ce60 100644
--- a/app/views/projects/serverless/functions/show.html.haml
+++ b/app/views/projects/serverless/functions/show.html.haml
@@ -1,14 +1,19 @@
- @no_container = true
- @content_class = "limit-container-width" unless fluid_layout
+- clusters_path = project_clusters_path(@project)
+- help_path = help_page_path('user/project/clusters/serverless/index')
- add_to_breadcrumbs('Serverless', project_serverless_functions_path(@project))
- page_title @service[:name]
-.serverless-function-details-page.js-serverless-function-details-page{ data: { service: @service.as_json } }
+.serverless-function-details-page.js-serverless-function-details-page{ data: { service: @service.as_json,
+ prometheus: @prometheus,
+ clusters_path: clusters_path,
+ help_path: help_path } }
+
%div{ class: [container_class, ('limit-container-width' unless fluid_layout)] }
- .top-area.adjust
- .serverless-function-details#js-serverless-function-details
+ .serverless-function-details#js-serverless-function-details
.js-serverless-function-notice
.flash-container
diff --git a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml
index 9409418bbcc..82c1d57c97e 100644
--- a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml
+++ b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml
@@ -18,22 +18,22 @@
.help-form
.form-group
- = label_tag :display_name, 'Display name', class: 'col-sm-2 col-12 col-form-label'
- .col-sm-10.col-12.input-group
+ = label_tag :display_name, 'Display name', class: 'col-12 col-form-label label-bold'
+ .col-12.input-group
= text_field_tag :display_name, "GitLab / #{@project.full_name}", class: 'form-control form-control-sm', readonly: 'readonly'
.input-group-append
= clipboard_button(target: '#display_name', class: 'input-group-text')
.form-group
- = label_tag :description, 'Description', class: 'col-sm-2 col-12 col-form-label'
- .col-sm-10.col-12.input-group
+ = label_tag :description, 'Description', class: 'col-12 col-form-label label-bold'
+ .col-12.input-group
= text_field_tag :description, run_actions_text, class: 'form-control form-control-sm', readonly: 'readonly'
.input-group-append
= clipboard_button(target: '#description', class: 'input-group-text')
.form-group
- = label_tag nil, 'Command trigger word', class: 'col-sm-2 col-12 col-form-label'
- .col-sm-10.col-12.text-block
+ = label_tag nil, 'Command trigger word', class: 'col-12 col-form-label label-bold'
+ .col-12
%p Fill in the word that works best for your team.
%p
Suggestions:
@@ -42,44 +42,44 @@
%code= @project.full_path
.form-group
- = label_tag :request_url, 'Request URL', class: 'col-sm-2 col-12 col-form-label'
- .col-sm-10.col-12.input-group
+ = label_tag :request_url, 'Request URL', class: 'col-12 col-form-label label-bold'
+ .col-12.input-group
= text_field_tag :request_url, service_trigger_url(subject), class: 'form-control form-control-sm', readonly: 'readonly'
.input-group-append
= clipboard_button(target: '#request_url', class: 'input-group-text')
.form-group
- = label_tag nil, 'Request method', class: 'col-sm-2 col-12 col-form-label'
- .col-sm-10.col-12.text-block POST
+ = label_tag nil, 'Request method', class: 'col-12 col-form-label label-bold'
+ .col-12 POST
.form-group
- = label_tag :response_username, 'Response username', class: 'col-sm-2 col-12 col-form-label'
- .col-sm-10.col-12.input-group
+ = label_tag :response_username, 'Response username', class: 'col-12 col-form-label label-bold'
+ .col-12.input-group
= text_field_tag :response_username, 'GitLab', class: 'form-control form-control-sm', readonly: 'readonly'
.input-group-append
= clipboard_button(target: '#response_username', class: 'input-group-text')
.form-group
- = label_tag :response_icon, 'Response icon', class: 'col-sm-2 col-12 col-form-label'
- .col-sm-10.col-12.input-group
+ = label_tag :response_icon, 'Response icon', class: 'col-12 col-form-label label-bold'
+ .col-12.input-group
= text_field_tag :response_icon, asset_url('gitlab_logo.png'), class: 'form-control form-control-sm', readonly: 'readonly'
.input-group-append
= clipboard_button(target: '#response_icon', class: 'input-group-text')
.form-group
- = label_tag nil, 'Autocomplete', class: 'col-sm-2 col-12 col-form-label'
- .col-sm-10.col-12.text-block Yes
+ = label_tag nil, 'Autocomplete', class: 'col-12 col-form-label label-bold'
+ .col-12 Yes
.form-group
- = label_tag :autocomplete_hint, 'Autocomplete hint', class: 'col-sm-2 col-12 col-form-label'
- .col-sm-10.col-12.input-group
+ = label_tag :autocomplete_hint, 'Autocomplete hint', class: 'col-12 col-12 col-form-label label-bold'
+ .col-12.input-group
= text_field_tag :autocomplete_hint, '[help]', class: 'form-control form-control-sm', readonly: 'readonly'
.input-group-append
= clipboard_button(target: '#autocomplete_hint', class: 'input-group-text')
.form-group
- = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-sm-2 col-12 col-form-label'
- .col-sm-10.col-12.input-group
+ = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-12 col-form-label label-bold'
+ .col-12.input-group
= text_field_tag :autocomplete_description, run_actions_text, class: 'form-control form-control-sm', readonly: 'readonly'
.input-group-append
= clipboard_button(target: '#autocomplete_description', class: 'input-group-text')
diff --git a/app/views/projects/services/slack_slash_commands/_help.html.haml b/app/views/projects/services/slack_slash_commands/_help.html.haml
index 9a7004f89c0..9b7732abc62 100644
--- a/app/views/projects/services/slack_slash_commands/_help.html.haml
+++ b/app/views/projects/services/slack_slash_commands/_help.html.haml
@@ -27,8 +27,8 @@
.help-form
.form-group
- = label_tag nil, 'Command', class: 'col-sm-2 col-12 col-form-label'
- .col-sm-10.col-12.text-block
+ = label_tag nil, 'Command', class: 'col-12 col-form-label label-bold'
+ .col-12
%p Fill in the word that works best for your team.
%p
Suggestions:
@@ -37,50 +37,50 @@
%code= @project.full_path
.form-group
- = label_tag :url, 'URL', class: 'col-sm-2 col-12 col-form-label'
- .col-sm-10.col-12.input-group
+ = label_tag :url, 'URL', class: 'col-12 col-form-label label-bold'
+ .col-12.input-group
= text_field_tag :url, service_trigger_url(subject), class: 'form-control form-control-sm', readonly: 'readonly'
.input-group-append
= clipboard_button(target: '#url', class: 'input-group-text')
.form-group
- = label_tag nil, 'Method', class: 'col-sm-2 col-12 col-form-label'
- .col-sm-10.col-12.text-block POST
+ = label_tag nil, 'Method', class: 'col-12 col-form-label label-bold'
+ .col-12 POST
.form-group
- = label_tag :customize_name, 'Customize name', class: 'col-sm-2 col-12 col-form-label'
- .col-sm-10.col-12.input-group
+ = label_tag :customize_name, 'Customize name', class: 'col-12 col-form-label label-bold'
+ .col-12.input-group
= text_field_tag :customize_name, 'GitLab', class: 'form-control form-control-sm', readonly: 'readonly'
.input-group-append
= clipboard_button(target: '#customize_name', class: 'input-group-text')
.form-group
- = label_tag nil, 'Customize icon', class: 'col-sm-2 col-12 col-form-label'
- .col-sm-10.col-12.text-block
- = image_tag(asset_url('slash-command-logo.png'), width: 36, height: 36)
+ = label_tag nil, 'Customize icon', class: 'col-12 col-form-label label-bold'
+ .col-12
+ = image_tag(asset_url('slash-command-logo.png'), width: 36, height: 36, class: 'mr-3')
= link_to('Download image', asset_url('gitlab_logo.png'), class: 'btn btn-sm', target: '_blank', rel: 'noopener noreferrer')
.form-group
- = label_tag nil, 'Autocomplete', class: 'col-sm-2 col-12 col-form-label'
- .col-sm-10.col-12.text-block Show this command in the autocomplete list
+ = label_tag nil, 'Autocomplete', class: 'col-12 col-form-label label-bold'
+ .col-12 Show this command in the autocomplete list
.form-group
- = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-sm-2 col-12 col-form-label'
- .col-sm-10.col-12.input-group
+ = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-12 col-form-label label-bold'
+ .col-12.input-group
= text_field_tag :autocomplete_description, run_actions_text, class: 'form-control form-control-sm', readonly: 'readonly'
.input-group-append
= clipboard_button(target: '#autocomplete_description', class: 'input-group-text')
.form-group
- = label_tag :autocomplete_usage_hint, 'Autocomplete usage hint', class: 'col-sm-2 col-12 col-form-label'
- .col-sm-10.col-12.input-group
+ = label_tag :autocomplete_usage_hint, 'Autocomplete usage hint', class: 'col-12 col-form-label label-bold'
+ .col-12.input-group
= text_field_tag :autocomplete_usage_hint, '[help]', class: 'form-control form-control-sm', readonly: 'readonly'
.input-group-append
= clipboard_button(target: '#autocomplete_usage_hint', class: 'input-group-text')
.form-group
- = label_tag :descriptive_label, 'Descriptive label', class: 'col-sm-2 col-12 col-form-label'
- .col-sm-10.col-12.input-group
+ = label_tag :descriptive_label, 'Descriptive label', class: 'col-12 col-form-label label-bold'
+ .col-12.input-group
= text_field_tag :descriptive_label, 'Perform common operations on GitLab project', class: 'form-control form-control-sm', readonly: 'readonly'
.input-group-append
= clipboard_button(target: '#descriptive_label', class: 'input-group-text')
diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml
new file mode 100644
index 00000000000..520f342f567
--- /dev/null
+++ b/app/views/projects/settings/_general.html.haml
@@ -0,0 +1,42 @@
+= form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "edit-project js-general-settings-form" }, authenticity_token: true do |f|
+ %input{ name: 'update_section', type: 'hidden', value: 'js-general-settings' }
+ = form_errors(@project)
+
+ %fieldset
+ .row
+ .form-group.col-md-5
+ = f.label :name, class: 'label-bold', for: 'project_name_edit' do
+ = _('Project name')
+ = f.text_field :name, class: 'form-control qa-project-name-field', id: "project_name_edit"
+
+ .form-group.col-md-7
+ = f.label :id, class: 'label-bold' do
+ = _('Project ID')
+ = f.text_field :id, class: 'form-control w-auto', readonly: true
+
+ .row
+ .form-group.col-md-9
+ = f.label :tag_list, _('Topics'), class: 'label-bold'
+ = f.text_field :tag_list, value: @project.tag_list.join(', '), maxlength: 2000, class: "form-control"
+ %p.form-text.text-muted= _('Separate topics with commas.')
+
+ .row
+ .form-group.col-md-9
+ = f.label :description, _('Project description (optional)'), class: 'label-bold'
+ = f.text_area :description, class: 'form-control', rows: 3, maxlength: 250
+
+ .row= render_if_exists 'projects/classification_policy_settings', f: f
+
+ = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :project
+
+ .form-group.prepend-top-default.append-bottom-20
+ .avatar-container.s90
+ = project_icon(@project, alt: _('Project avatar'), class: 'avatar project-avatar s90')
+ = f.label :avatar, _('Project avatar'), class: 'label-bold d-block'
+ = render 'shared/choose_avatar_button', f: f
+ - if @project.avatar?
+ %hr
+ = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link'
+
+
+ = f.submit _('Save changes'), class: "btn btn-success mt-4 qa-save-naming-topics-avatar-button"
diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
index 8c4d1c32ebe..fe74dc122c3 100644
--- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
@@ -8,19 +8,20 @@
.card.auto-devops-card
.card-body
.form-check
- = form.check_box :enabled, class: 'form-check-input js-toggle-extra-settings', checked: @project.auto_devops_enabled?
+ = form.check_box :enabled, class: 'form-check-input js-toggle-extra-settings', checked: auto_devops_enabled
= form.label :enabled, class: 'form-check-label' do
%strong= s_('CICD|Default to Auto DevOps pipeline')
- - if @project.has_auto_devops_implicitly_enabled?
- %span.badge.badge-info.js-instance-default-badge= s_('CICD|instance enabled')
+ - if auto_devops_enabled
+ %span.badge.badge-info.js-instance-default-badge= badge_for_auto_devops_scope(@project)
.form-text.text-muted
= s_('CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found.')
= link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank'
- .card-footer.js-extra-settings{ class: @project.auto_devops_enabled? || 'hidden' }
- %p.settings-message.text-center
- - kubernetes_cluster_link = help_page_path('user/project/clusters/index')
- - kubernetes_cluster_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: kubernetes_cluster_link }
- = s_('CICD|You must add a %{kubernetes_cluster_start}Kubernetes cluster integration%{kubernetes_cluster_end} to this project with a domain in order for your deployment strategy to work correctly.').html_safe % { kubernetes_cluster_start: kubernetes_cluster_start, kubernetes_cluster_end: '</a>'.html_safe }
+ .card-footer.js-extra-settings{ class: auto_devops_enabled || 'hidden' }
+ - if @project.all_clusters.empty?
+ %p.settings-message.text-center
+ - kubernetes_cluster_link = help_page_path('user/project/clusters/index')
+ - kubernetes_cluster_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: kubernetes_cluster_link }
+ = s_('CICD|You must add a %{kubernetes_cluster_start}Kubernetes cluster integration%{kubernetes_cluster_end} to this project with a domain in order for your deployment strategy to work correctly.').html_safe % { kubernetes_cluster_start: kubernetes_cluster_start, kubernetes_cluster_end: '</a>'.html_safe }
%label.prepend-top-10
%strong= s_('CICD|Deployment strategy')
.form-check
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index bfb275b9ef5..2d108a1cba5 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -26,6 +26,14 @@
%hr
.form-group
+ = f.fields_for :ci_cd_settings_attributes, @project.ci_cd_settings do |form|
+ = form.label :default_git_depth, _('Git shallow clone'), class: 'label-bold'
+ = form.number_field :default_git_depth, { class: 'form-control', min: 0, max: 1000 }
+ %p.form-text.text-muted
+ = _('The number of changes to be fetched from GitLab when cloning a repository. This can speed up Pipelines execution. Keep empty or set to 0 to disable shallow clone by default and make GitLab CI fetch all branches and tags each time.')
+
+ %hr
+ .form-group
= f.label :build_timeout_human_readable, _('Timeout'), class: 'label-bold'
= f.text_field :build_timeout_human_readable, class: 'form-control'
%p.form-text.text-muted
@@ -102,17 +110,20 @@
tap --coverage-report=text-summary (NodeJS) -
%code ^Statements\s*:\s*([^%]+)
%li
+ nyc npm test (NodeJS) -
+ %code All files[^|]*\|[^|]*\s+([\d\.]+)
+ %li
excoveralls (Elixir) -
%code \[TOTAL\]\s+(\d+\.\d+)%
%li
+ mix test --cover (Elixir) -
+ %code \d+.\d+\%\s+\|\s+Total
+ %li
JaCoCo (Java/Kotlin)
%code Total.*?([0-9]{1,3})%
%li
go test -cover (Go)
%code coverage: \d+.\d+% of statements
- %li
- nyc npm test (NodeJS) -
- %code All files[^|]*\|[^|]*\s+([\d\.]+)
= f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index 6966bf96724..5e3e1076c2c 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -2,7 +2,7 @@
- page_title _("CI / CD Settings")
- page_title _("CI / CD")
-- expanded = Rails.env.test?
+- expanded = expanded_by_default?
- general_expanded = @project.errors.empty? ? expanded : true
%section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if general_expanded) }
@@ -26,7 +26,7 @@
= s_('CICD|Auto DevOps will automatically build, test, and deploy your application based on a predefined Continuous Integration and Delivery configuration.')
= link_to s_('CICD|Learn more about Auto DevOps'), help_page_path('topics/autodevops/index.md')
.settings-content
- = render 'autodevops_form'
+ = render 'autodevops_form', auto_devops_enabled: @project.auto_devops_enabled?
= render_if_exists 'projects/settings/ci_cd/protected_environments', expanded: expanded
diff --git a/app/views/projects/settings/operations/_error_tracking.html.haml b/app/views/projects/settings/operations/_error_tracking.html.haml
index 4911e8d3770..583fc08f375 100644
--- a/app/views/projects/settings/operations/_error_tracking.html.haml
+++ b/app/views/projects/settings/operations/_error_tracking.html.haml
@@ -2,29 +2,19 @@
- setting = error_tracking_setting
-%section.settings.expanded.border-0.no-animate
+%section.settings.no-animate.js-error-tracking-settings
.settings-header
%h4
= _('Error Tracking')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = _('Expand')
%p
= _('To link Sentry to GitLab, enter your Sentry URL and Auth Token.')
+ = link_to _('More information'), help_page_path('user/project/operations/error_tracking'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
- = form_for @project, url: project_settings_operations_path(@project), method: :patch do |f|
- = form_errors(@project)
- .form-group
- = f.fields_for :error_tracking_setting_attributes, setting do |form|
- .form-check.form-group
- = form.check_box :enabled, class: 'form-check-input'
- = form.label :enabled, _('Active'), class: 'form-check-label'
- .form-group
- = form.label :api_url, _('Sentry API URL'), class: 'label-bold'
- = form.url_field :api_url, class: 'form-control', placeholder: _('http://<sentry-host>/api/0/projects/{organization_slug}/{project_slug}/')
- %p.form-text.text-muted
- = _('Enter your Sentry API URL')
- .form-group
- = form.label :token, _('Auth Token'), class: 'label-bold'
- = form.text_field :token, class: 'form-control'
- %p.form-text.text-muted
- = _('Find and manage Auth Tokens in your Sentry account settings page.')
-
- = f.submit _('Save changes'), class: 'btn btn-success'
+ .js-error-tracking-form{ data: { list_projects_endpoint: list_projects_project_error_tracking_index_path(@project, format: :json),
+ operations_settings_endpoint: project_settings_operations_path(@project),
+ project: error_tracking_setting_project_json,
+ api_host: setting.api_host,
+ enabled: setting.enabled.to_json,
+ token: setting.token } }
diff --git a/app/views/projects/settings/operations/_external_dashboard.html.haml b/app/views/projects/settings/operations/_external_dashboard.html.haml
new file mode 100644
index 00000000000..a124283921d
--- /dev/null
+++ b/app/views/projects/settings/operations/_external_dashboard.html.haml
@@ -0,0 +1,3 @@
+.js-operation-settings{ data: { operations_settings_endpoint: project_settings_operations_path(@project),
+ external_dashboard: { url: metrics_external_dashboard_url,
+ help_page_path: help_page_path('user/project/operations/link_to_external_dashboard') } } }
diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml
index b36fa9a5f51..0a7a155bc12 100644
--- a/app/views/projects/settings/operations/show.html.haml
+++ b/app/views/projects/settings/operations/show.html.haml
@@ -1,5 +1,8 @@
- @content_class = 'limit-container-width' unless fluid_layout
-- page_title _('Operations')
+- page_title _('Operations Settings')
+- breadcrumb_title _('Operations Settings')
-= render 'projects/settings/operations/error_tracking', expanded: true
+= render_if_exists 'projects/settings/operations/incidents'
+= render 'projects/settings/operations/error_tracking'
+= render 'projects/settings/operations/external_dashboard'
= render_if_exists 'projects/settings/operations/tracing'
diff --git a/app/views/projects/settings/repository/_protected_branches.html.haml b/app/views/projects/settings/repository/_protected_branches.html.haml
new file mode 100644
index 00000000000..31630828571
--- /dev/null
+++ b/app/views/projects/settings/repository/_protected_branches.html.haml
@@ -0,0 +1,2 @@
+= render "projects/protected_branches/index"
+= render "projects/protected_tags/index"
diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml
index cb3a035c49e..ff30cc4f6db 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -3,14 +3,17 @@
- @content_class = "limit-container-width" unless fluid_layout
= render "projects/default_branch/show"
+= render_if_exists "projects/push_rules/index"
= render "projects/mirrors/mirror_repos"
-# Protected branches & tags use a lot of nested partials.
-# The shared parts of the views can be found in the `shared` directory.
-# Those are used throughout the actual views. These `shared` views are then
-# reused in EE.
-= render "projects/protected_branches/index"
-= render "projects/protected_tags/index"
+= render "projects/settings/repository/protected_branches"
+
= render @deploy_keys
= render "projects/deploy_tokens/index"
= render "projects/cleanup/show"
+
+= render_if_exists 'shared/promotions/promote_repository_features'
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index cc203cfad86..8bfface3f5a 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -20,9 +20,8 @@
%p
= s_("TagsPage|Can't find HEAD commit for this tag")
- if release && release.description.present?
- .description.prepend-top-default
- .wiki
- = markdown_field(release, :description)
+ .description.md.prepend-top-default
+ = markdown_field(release, :description)
.row-fixed-content.controls.flex-row
= render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name]
diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml
index 458096f9dd6..2e78b0bff3e 100644
--- a/app/views/projects/tags/index.html.haml
+++ b/app/views/projects/tags/index.html.haml
@@ -9,7 +9,7 @@
.nav-text.row-main-content
= s_('TagsPage|Tags give the ability to mark specific points in history as being important')
- .nav-controls.row-fixed-content
+ .nav-controls
= form_tag(filter_tags_path, method: :get) do
= search_field_tag :search, params[:search], { placeholder: s_('TagsPage|Filter by tag name'), id: 'tag-search', class: 'form-control search-text-input input-short', spellcheck: false }
diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml
index feeaf799f51..59232372150 100644
--- a/app/views/projects/tags/show.html.haml
+++ b/app/views/projects/tags/show.html.haml
@@ -18,12 +18,12 @@
- else
= s_("TagsPage|Can't find HEAD commit for this tag")
- .nav-controls.controls-flex
+ .nav-controls
- if can?(current_user, :push_code, @project)
= link_to edit_project_tag_release_path(@project, @tag.name), class: 'btn btn-edit controls-item has-tooltip', title: s_('TagsPage|Edit release notes') do
= icon("pencil")
= link_to project_tree_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: s_('TagsPage|Browse files') do
- = icon('files-o')
+ = sprite_icon('folder-open')
= link_to project_commits_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: s_('TagsPage|Browse commits') do
= icon('history')
.btn-container.controls-item
@@ -39,8 +39,7 @@
.append-bottom-default.prepend-top-default
- if @release.description.present?
- .description
- .wiki
- = markdown_field(@release, :description)
+ .description.md
+ = markdown_field(@release, :description)
- else
= s_('TagsPage|This tag has no release notes.')
diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml
index 4daacbe157c..4f6c7e1f9a6 100644
--- a/app/views/projects/tree/_readme.html.haml
+++ b/app/views/projects/tree/_readme.html.haml
@@ -1,5 +1,5 @@
- if readme.rich_viewer
- %article.file-holder.readme-holder{ id: 'readme', class: ("limited-width-container" unless fluid_layout) }
+ %article.file-holder.readme-holder{ id: 'readme', class: [("limited-width-container" unless fluid_layout), ("js-hide-on-navigation" 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
diff --git a/app/views/projects/tree/_tree_commit_column.html.haml b/app/views/projects/tree/_tree_commit_column.html.haml
index e37fd7624be..065fef606d5 100644
--- a/app/views/projects/tree/_tree_commit_column.html.haml
+++ b/app/views/projects/tree/_tree_commit_column.html.haml
@@ -1,2 +1,3 @@
+- full_title = markdown_field(commit, :full_title)
%span.str-truncated
- = link_to_html commit.redacted_full_title_html, project_commit_path(@project, commit.id), title: commit.redacted_full_title_html, class: 'tree-commit-link'
+ = link_to_html full_title, project_commit_path(@project, commit.id), title: full_title, class: 'tree-commit-link'
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index ec8e5234bd4..ea6349f2f57 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -6,71 +6,74 @@
= render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true
- if on_top_of_branch?
- - addtotree_toggle_attributes = { href: '#', 'data-toggle': 'dropdown', 'data-target': '.add-to-tree-dropdown', 'data-boundary': 'window' }
+ - addtotree_toggle_attributes = { 'data-toggle': 'dropdown', 'data-target': '.add-to-tree-dropdown', 'data-boundary': 'window' }
- else
- addtotree_toggle_attributes = { title: _("You can only add files when you are on a branch"), data: { container: 'body' }, class: 'disabled has-tooltip' }
- %ul.breadcrumb.repo-breadcrumb
- %li.breadcrumb-item
- = link_to project_tree_path(@project, @ref) do
- = @project.path
- - path_breadcrumbs do |title, path|
+ - if vue_file_list_enabled?
+ #js-repo-breadcrumb
+ - else
+ %ul.breadcrumb.repo-breadcrumb
%li.breadcrumb-item
- = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path))
+ = link_to project_tree_path(@project, @ref) do
+ = @project.path
+ - path_breadcrumbs do |title, path|
+ %li.breadcrumb-item
+ = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path))
- - if can_collaborate || can_create_mr_from_fork
- %li.breadcrumb-item
- %a.btn.add-to-tree.qa-add-to-tree{ addtotree_toggle_attributes }
- = sprite_icon('plus', size: 16, css_class: 'float-left')
- = sprite_icon('arrow-down', size: 16, css_class: 'float-left')
- - if on_top_of_branch?
- .add-to-tree-dropdown
- %ul.dropdown-menu
- - if can_edit_tree?
- %li.dropdown-header
- #{ _('This directory') }
- %li
- = link_to project_new_blob_path(@project, @id), class: 'qa-new-file-option' do
- #{ _('New file') }
- %li
- = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do
- #{ _('Upload file') }
- %li
- = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do
- #{ _('New directory') }
- - elsif can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project)
- %li
- - continue_params = { to: project_new_blob_path(@project, @id),
- notice: edit_in_new_fork_notice,
- notice_now: edit_in_new_fork_notice_now }
- - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
- = link_to fork_path, method: :post do
- #{ _('New file') }
- %li
- - continue_params = { to: request.fullpath,
- notice: edit_in_new_fork_notice + " Try to upload a file again.",
- notice_now: edit_in_new_fork_notice_now }
- - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
- = link_to fork_path, method: :post do
- #{ _('Upload file') }
- %li
- - continue_params = { to: request.fullpath,
- notice: edit_in_new_fork_notice + " Try to create a new directory again.",
- notice_now: edit_in_new_fork_notice_now }
- - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
- = link_to fork_path, method: :post do
- #{ _('New directory') }
+ - if can_collaborate || can_create_mr_from_fork
+ %li.breadcrumb-item
+ %button.btn.add-to-tree.qa-add-to-tree{ addtotree_toggle_attributes, type: 'button' }
+ = sprite_icon('plus', size: 16, css_class: 'float-left')
+ = sprite_icon('arrow-down', size: 16, css_class: 'float-left')
+ - if on_top_of_branch?
+ .add-to-tree-dropdown
+ %ul.dropdown-menu
+ - if can_edit_tree?
+ %li.dropdown-header
+ #{ _('This directory') }
+ %li
+ = link_to project_new_blob_path(@project, @id), class: 'qa-new-file-option' do
+ #{ _('New file') }
+ %li
+ = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do
+ #{ _('Upload file') }
+ %li
+ = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do
+ #{ _('New directory') }
+ - elsif can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project)
+ %li
+ - continue_params = { to: project_new_blob_path(@project, @id),
+ notice: edit_in_new_fork_notice,
+ notice_now: edit_in_new_fork_notice_now }
+ - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
+ = link_to fork_path, method: :post do
+ #{ _('New file') }
+ %li
+ - continue_params = { to: request.fullpath,
+ notice: edit_in_new_fork_notice + " Try to upload a file again.",
+ notice_now: edit_in_new_fork_notice_now }
+ - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
+ = link_to fork_path, method: :post do
+ #{ _('Upload file') }
+ %li
+ - continue_params = { to: request.fullpath,
+ notice: edit_in_new_fork_notice + " Try to create a new directory again.",
+ notice_now: edit_in_new_fork_notice_now }
+ - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
+ = link_to fork_path, method: :post do
+ #{ _('New directory') }
- - if can?(current_user, :push_code, @project)
- %li.divider
- %li.dropdown-header
- #{ _('This repository') }
- %li
- = link_to new_project_branch_path(@project) do
- #{ _('New branch') }
- %li
- = link_to new_project_tag_path(@project) do
- #{ _('New tag') }
+ - if can?(current_user, :push_code, @project)
+ %li.divider
+ %li.dropdown-header
+ #{ _('This repository') }
+ %li
+ = link_to new_project_branch_path(@project) do
+ #{ _('New branch') }
+ %li
+ = link_to new_project_tag_path(@project) do
+ #{ _('New tag') }
.tree-controls
= link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn'
diff --git a/app/views/projects/wikis/pages.html.haml b/app/views/projects/wikis/pages.html.haml
index 94267b6e0cf..77fdf7f001c 100644
--- a/app/views/projects/wikis/pages.html.haml
+++ b/app/views/projects/wikis/pages.html.haml
@@ -2,6 +2,7 @@
- add_to_breadcrumbs "Wiki", project_wiki_path(@project, :home)
- breadcrumb_title s_("Wiki|Pages")
- page_title s_("Wiki|Pages"), _("Wiki")
+- sort_title = wiki_sort_title(params[:sort])
%div{ class: container_class }
.wiki-page-header
@@ -15,6 +16,18 @@
= icon('cloud-download')
= _("Clone repository")
+ .dropdown.inline.wiki-sort-dropdown
+ .btn-group{ role: 'group' }
+ .btn-group{ role: 'group' }
+ %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' }
+ = sort_title
+ = icon('chevron-down')
+ %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
+ %li
+ = sortable_item(s_("Wiki|Title"), project_wikis_pages_path(@project, sort: ProjectWiki::TITLE_ORDER), sort_title)
+ = sortable_item(s_("Wiki|Created date"), project_wikis_pages_path(@project, sort: ProjectWiki::CREATED_AT_ORDER), sort_title)
+ = wiki_sort_controls(@project, params[:sort], params[:direction])
+
%ul.wiki-pages-list.content-list
= render @wiki_entries, context: 'pages'
diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml
index 8e1c054b50c..40d674f3fec 100644
--- a/app/views/projects/wikis/show.html.haml
+++ b/app/views/projects/wikis/show.html.haml
@@ -26,7 +26,7 @@
= (s_("WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}.") % { most_recent_link: most_recent_link, history_link: history_link }).html_safe
.prepend-top-default.append-bottom-default
- .wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
+ .md.md-file{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= render_wiki_content(@page)
= render 'sidebar'
diff --git a/app/views/repository_check_mailer/notify.html.haml b/app/views/repository_check_mailer/notify.html.haml
index d5327a2b4cc..dfcd1c6b19f 100644
--- a/app/views/repository_check_mailer/notify.html.haml
+++ b/app/views/repository_check_mailer/notify.html.haml
@@ -6,3 +6,5 @@
%p
= _("You are receiving this message because you are a GitLab administrator for %{url}.") % { url: Gitlab.config.gitlab.url }
+
+= render_if_exists 'repository_check_mailer/email_additional_text'
diff --git a/app/views/repository_check_mailer/notify.text.haml b/app/views/repository_check_mailer/notify.text.haml
index 6b64b337b0e..a2e04fa710f 100644
--- a/app/views/repository_check_mailer/notify.text.haml
+++ b/app/views/repository_check_mailer/notify.text.haml
@@ -3,3 +3,5 @@
= _("View details: %{details_url}") % { details_url: admin_projects_url(last_repository_check_failed: 1) }
= _("You are receiving this message because you are a GitLab administrator for %{url}.") % { url: Gitlab.config.gitlab.url }
+
+= render_if_exists 'repository_check_mailer/email_additional_text'
diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml
index aaf9b973cda..df408e5fb60 100644
--- a/app/views/search/_category.html.haml
+++ b/app/views/search/_category.html.haml
@@ -1,3 +1,11 @@
+- users = capture_haml do
+ - if search_tabs?(:members)
+ %li{ class: active_when(@scope == 'users') }
+ = link_to search_filter_path(scope: 'users') do
+ Users
+ %span.badge.badge-pill
+ = limited_count(@search_results.limited_users_count)
+
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
@@ -45,6 +53,7 @@
= _("Commits")
%span.badge.badge-pill
= @search_results.commits_count
+ = users
- elsif @show_snippets
%li{ class: active_when(@scope == 'snippet_blobs') }
@@ -78,3 +87,4 @@
= _("Milestones")
%span.badge.badge-pill
= limited_count(@search_results.limited_milestones_count)
+ = users
diff --git a/app/views/search/_form.html.haml b/app/views/search/_form.html.haml
index 4af0c6bf84a..db0dcc8adfb 100644
--- a/app/views/search/_form.html.haml
+++ b/app/views/search/_form.html.haml
@@ -13,3 +13,4 @@
- unless params[:snippets].eql? 'true'
= render 'filter'
= button_tag _("Search"), class: "btn btn-success btn-search"
+ = render_if_exists 'search/form_elasticsearch'
diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml
index be7a2436d16..12eb8d7fa81 100644
--- a/app/views/search/_results.html.haml
+++ b/app/views/search/_results.html.haml
@@ -1,5 +1,6 @@
- if @search_objects.to_a.empty?
= render partial: "search/results/empty"
+ = render_if_exists 'shared/promotions/promote_advanced_search'
- else
.row-content-block
- unless @search_objects.is_a?(Kaminari::PaginatableWithoutCount)
@@ -11,7 +12,7 @@
- elsif @group
- link_to_group = link_to(@group.name, @group)
= _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group }
-
+ = render_if_exists 'shared/promotions/promote_advanced_search'
.results.prepend-top-10
- if @scope == 'commits'
%ul.content-list.commit-list
@@ -20,9 +21,10 @@
.search-results
- if @scope == 'projects'
.term
- = render 'shared/projects/list', projects: @search_objects
+ = render 'shared/projects/list', { projects: @search_objects, pipeline_status: false }.merge(@display_options)
- else
- = render partial: "search/results/#{@scope.singularize}", collection: @search_objects
+ - locals = { projects: blob_projects(@search_objects) } if %w[blobs wiki_blobs].include?(@scope)
+ = render partial: "search/results/#{@scope.singularize}", collection: @search_objects, locals: locals
- if @scope != 'projects'
= paginate_collection(@search_objects)
diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml
index 2a602095845..bdad07f36d1 100644
--- a/app/views/search/results/_blob.html.haml
+++ b/app/views/search/results/_blob.html.haml
@@ -1,4 +1,4 @@
-- project = find_project_for_result_blob(blob)
+- project = find_project_for_result_blob(projects, blob)
- return unless project
- blob = parse_search_result(blob)
diff --git a/app/views/search/results/_issue.html.haml b/app/views/search/results/_issue.html.haml
index 796782035f2..1f055cdfa31 100644
--- a/app/views/search/results/_issue.html.haml
+++ b/app/views/search/results/_issue.html.haml
@@ -1,7 +1,7 @@
.search-result-row
%h4
= confidential_icon(issue)
- = link_to [issue.project.namespace.becomes(Namespace), issue.project, issue] do
+ = link_to namespace_project_issue_path(issue.project.namespace.becomes(Namespace), issue.project, issue) do
%span.term.str-truncated= issue.title
- if issue.closed?
%span.badge.badge-danger.prepend-left-5= _("Closed")
diff --git a/app/views/search/results/_merge_request.html.haml b/app/views/search/results/_merge_request.html.haml
index f0e0af11f27..074bb9bce8d 100644
--- a/app/views/search/results/_merge_request.html.haml
+++ b/app/views/search/results/_merge_request.html.haml
@@ -1,6 +1,6 @@
.search-result-row
%h4
- = link_to [merge_request.target_project.namespace.becomes(Namespace), merge_request.target_project, merge_request] do
+ = link_to namespace_project_merge_request_path(merge_request.target_project.namespace.becomes(Namespace), merge_request.target_project, merge_request) do
%span.term.str-truncated= merge_request.title
- if merge_request.merged?
%span.badge.badge-primary.prepend-left-5= _("Merged")
diff --git a/app/views/search/results/_milestone.html.haml b/app/views/search/results/_milestone.html.haml
index 2daa96e34d1..3201f1a7815 100644
--- a/app/views/search/results/_milestone.html.haml
+++ b/app/views/search/results/_milestone.html.haml
@@ -1,6 +1,6 @@
.search-result-row
%h4
- = link_to [milestone.project.namespace.becomes(Namespace), milestone.project, milestone] do
+ = link_to namespace_project_milestone_path(milestone.project.namespace.becomes(Namespace), milestone.project, milestone) do
%span.term.str-truncated= milestone.title
- if milestone.description.present?
diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml
index a60a4501557..f17dae0a94c 100644
--- a/app/views/search/results/_snippet_blob.html.haml
+++ b/app/views/search/results/_snippet_blob.html.haml
@@ -18,7 +18,7 @@
%i.fa.fa-file
%strong= snippet.file_name
- if markup?(snippet.file_name)
- .file-content.wiki
+ .file-content.md.md-file
- snippet_chunks.each do |chunk|
- unless chunk[:data].empty?
= markup(snippet.file_name, chunk[:data])
diff --git a/app/views/search/results/_user.html.haml b/app/views/search/results/_user.html.haml
new file mode 100644
index 00000000000..8060a1577e4
--- /dev/null
+++ b/app/views/search/results/_user.html.haml
@@ -0,0 +1,10 @@
+%ul.content-list
+ %li
+ .avatar-cell.d-none.d-sm-block
+ = user_avatar(user: user, user_name: user.name, css_class: 'd-none d-sm-inline avatar s40')
+ .user-info
+ = link_to user_path(user), class: 'd-none d-sm-inline' do
+ .item-title
+ = user.name
+ = user_status(user)
+ .cgray= user.to_reference
diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml
index 389e4cc75b9..5847751b268 100644
--- a/app/views/search/results/_wiki_blob.html.haml
+++ b/app/views/search/results/_wiki_blob.html.haml
@@ -1,5 +1,5 @@
-- project = find_project_for_result_blob(wiki_blob)
+- project = find_project_for_result_blob(projects, wiki_blob)
- wiki_blob = parse_search_result(wiki_blob)
-- wiki_blob_link = project_wiki_path(project, wiki_blob.basename)
+- wiki_blob_link = project_wiki_path(project, Pathname.new(wiki_blob.filename).sub_ext(''))
= render partial: 'search/results/blob_data', locals: { blob: wiki_blob, project: project, file_name: wiki_blob.filename, blob_link: wiki_blob_link }
diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml
index ca392e1adfc..22fcfcda297 100644
--- a/app/views/sent_notifications/unsubscribe.html.haml
+++ b/app/views/sent_notifications/unsubscribe.html.haml
@@ -1,6 +1,6 @@
- noteable = @sent_notification.noteable
- noteable_type = @sent_notification.noteable_type.titleize.downcase
-- noteable_text = %(#{noteable.title} (#{noteable.to_reference}))
+- noteable_text = show_unsubscribe_title?(noteable) ? %(#{noteable.title} (#{noteable.to_reference})) : %(#{noteable.to_reference})
- page_title _("Unsubscribe"), noteable_text, noteable_type.pluralize, @sent_notification.project.full_name
%h3.page-title
diff --git a/app/views/shared/_choose_avatar_button.html.haml b/app/views/shared/_choose_avatar_button.html.haml
new file mode 100644
index 00000000000..0d46d047134
--- /dev/null
+++ b/app/views/shared/_choose_avatar_button.html.haml
@@ -0,0 +1,4 @@
+%button.btn.js-choose-avatar-button{ type: 'button' }= _("Choose file…")
+%span.file_name.js-avatar-filename= _("No file chosen")
+= f.file_field :avatar, class: "js-avatar-input hidden"
+.form-text.text-muted= _("The maximum file size allowed is 200KB.")
diff --git a/app/views/shared/_choose_group_avatar_button.html.haml b/app/views/shared/_choose_group_avatar_button.html.haml
deleted file mode 100644
index 0552fe62090..00000000000
--- a/app/views/shared/_choose_group_avatar_button.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-%button.btn.js-choose-group-avatar-button{ type: 'button' }= _("Choose File ...")
-%span.file_name.js-avatar-filename= _("No file chosen")
-= f.file_field :avatar, class: "js-group-avatar-input hidden"
-.form-text.text-muted= _("The maximum file size allowed is 200KB.")
diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml
index a2df0347fd6..1e509ea0d1f 100644
--- a/app/views/shared/_clone_panel.html.haml
+++ b/app/views/shared/_clone_panel.html.haml
@@ -16,7 +16,12 @@
= ssh_clone_button(project)
%li
= http_clone_button(project)
+ = render_if_exists 'shared/kerberos_clone_button', project: project
= text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: 'Project clone URL' }
.input-group-append
= clipboard_button(target: '#project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard")
+
+ = render_if_exists 'shared/geo_modal_button'
+
+= render_if_exists 'shared/geo_modal', project: project
diff --git a/app/views/shared/_confirm_modal.html.haml b/app/views/shared/_confirm_modal.html.haml
index 1dcf4369253..3967c8148d2 100644
--- a/app/views/shared/_confirm_modal.html.haml
+++ b/app/views/shared/_confirm_modal.html.haml
@@ -2,8 +2,7 @@
.modal-dialog
.modal-content
.modal-header
- %h3.page-title
- Confirmation required
+ %h3.page-title= _('Confirmation required')
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
%span{ "aria-hidden": true } &times;
@@ -11,8 +10,7 @@
%p.text-danger.js-confirm-text
%p
- This action can lead to data loss.
- To prevent accidental actions we ask you to confirm your intention.
+ %span.js-warning-text= _('This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention.')
%br
Please type
%code.js-confirm-danger-match= phrase
@@ -21,4 +19,4 @@
.form-group
= text_field_tag 'confirm_name_input', '', class: 'form-control js-confirm-danger-input'
.form-actions
- = submit_tag 'Confirm', class: "btn btn-danger js-confirm-danger-submit"
+ = submit_tag _('Confirm'), class: "btn btn-danger js-confirm-danger-submit"
diff --git a/app/views/shared/_delete_label_modal.html.haml b/app/views/shared/_delete_label_modal.html.haml
index b96380923ac..f37dd2cdf02 100644
--- a/app/views/shared/_delete_label_modal.html.haml
+++ b/app/views/shared/_delete_label_modal.html.haml
@@ -2,20 +2,20 @@
.modal-dialog
.modal-content
.modal-header
- %h3.page-title Delete #{render_colored_label(label, tooltip: false)} ?
+ %h3.page-title Delete #{render_label(label, tooltip: false)} ?
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
%span{ "aria-hidden": true } &times;
.modal-body
%p
%strong= label.name
- %span will be permanently deleted from #{label.is_a?(ProjectLabel)? label.project.name : label.group.name}. This cannot be undone.
+ %span will be permanently deleted from #{label.subject_name}. This cannot be undone.
.modal-footer
%a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' } Cancel
= link_to 'Delete label',
- destroy_label_path(label),
+ label.destroy_path,
title: 'Delete',
method: :delete,
class: 'btn btn-remove'
diff --git a/app/views/shared/_file_highlight.html.haml b/app/views/shared/_file_highlight.html.haml
index 5073e6ad48f..d7e57fc0d01 100644
--- a/app/views/shared/_file_highlight.html.haml
+++ b/app/views/shared/_file_highlight.html.haml
@@ -1,4 +1,4 @@
-.file-content.code.js-syntax-highlight
+.file-content.code.js-syntax-highlight.qa-file-content
.line-numbers
- if blob.data.present?
- link_icon = icon('link')
diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml
index 7b593ca4f76..d0f9374e832 100644
--- a/app/views/shared/_import_form.html.haml
+++ b/app/views/shared/_import_form.html.haml
@@ -1,11 +1,26 @@
- ci_cd_only = local_assigns.fetch(:ci_cd_only, false)
+- import_url = Gitlab::UrlSanitizer.new(f.object.import_url)
-.form-group.import-url-data
- = f.label :import_url, class: 'label-bold' do
- %span
- = _('Git repository URL')
+.import-url-data
+ .form-group
+ = f.label :import_url, class: 'label-bold' do
+ %span
+ = _('Git repository URL')
+ = f.text_field :import_url, value: import_url.sanitized_url,
+ autocomplete: 'off', class: 'form-control', placeholder: 'https://gitlab.company.com/group/project.git', required: true
- = f.text_field :import_url, autocomplete: 'off', class: 'form-control', placeholder: 'https://username:password@gitlab.company.com/group/project.git', required: true
+ .row
+ .form-group.col-md-6
+ = f.label :import_url_user, class: 'label-bold' do
+ %span
+ = _('Username (optional)')
+ = f.text_field :import_url_user, value: import_url.user, class: 'form-control', required: false, autocomplete: 'new-password'
+
+ .form-group.col-md-6
+ = f.label :import_url_password, class: 'label-bold' do
+ %span
+ = _('Password (optional)')
+ = f.password_field :import_url_password, class: 'form-control', required: false, autocomplete: 'new-password'
.info-well.prepend-top-20
.well-segment
@@ -13,8 +28,11 @@
%li
= _('The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>.').html_safe
%li
- = _('If your HTTP repository is not publicly accessible, add authentication information to the URL: <code>https://username:password@gitlab.company.com/group/project.git</code>.').html_safe
+ = _('If your HTTP repository is not publicly accessible, add your credentials.')
%li
= import_will_timeout_message(ci_cd_only)
%li
= import_svn_message(ci_cd_only)
+ = render_if_exists 'shared/ci_cd_only_link', ci_cd_only: ci_cd_only
+
+= render_if_exists 'shared/ee/import_form', f: f, ci_cd_only: ci_cd_only
diff --git a/app/views/shared/_issuable_meta_data.html.haml b/app/views/shared/_issuable_meta_data.html.haml
index 6cc8c485666..31a5370a5f8 100644
--- a/app/views/shared/_issuable_meta_data.html.haml
+++ b/app/views/shared/_issuable_meta_data.html.haml
@@ -1,4 +1,4 @@
-- note_count = @issuable_meta_data[issuable.id].notes_count
+- note_count = @issuable_meta_data[issuable.id].user_notes_count
- issue_votes = @issuable_meta_data[issuable.id]
- upvotes, downvotes = issue_votes.upvotes, issue_votes.downvotes
- issuable_url = @collection_type == "Issue" ? issue_path(issuable, anchor: 'notes') : merge_request_path(issuable, anchor: 'notes')
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index 21ea188d7b3..c4b7ef481fd 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -1,13 +1,13 @@
+- label = label.present(issuable_subject: local_assigns[:subject])
- label_css_id = dom_id(label)
- status = label_subscription_status(label, @project).inquiry if current_user
-- subject = local_assigns[:subject]
- use_label_priority = local_assigns.fetch(:use_label_priority, false)
- force_priority = local_assigns.fetch(:force_priority, use_label_priority ? label.priority.present? : false)
- toggle_subscription_path = toggle_subscription_label_path(label, @project) if current_user
- tooltip_title = label_status_tooltip(label, status) if status
%li.label-list-item{ id: label_css_id, data: { id: label.id } }
- = render "shared/label_row", label: label, subject: subject, force_priority: force_priority
+ = render "shared/label_row", label: label, force_priority: force_priority
%ul.label-actions-list
- if @project
%li.inline
@@ -21,7 +21,7 @@
= sprite_icon('star')
- if can?(current_user, :admin_label, label)
%li.inline
- = link_to edit_label_path(label), class: 'btn btn-transparent label-action edit has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do
+ = link_to label.edit_path, class: 'btn btn-transparent label-action edit has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do
= sprite_icon('pencil')
- if can?(current_user, :admin_label, label)
%li.inline
@@ -30,7 +30,7 @@
= sprite_icon('ellipsis_v')
.dropdown-menu.dropdown-open-left
%ul
- - if label.is_a?(ProjectLabel) && label.project.group && can?(current_user, :admin_label, label.project.group)
+ - if label.project_label? && label.project.group && can?(current_user, :admin_label, label.project.group)
%li
%button.js-promote-project-label-button.btn.btn-transparent.btn-action{ disabled: true, type: 'button',
data: { url: promote_project_label_path(label.project, label),
@@ -48,7 +48,7 @@
%button.text-danger.remove-row{ type: 'button' }= _('Delete')
- if current_user
%li.inline.label-subscription
- - if can_subscribe_to_label_in_different_levels?(label)
+ - if label.can_subscribe_to_label_in_different_levels?
%button.js-unsubscribe-button.label-subscribe-button.btn.btn-default{ class: ('hidden' if status.unsubscribed?), data: { url: toggle_subscription_path, toggle: 'tooltip' }, title: tooltip_title }
%span= _('Unsubscribe')
.dropdown.dropdown-group-label{ class: ('hidden' unless status.unsubscribed?) }
diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml
index c5ea15a7f63..af11ce94ec5 100644
--- a/app/views/shared/_label_row.html.haml
+++ b/app/views/shared/_label_row.html.haml
@@ -1,13 +1,10 @@
-- subject = local_assigns[:subject]
- force_priority = local_assigns.fetch(:force_priority, false)
-- show_label_issues_link = defined?(@project) && show_label_issuables_link?(label, :issues, project: @project)
-- show_label_merge_requests_link = defined?(@project) && show_label_issuables_link?(label, :merge_requests, project: @project)
+- subject_or_group_defined = defined?(@project) || defined?(@group)
+- show_label_issues_link = subject_or_group_defined && show_label_issuables_link?(label, :issues, project: @project)
+- show_label_merge_requests_link = subject_or_group_defined && show_label_issuables_link?(label, :merge_requests, project: @project)
.label-name
- - if defined?(@project)
- = link_to_label(label, subject: @project, tooltip: false)
- - else
- = render_colored_label(label, tooltip: false)
+ = render_label(label, tooltip: false)
.label-description
.append-right-default.prepend-left-default
- if label.description.present?
@@ -16,11 +13,13 @@
%ul.label-links
- if show_label_issues_link
%li.label-link-item.inline
- = link_to_label(label, subject: subject) { 'Issues' }
+ = link_to_label(label) { 'Issues' }
- if show_label_merge_requests_link
&middot;
%li.label-link-item.inline
- = link_to_label(label, subject: subject, type: :merge_request) { _('Merge requests') }
+ = link_to_label(label, type: :merge_request) { _('Merge requests') }
- if force_priority
+ &middot;
%li.label-link-item.priority-badge.js-priority-badge.inline.prepend-left-10
.label-badge.label-badge-blue= _('Prioritized label')
+ = render_if_exists 'shared/label_row_epics_link', label: label
diff --git a/app/views/shared/_mini_pipeline_graph.html.haml b/app/views/shared/_mini_pipeline_graph.html.haml
index 8607f87ce0b..a1f21c2a83e 100644
--- a/app/views/shared/_mini_pipeline_graph.html.haml
+++ b/app/views/shared/_mini_pipeline_graph.html.haml
@@ -4,7 +4,7 @@
- detailed_status = stage.detailed_status(current_user)
- icon_status = "#{detailed_status.icon}_borderless"
- .stage-container.dropdown{ class: klass }
+ .stage-container.mt-0.ml-1.dropdown{ class: klass }
%button.mini-pipeline-graph-dropdown-toggle.has-tooltip.js-builds-dropdown-button{ class: "ci-status-icon-#{detailed_status.group}", type: 'button', data: { toggle: 'dropdown', title: "#{stage.name}: #{detailed_status.label}", placement: 'top', "stage-endpoint" => stage_ajax_project_pipeline_path(pipeline.project, pipeline, stage: stage.name) } }
= sprite_icon(icon_status)
@@ -13,5 +13,5 @@
%ul
%li.js-builds-dropdown-loading.hidden
- .text-center
- %i.fa.fa-spinner.fa-spin{ 'aria-hidden': 'true', 'aria-label': 'Loading' }
+ .loading-container.text-center
+ %span.spinner{ 'aria-label': 'Loading' }
diff --git a/app/views/shared/_mobile_clone_panel.html.haml b/app/views/shared/_mobile_clone_panel.html.haml
index 6e2527bd1a1..1e6b6f7c79b 100644
--- a/app/views/shared/_mobile_clone_panel.html.haml
+++ b/app/views/shared/_mobile_clone_panel.html.haml
@@ -13,3 +13,4 @@
- if http_enabled?
%li
= dropdown_item_with_description(http_copy_label, project.http_url_to_repo, href: project.http_url_to_repo, data: { clone_type: 'http' })
+ = render_if_exists 'shared/mobile_kerberos_clone'
diff --git a/app/views/shared/_old_visibility_level.html.haml b/app/views/shared/_old_visibility_level.html.haml
index fd576e4fbea..e8f3d888cce 100644
--- a/app/views/shared/_old_visibility_level.html.haml
+++ b/app/views/shared/_old_visibility_level.html.haml
@@ -1,6 +1,6 @@
.form-group.row
.col-sm-2.col-form-label
= _('Visibility level')
- = link_to icon('question-circle'), help_page_path("public_access/public_access")
+ = link_to icon('question-circle'), help_page_path("public_access/public_access"), target: '_blank'
.col-sm-10
= render 'shared/visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_visibility_level, form_model: form_model, with_label: with_label
diff --git a/app/views/shared/_remote_mirror_update_button.html.haml b/app/views/shared/_remote_mirror_update_button.html.haml
index 721a2af8069..8da2ae5111a 100644
--- a/app/views/shared/_remote_mirror_update_button.html.haml
+++ b/app/views/shared/_remote_mirror_update_button.html.haml
@@ -1,6 +1,6 @@
- if remote_mirror.update_in_progress?
%button.btn.disabled{ type: 'button', data: { toggle: 'tooltip', container: 'body' }, title: _('Updating') }
= icon("refresh spin")
-- else
+- elsif remote_mirror.enabled?
= link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn qa-update-now-button", data: { toggle: 'tooltip', container: 'body' }, title: _('Update now') do
= icon("refresh")
diff --git a/app/views/shared/_sidebar_toggle_button.html.haml b/app/views/shared/_sidebar_toggle_button.html.haml
index 2530db986e0..d90a6d43761 100644
--- a/app/views/shared/_sidebar_toggle_button.html.haml
+++ b/app/views/shared/_sidebar_toggle_button.html.haml
@@ -1,8 +1,8 @@
%a.toggle-sidebar-button.js-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" }
= sprite_icon('angle-double-left', css_class: 'icon-angle-double-left')
= sprite_icon('angle-double-right', css_class: 'icon-angle-double-right')
- %span.collapse-text Collapse sidebar
+ %span.collapse-text= _("Collapse sidebar")
= button_tag class: 'close-nav-button', type: 'button' do
= sprite_icon('close', size: 16)
- %span.collapse-text Close sidebar
+ %span.collapse-text= _("Close sidebar")
diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml
index f0d1dd162df..813fccd217b 100644
--- a/app/views/shared/boards/_show.html.haml
+++ b/app/views/shared/boards/_show.html.haml
@@ -13,14 +13,14 @@
%script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal, show_sorting_dropdown: false
%script#js-board-promotion{ type: "text/x-template" }= render_if_exists "shared/promotions/promote_issue_board"
-#board-app.boards-app{ "v-cloak" => true, data: board_data, ":class" => "{ 'is-compact': detailIssueVisible }" }
+#board-app.boards-app.position-relative{ "v-cloak" => "true", data: board_data, ":class" => "{ 'is-compact': detailIssueVisible }" }
.d-none.d-sm-none.d-md-block
= render 'shared/issuable/search_bar', type: :boards, board: board
- .boards-list
- .boards-app-loading.text-center{ "v-if" => "loading" }
- = icon("spinner spin")
- %board{ "v-cloak" => true,
+ .boards-list.w-100.py-3.px-2.text-nowrap
+ .boards-app-loading.w-100.text-center{ "v-if" => "loading" }
+ = icon("spinner spin 2x")
+ %board{ "v-cloak" => "true",
"v-for" => "list in state.lists",
"ref" => "board",
":list" => "list",
diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml
index 307a0919a4c..f9cfcabc015 100644
--- a/app/views/shared/boards/components/_board.html.haml
+++ b/app/views/shared/boards/components/_board.html.haml
@@ -1,8 +1,8 @@
-.board{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded, "board-type-assignee": list.type === "assignee" }',
+.board.d-inline-block.h-100.px-2.align-top.ws-normal{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded, "board-type-assignee": list.type === "assignee" }',
":data-id" => "list.id" }
- .board-inner.d-flex.flex-column
- %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", "@click" => "toggleExpanded($event)" }
- %h3.board-title.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset) }' }
+ .board-inner.d-flex.flex-column.position-relative.h-100.rounded
+ %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color, "position-relative": list.isExpanded, "position-absolute position-top-0 position-left-0 w-100 h-100": !list.isExpanded }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", "@click" => "toggleExpanded($event)" }
+ %h3.board-title.m-0.d-flex.align-items-center.py-2.px-3.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset), "p-0 border-bottom-0 justify-content-center": !list.isExpanded }' }
%i.fa.fa-fw.board-title-expandable-toggle{ "v-if": "list.isExpandable",
":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded }",
"aria-hidden": "true" }
@@ -31,9 +31,9 @@
%board-delete{ "inline-template" => true,
":list" => "list",
"v-if" => "!list.preset && list.id" }
- %button.board-delete.has-tooltip.float-right{ type: "button", title: _("Delete list"), "aria-label" => _("Delete list"), data: { placement: "bottom" }, "@click.stop" => "deleteBoard" }
+ %button.board-delete.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.text-secondary{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', ":title": "counterTooltip", "v-tooltip": true, data: { placement: "top" } }
+ .issue-count-badge.text-secondary{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', ":title": "counterTooltip", ":class": "{ 'd-none': !list.isExpanded }", "v-tooltip": true, data: { placement: "top" } }
%span.issue-count-badge-count
%icon.mr-1{ name: "issues" }
{{ list.issuesSize }}
@@ -42,6 +42,7 @@
%button.issue-count-badge-add-button.btn.btn-sm.btn-default.ml-1.has-tooltip.js-no-trigger-collapse{ type: "button",
"@click" => "showNewIssueForm",
"v-if" => "isNewIssueShown",
+ ":class": "{ 'd-none': !list.isExpanded }",
"aria-label" => _("New issue"),
"title" => _("New issue"),
data: { placement: "top", container: "body" } }
diff --git a/app/views/shared/boards/components/_sidebar.html.haml b/app/views/shared/boards/components/_sidebar.html.haml
index c9ff63f8c45..b4f75967a67 100644
--- a/app/views/shared/boards/components/_sidebar.html.haml
+++ b/app/views/shared/boards/components/_sidebar.html.haml
@@ -2,16 +2,16 @@
%transition{ name: "boards-sidebar-slide" }
%aside.right-sidebar.right-sidebar-expanded.issue-boards-sidebar{ "v-show" => "showSidebar" }
.issuable-sidebar
- .block.issuable-sidebar-header
+ .block.issuable-sidebar-header.position-relative
%span.issuable-header-text.hide-collapsed.float-left
- %strong
+ %strong.bold
{{ issue.title }}
%br/
%span
= render_if_exists "shared/boards/components/sidebar/issue_project_path"
= precede "#" do
{{ issue.iid }}
- %a.gutter-toggle.float-right{ role: "button",
+ %a.gutter-toggle.position-absolute.position-top-0.position-right-0{ role: "button",
href: "#",
"@click.prevent" => "closeSidebar",
"aria-label" => "Toggle sidebar" }
@@ -20,6 +20,7 @@
= render "shared/boards/components/sidebar/assignee"
= render_if_exists "shared/boards/components/sidebar/epic"
= render "shared/boards/components/sidebar/milestone"
+ = render "shared/boards/components/sidebar/time_tracker"
= render "shared/boards/components/sidebar/due_date"
= render "shared/boards/components/sidebar/labels"
= render_if_exists "shared/boards/components/sidebar/weight"
diff --git a/app/views/shared/boards/components/sidebar/_assignee.html.haml b/app/views/shared/boards/components/sidebar/_assignee.html.haml
index 1374da9d82c..af6a519a967 100644
--- a/app/views/shared/boards/components/sidebar/_assignee.html.haml
+++ b/app/views/shared/boards/components/sidebar/_assignee.html.haml
@@ -19,7 +19,7 @@
":data-name" => "assignee.name",
":data-username" => "assignee.username" }
.dropdown
- - dropdown_options = issue_assignees_dropdown_options
+ - dropdown_options = assignees_dropdown_options('issue')
%button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: 'button', ref: 'assigneeDropdown', data: board_sidebar_user_data,
":data-issuable-id" => "issue.iid" }
= dropdown_options[:title]
diff --git a/app/views/shared/boards/components/sidebar/_due_date.html.haml b/app/views/shared/boards/components/sidebar/_due_date.html.haml
index 5630375f428..117d56b30f5 100644
--- a/app/views/shared/boards/components/sidebar/_due_date.html.haml
+++ b/app/views/shared/boards/components/sidebar/_due_date.html.haml
@@ -7,7 +7,7 @@
.value
.value-content
%span.no-value{ "v-if" => "!issue.dueDate" }
- = _("No due date")
+ = _("None")
%span.bold{ "v-if" => "issue.dueDate" }
{{ issue.dueDate | due-date }}
- if can_admin_issue?
diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml
index 19159684420..c50826a7cda 100644
--- a/app/views/shared/boards/components/sidebar/_labels.html.haml
+++ b/app/views/shared/boards/components/sidebar/_labels.html.haml
@@ -7,10 +7,17 @@
.value.issuable-show-labels.dont-hide
%span.no-value{ "v-if" => "issue.labels && issue.labels.length === 0" }
= _("None")
- %a{ href: "#",
- "v-for" => "label in issue.labels" }
- .badge.color-label.has-tooltip{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" }
- {{ label.title }}
+ %span{ "v-for" => "label in issue.labels" }
+ %span.d-inline-block.position-relative.scoped-label-wrapper{ "v-if" => "showScopedLabels(label)" }
+ %a{ href: '#' }
+ %span.badge.color-label.label{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" }
+ {{ label.title }}
+ %a.label.scoped-label{ ":href" => "helpLink()" }
+ %i.fa.fa-question-circle{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" }
+ %a{ href: "#", "v-else" => true }
+ .badge.color-label.has-tooltip{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" }
+ {{ label.title }}
+
- if can_admin_issue?
.selectbox
%input{ type: "hidden",
@@ -21,17 +28,11 @@
%button.dropdown-menu-toggle.js-label-select.js-multiselect.js-issue-board-sidebar{ type: "button",
":data-selected" => "selectedLabels",
":data-labels" => "issue.assignableLabelsEndpoint",
- data: { toggle: "dropdown",
- field_name: "issue[label_names][]",
- show_no: "true",
- show_any: "true",
- project_id: @project&.try(:id),
- namespace_path: @namespace_path,
- project_path: @project.try(:path) } }
+ data: label_dropdown_data(@project, namespace_path: @namespace_path, field_name: "issue[label_names][]") }
%span.dropdown-toggle-text
{{ labelDropdownTitle }}
= icon('chevron-down')
- .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable
+ .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height
= render partial: "shared/issuable/label_page_default"
- if can?(current_user, :admin_label, current_board_parent)
- = render partial: "shared/issuable/label_page_create"
+ = render partial: "shared/issuable/label_page_create", locals: { show_add_list: true }
diff --git a/app/views/shared/boards/components/sidebar/_time_tracker.html.haml b/app/views/shared/boards/components/sidebar/_time_tracker.html.haml
new file mode 100644
index 00000000000..b76d44c5907
--- /dev/null
+++ b/app/views/shared/boards/components/sidebar/_time_tracker.html.haml
@@ -0,0 +1,6 @@
+.block.time-tracking
+ %time-tracker{ ":time-estimate" => "issue.timeEstimate || 0",
+ ":time-spent" => "issue.timeSpent || 0",
+ ":human-time-estimate" => "issue.humanTimeEstimate",
+ ":human-time-spent" => "issue.humanTimeSpent",
+ "root-path" => "#{root_url}" }
diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml
index 913c065e188..bc0dc7f9631 100644
--- a/app/views/shared/deploy_keys/_form.html.haml
+++ b/app/views/shared/deploy_keys/_form.html.haml
@@ -13,8 +13,9 @@
= form.label :key, class: 'col-form-label col-sm-2'
.col-sm-10
%p.light
- Paste a machine public key here. Read more about how to generate it
- = link_to 'here', help_page_path('ssh/README')
+ - link_start = "<a href='#{help_page_path('ssh/README')}' target='_blank' rel='noreferrer noopener'>".html_safe
+ - link_end = '</a>'
+ = _('Paste a machine public key here. Read more about how to generate it %{link_start}here%{link_end}').html_safe % { link_start: link_start, link_end: link_end.html_safe }
= form.text_area :key, class: 'form-control thin_area', rows: 5
- else
= form.label :fingerprint, class: 'col-form-label col-sm-2'
@@ -28,6 +29,6 @@
.col-sm-10
= deploy_keys_project_form.label :can_push do
= deploy_keys_project_form.check_box :can_push
- %strong Write access allowed
+ %strong= _('Write access allowed')
%p.light.append-bottom-0
- Allow this key to push to repository as well? (Default only allows pull access.)
+ = _('Allow this key to push to repository as well? (Default only allows pull access.)')
diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml
index 25df2fe5cd6..b11cb8a3076 100644
--- a/app/views/shared/form_elements/_description.html.haml
+++ b/app/views/shared/form_elements/_description.html.haml
@@ -5,7 +5,7 @@
- supports_quick_actions = model.new_record?
- if supports_quick_actions
- - preview_url = preview_markdown_path(project, quick_actions_target_type: model.class.name)
+ - preview_url = preview_markdown_path(project, target_type: model.class.name)
- else
- preview_url = preview_markdown_path(project)
diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml
index 1ae6d1f5ee3..f4915440cb2 100644
--- a/app/views/shared/groups/_dropdown.html.haml
+++ b/app/views/shared/groups/_dropdown.html.haml
@@ -24,10 +24,10 @@
%li.divider
%li.js-filter-archived-projects
= link_to filter_groups_path(archived: nil), class: ("is-active" unless params[:archived].present?) do
- Hide archived projects
+ = _("Hide archived projects")
%li.js-filter-archived-projects
= link_to filter_groups_path(archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do
- Show archived projects
+ = _("Show archived projects")
%li.js-filter-archived-projects
= link_to filter_groups_path(archived: 'only'), class: ("is-active" if params[:archived] == 'only') do
- Show archived projects only
+ = _("Show archived projects only")
diff --git a/app/views/shared/icons/_emoji_slightly_smiling_face.svg b/app/views/shared/icons/_emoji_slightly_smiling_face.svg
deleted file mode 100644
index 56dbad91554..00000000000
--- a/app/views/shared/icons/_emoji_slightly_smiling_face.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369.721.721 0 0 1 .568.047.715.715 0 0 1 .37.445c.195.625.556 1.131 1.084 1.518A2.93 2.93 0 0 0 9 12.75a2.93 2.93 0 0 0 1.775-.58 2.913 2.913 0 0 0 1.084-1.518.711.711 0 0 1 .375-.445.737.737 0 0 1 .575-.047c.195.063.34.186.433.37.094.183.11.372.047.568zM7.5 6c0 .414-.146.768-.44 1.06-.292.294-.646.44-1.06.44-.414 0-.768-.146-1.06-.44A1.445 1.445 0 0 1 4.5 6c0-.414.146-.768.44-1.06.292-.294.646-.44 1.06-.44.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm6 0c0 .414-.146.768-.44 1.06-.292.294-.646.44-1.06.44-.414 0-.768-.146-1.06-.44A1.445 1.445 0 0 1 10.5 6c0-.414.146-.768.44-1.06.292-.294.646-.44 1.06-.44.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm3 3a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6A7.29 7.29 0 0 0 9 16.5a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39A7.29 7.29 0 0 0 16.5 9zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="evenodd"/></svg>
diff --git a/app/views/shared/icons/_emoji_smile.svg b/app/views/shared/icons/_emoji_smile.svg
deleted file mode 100644
index ce645fee46f..00000000000
--- a/app/views/shared/icons/_emoji_smile.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369c.195-.062 7.41-.062 7.606 0 .195.063.34.186.433.37.094.183.11.372.047.568zM14 6.37c0 .398-.04.755-.513.755-.473 0-.498-.272-1.237-.272-.74 0-.74.215-1.165.215-.425 0-.585-.3-.585-.698 0-.397.17-.736.513-1.017.341-.281.754-.422 1.237-.422.483 0 .896.14 1.237.422.342.28.513.62.513 1.017zm-6.5 0c0 .398-.04.755-.513.755-.473 0-.498-.272-1.237-.272-.74 0-.74.215-1.165.215-.425 0-.585-.3-.585-.698 0-.397.17-.736.513-1.017.341-.281.754-.422 1.237-.422.483 0 .896.14 1.237.422.342.28.513.62.513 1.017zm9 2.63a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6A7.29 7.29 0 0 0 9 16.5a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39A7.29 7.29 0 0 0 16.5 9zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="evenodd"/></svg>
diff --git a/app/views/shared/icons/_emoji_smiley.svg b/app/views/shared/icons/_emoji_smiley.svg
deleted file mode 100644
index ddfae50e566..00000000000
--- a/app/views/shared/icons/_emoji_smiley.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369c.195-.062 7.41-.062 7.606 0 .195.063.34.186.433.37.094.183.11.372.047.568h.001zM7.5 6c0 .414-.146.768-.44 1.06A1.44 1.44 0 0 1 6 7.5a1.44 1.44 0 0 1-1.06-.44A1.445 1.445 0 0 1 4.5 6c0-.414.146-.768.44-1.06A1.44 1.44 0 0 1 6 4.5c.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm6 0c0 .414-.146.768-.44 1.06A1.44 1.44 0 0 1 12 7.5a1.44 1.44 0 0 1-1.06-.44A1.445 1.445 0 0 1 10.5 6c0-.414.146-.768.44-1.06A1.44 1.44 0 0 1 12 4.5c.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm3 3a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6c.92.397 1.91.6 2.912.598a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39c.397-.92.6-1.91.598-2.912zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="nonzero"/></svg>
diff --git a/app/views/shared/icons/_gitea_logo.svg.erb b/app/views/shared/icons/_gitea_logo.svg.erb
new file mode 100644
index 00000000000..c8ddbc5535e
--- /dev/null
+++ b/app/views/shared/icons/_gitea_logo.svg.erb
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?><!-- Created with Inkscape (http://www.inkscape.org/) --><svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="<%= size %>" height="<%= size %>" viewBox="0 0 135.46667 135.46667" version="1.1" id="svg8" sodipodi:docname="logo.svg" inkscape:version="0.92.1 r15371" inkscape:export-filename="" inkscape:export-xdpi="48.000004" inkscape:export-ydpi="48.000004" style="zoom: 1;"><defs id="defs2"></defs><sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0" inkscape:pageshadow="2" inkscape:zoom="0.70710678" inkscape:cx="418.13805" inkscape:cy="177.57445" inkscape:document-units="mm" inkscape:current-layer="layer2" showgrid="false" units="px" width="256px" showguides="false" inkscape:window-width="1920" inkscape:window-height="1137" inkscape:window-x="1912" inkscape:window-y="-8" inkscape:window-maximized="1" inkscape:pagecheckerboard="false" inkscape:measure-start="283.373,243.952" inkscape:measure-end="290.267,236.527"><sodipodi:guide position="0,0" orientation="0,512" id="guide3699" inkscape:locked="false"></sodipodi:guide><sodipodi:guide position="135.46667,0" orientation="-512,0" id="guide3701" inkscape:locked="false"></sodipodi:guide><sodipodi:guide position="135.46667,135.46667" orientation="0,-512" id="guide3703" inkscape:locked="false"></sodipodi:guide><sodipodi:guide position="0,135.46667" orientation="512,0" id="guide3705" inkscape:locked="false"></sodipodi:guide></sodipodi:namedview><metadata id="metadata5"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"></dc:type><dc:title></dc:title></cc:Work></rdf:RDF></metadata><g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" transform="translate(0,-161.53334)" style="display:inline"><path d="M27.709937,195.15095 c-9.546573,-0.0272 -22.3392732,6.79805 -21.6317552,23.90397 c1.105534,26.72889 25.4565952,29.20839 35.1916502,29.42301 c1.068023,5.01357 12.521798,22.30563 21.001818,23.21667 h37.15277 c22.27763,-1.66785 38.9607,-75.75671 26.59321,-76.03825 c-46.781583,2.47691 -49.995146,2.13838 -88.599758,0 c-2.495053,-0.0266 -5.972321,-0.49474 -9.707935,-0.5054 z m2.491319,9.45886 c1.351378,13.69267 3.555849,21.70359 8.018216,33.94345 c-11.382872,-1.50473 -21.069822,-5.22443 -22.851515,-19.10984 c-0.950962,-7.4112 2.390428,-15.16769 14.833299,-14.83361 z " id="path3722" sodipodi:nodetypes="sscccccsccsc" inkscape:connector-curvature="0" style="fill:#000;fill-opacity:1;stroke:#000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"></path></g><g inkscape:groupmode="layer" id="layer2" inkscape:label="Layer 2" style="display:inline"><rect style="display:inline;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.24757317;stroke-opacity:1" id="rect4599" width="34.762054" height="34.762054" x="87.508659" y="18.291576" transform="rotate(25.914715)" ry="5.4825778"></rect><path style="display:inline;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.26644793px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" d="m 79.804947,57.359056 3.241146,1.609954 V 35.255731 h -3.262698 z" id="path4525" inkscape:connector-curvature="0" sodipodi:nodetypes="ccccc"></path></g><g inkscape:groupmode="layer" id="layer3" inkscape:label="Layer 3" style="display:inline"><g style="display:inline" id="g4539"><circle style="fill:#000;fill-opacity:1;stroke:none;stroke-width:0.26458332;stroke-opacity:1" id="path4606" cy="90.077766" r="3.4745038" cx="49.064713" transform="rotate(-19.796137)"></circle><circle style="fill:#000;fill-opacity:1;stroke:none;stroke-width:0.26458332;stroke-opacity:1" id="path4606-3" cy="102.1049" r="3.4745038" cx="36.810425" transform="rotate(-19.796137)"></circle><circle style="fill:#000;fill-opacity:1;stroke:none;stroke-width:0.26458332;stroke-opacity:1" id="path4606-1" cy="111.43928" r="3.4745038" cx="46.484283" transform="rotate(-19.796137)"></circle><rect height="27.261492" style="fill:#000;fill-opacity:1;stroke:none;stroke-width:0.27444693;stroke-opacity:1" x="97.333458" y="18.061695" id="rect4629-8" width="2.6726954" transform="rotate(26.024158)"></rect><path d="M76.558096,68.116343 c12.97589,6.395378 13.012989,4.101862 4.890858,20.907244 " id="path4514" sodipodi:nodetypes="cc" inkscape:connector-curvature="0" style="fill:none;stroke:#000;stroke-width:2.68000007;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"></path></g></g></svg> \ No newline at end of file
diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml
index ef3d44a9241..24734ed66cf 100644
--- a/app/views/shared/issuable/_assignees.html.haml
+++ b/app/views/shared/issuable/_assignees.html.haml
@@ -1,9 +1,9 @@
- max_render = 4
-- assignees_rendering_overflow = issue.assignees.size > max_render
+- assignees_rendering_overflow = issuable.assignees.size > max_render
- render_count = assignees_rendering_overflow ? max_render - 1 : max_render
-- more_assignees_count = issue.assignees.size - render_count
+- more_assignees_count = issuable.assignees.size - render_count
-- issue.assignees.take(render_count).each do |assignee| # rubocop: disable CodeReuse/ActiveRecord
+- issuable.assignees.take(render_count).each do |assignee| # rubocop: disable CodeReuse/ActiveRecord
= link_to_member(@project, assignee, name: false, title: "Assigned to :name")
- if more_assignees_count.positive?
diff --git a/app/views/shared/issuable/_board_create_list_dropdown.html.haml b/app/views/shared/issuable/_board_create_list_dropdown.html.haml
index fd413bd68c8..416b4a34651 100644
--- a/app/views/shared/issuable/_board_create_list_dropdown.html.haml
+++ b/app/views/shared/issuable/_board_create_list_dropdown.html.haml
@@ -4,5 +4,5 @@
.dropdown-menu.dropdown-extended-height.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels
= render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" }
- if can?(current_user, :admin_label, board.parent)
- = render partial: "shared/issuable/label_page_create"
+ = render partial: "shared/issuable/label_page_create", locals: { show_add_list: true, add_list: true, add_list_class: 'd-none' }
= dropdown_loading
diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
index 909eb738f95..a05a13814ac 100644
--- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml
+++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
@@ -21,10 +21,7 @@
.title
Assignee
.filter-item
- - if type == :issues
- - field_name = "update[assignee_ids][]"
- - else
- - field_name = "update[assignee_id]"
+ - field_name = "update[assignee_ids][]"
= dropdown_tag("Select assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable",
placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } })
.block
diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml
index d5fb85ba0f3..483652852b6 100644
--- a/app/views/shared/issuable/_label_dropdown.html.haml
+++ b/app/views/shared/issuable/_label_dropdown.html.haml
@@ -8,7 +8,7 @@
- classes = local_assigns.fetch(:classes, [])
- selected = local_assigns.fetch(:selected, nil)
- dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by label")
-- dropdown_data = {toggle: 'dropdown', field_name: "label_name[]", show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), labels: labels_filter_path_with_defaults, default_label: "Labels"}
+- dropdown_data = label_dropdown_data(@project, labels: labels_filter_path_with_defaults, default_label: "Labels")
- dropdown_data.merge!(data_options)
- label_name = local_assigns.fetch(:label_name, "Labels")
- no_default_styles = local_assigns.fetch(:no_default_styles, false)
@@ -25,7 +25,7 @@
%span.dropdown-toggle-text{ class: ("is-default" if apply_is_default_styles) }
= multi_label_name(selected, label_name)
= icon('chevron-down')
- .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable
+ .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height
= render partial: "shared/issuable/label_page_default", locals: { title: dropdown_title, show_footer: show_footer, show_create: show_create }
- if show_create && project && can?(current_user, :admin_label, project)
= render partial: "shared/issuable/label_page_create"
diff --git a/app/views/shared/issuable/_label_page_create.html.haml b/app/views/shared/issuable/_label_page_create.html.haml
index 55edaa7eda4..a0d3bc64f1f 100644
--- a/app/views/shared/issuable/_label_page_create.html.haml
+++ b/app/views/shared/issuable/_label_page_create.html.haml
@@ -1,4 +1,7 @@
- show_close = local_assigns.fetch(:show_close, true)
+- show_add_list = local_assigns.fetch(:show_add_list, false)
+- add_list = local_assigns.fetch(:add_list, false)
+- add_list_class = local_assigns.fetch(:add_list_class, '')
- subject = @project || @group
.dropdown-page-two.dropdown-new-label
= dropdown_title(create_label_title(subject), options: { back: true, close: show_close })
@@ -6,12 +9,15 @@
.dropdown-labels-error.js-label-error
%input#new_label_name.default-dropdown-input{ type: "text", placeholder: _('Name new label') }
.suggest-colors.suggest-colors-dropdown
- - suggested_colors.each do |color|
- = link_to '#', style: "background-color: #{color}", data: { color: color } do
- &nbsp
+ = render_suggested_colors
.dropdown-label-color-input
.dropdown-label-color-preview.js-dropdown-label-color-preview
%input#new_label_color.default-dropdown-input{ type: "text", placeholder: _('Assign custom color like #FF0000') }
+ - if show_add_list
+ .dropdown-label-input{ class: add_list_class }
+ %label
+ %input.js-add-list{ type: "checkbox", name: "add_list", checked: add_list }
+ %span= _('Add list')
.clearfix
%button.btn.btn-primary.float-left.js-new-label-btn{ type: "button" }
= _('Create')
diff --git a/app/views/shared/issuable/_label_page_default.html.haml b/app/views/shared/issuable/_label_page_default.html.haml
index aa4a5f0e0d3..a0fb5229fc3 100644
--- a/app/views/shared/issuable/_label_page_default.html.haml
+++ b/app/views/shared/issuable/_label_page_default.html.haml
@@ -11,7 +11,7 @@
= dropdown_title(title)
- if show_boards_content
.issue-board-dropdown-content
- %p
+ %p.m-0
= content_title
= dropdown_filter(filter_placeholder)
= dropdown_content
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index bdba47ed14d..3d6c5d29d44 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -3,11 +3,11 @@
- block_css_class = type != :boards_modal ? 'row-content-block second-block' : ''
- user_can_admin_list = board && can?(current_user, :admin_list, board.parent)
-.issues-filters
- .issues-details-filters.filtered-search-block{ class: block_css_class, "v-pre" => type == :boards_modal }
+.issues-filters{ class: ("w-100" if type == :boards_modal) }
+ .issues-details-filters.filtered-search-block.d-flex{ class: block_css_class, "v-pre" => type == :boards_modal }
- if type == :boards
= render_if_exists "shared/boards/switcher", board: board
- = form_tag page_filter_path, method: :get, class: 'filter-form js-filter-form' do
+ = form_tag page_filter_path, method: :get, class: 'filter-form js-filter-form w-100' do
- if params[:search].present?
= hidden_field_tag :search, params[:search]
- if @can_bulk_update
@@ -71,6 +71,7 @@
= render 'shared/issuable/user_dropdown_item',
user: User.new(username: '{{username}}', name: '{{name}}'),
avatar: { lazy: true, url: '{{avatar_url}}' }
+ = render_if_exists 'shared/issuable/approver_dropdown'
#js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'None' } }
@@ -136,6 +137,11 @@
%li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
%button.btn.btn-link{ type: 'button' }
= _('No')
+ #js-dropdown-target-branch.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ %button.btn.btn-link.js-data-value.monospace
+ {{title}}
= render_if_exists 'shared/issuable/filter_weight', type: type
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 9596c1df20e..3a5adb34ad1 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -73,7 +73,7 @@
%span.bold= issuable_sidebar[:due_date].to_s(:medium)
- else
%span.no-value
- = _('No due date')
+ = _('None')
- if can_edit_issuable
%span.no-value.js-remove-due-date-holder{ class: ("hidden" if issuable_sidebar[:due_date].nil?) }
\-
@@ -105,10 +105,8 @@
= link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right'
.value.issuable-show-labels.dont-hide.hide-collapsed.qa-labels-block{ class: ("has-labels" if selected_labels.any?) }
- if selected_labels.any?
- - selected_labels.each do |label|
- = link_to sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label[:title]) do
- %span.badge.color-label.has-tooltip{ style: "background-color: #{label[:color]}; color: #{label[:text_color]}", title: label[:description], data: { container: "body" } }
- = label[:title]
+ - selected_labels.each do |label_hash|
+ = render_label(label_from_hash(label_hash).present(issuable_subject: nil), link: sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label_hash[:title]))
- else
%span.no-value
= _('None')
@@ -116,11 +114,11 @@
- selected_labels.each do |label|
= hidden_field_tag "#{issuable_type}[label_names][]", label[:id], id: nil
.dropdown
- %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable_type}[label_names][]", ability_name: issuable_type, show_no: "true", show_any: "true", namespace_path: issuable_sidebar[:namespace_path], project_path: issuable_sidebar[:project_path], issue_update: issuable_sidebar[:issuable_json_path], labels: issuable_sidebar[:project_labels_path], display: 'static' } }
+ %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: sidebar_label_dropdown_data(issuable_type, issuable_sidebar) }
%span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) }
= multi_label_name(selected_labels, "Labels")
= icon('chevron-down', 'aria-hidden': 'true')
- .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable
+ .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height
= render partial: "shared/issuable/label_page_default"
- if issuable_sidebar.dig(:current_user, :can_admin_label)
= render partial: "shared/issuable/label_page_create"
@@ -160,13 +158,13 @@
%button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button',
data: { toggle: 'dropdown', display: 'static' } }
= _('Move issue')
- .dropdown-menu.dropdown-menu-selectable
+ .dropdown-menu.dropdown-menu-selectable.dropdown-extended-height
= dropdown_title(_('Move issue'))
= dropdown_filter(_('Search project'), search_id: 'sidebar-move-issue-dropdown-search')
= dropdown_content
= dropdown_loading
= dropdown_footer add_content_class: true do
- %button.btn.btn-success.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ disabled: true }
+ %button.btn.btn-success.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ type: 'button', disabled: true }
= _('Move')
= icon('spinner spin', class: 'sidebar-move-issue-confirmation-loading-icon')
diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml
index 1a59055f652..ab01094ed6e 100644
--- a/app/views/shared/issuable/_sidebar_assignees.html.haml
+++ b/app/views/shared/issuable/_sidebar_assignees.html.haml
@@ -1,42 +1,10 @@
- issuable_type = issuable_sidebar[:type]
- signed_in = !!issuable_sidebar.dig(:current_user, :id)
-- can_edit_issuable = issuable_sidebar.dig(:current_user, :can_edit)
-- if issuable_type == "issue"
- #js-vue-sidebar-assignees{ data: { field: "#{issuable_type}[assignee_ids]", signed_in: signed_in } }
- .title.hide-collapsed
- = _('Assignee')
- = icon('spinner spin')
-- else
- - assignee = assignees.first
- .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body", boundary: 'viewport' }, title: (issuable_sidebar.dig(:assignee, :name) || _('Assignee')) }
- - if issuable_sidebar[:assignee]
- = link_to_member(@project, assignee, size: 24)
- - else
- = icon('user', 'aria-hidden': 'true')
+#js-vue-sidebar-assignees{ data: { field: "#{issuable_type}[assignee_ids]", signed_in: signed_in } }
.title.hide-collapsed
= _('Assignee')
- = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- - if can_edit_issuable
- = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right'
- - if !signed_in
- %a.gutter-toggle.float-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => _('Toggle sidebar') }
- = sidebar_gutter_toggle_icon
- .value.hide-collapsed
- - if issuable_sidebar[:assignee]
- = link_to_member(@project, assignee, size: 32, extra_class: 'bold') do
- - unless issuable_sidebar[:assignee][:can_merge]
- %span.float-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: _('Not allowed to merge') }
- = icon('exclamation-triangle', 'aria-hidden': 'true')
- %span.username
- @#{issuable_sidebar[:assignee][:username]}
- - else
- %span.assign-yourself.no-value
- = _('No assignee')
- - if can_edit_issuable
- \-
- %a.js-assign-yourself{ href: '#' }
- = _('assign yourself')
+ = icon('spinner spin')
.selectbox.hide-collapsed
- if assignees.none?
@@ -59,17 +27,15 @@
ability_name: issuable_type,
null_user: true,
display: 'static' } }
- - title = _('Select assignee')
- - if issuable_type == "issue"
- - dropdown_options = issue_assignees_dropdown_options
- - title = dropdown_options[:title]
- - options[:toggle_class] += ' js-multiselect js-save-user-data'
- - data = { field_name: "#{issuable_type}[assignee_ids][]" }
- - data[:multi_select] = true
- - data['dropdown-title'] = title
- - data['dropdown-header'] = dropdown_options[:data][:'dropdown-header']
- - data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select']
- - options[:data].merge!(data)
+ - dropdown_options = assignees_dropdown_options(issuable_type)
+ - title = dropdown_options[:title]
+ - options[:toggle_class] += ' js-multiselect js-save-user-data'
+ - data = { field_name: "#{issuable_type}[assignee_ids][]" }
+ - data[:multi_select] = true
+ - data['dropdown-title'] = title
+ - data['dropdown-header'] = dropdown_options[:data][:'dropdown-header']
+ - data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select']
+ - options[:data].merge!(data)
= dropdown_tag(title, options: options)
diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml
index b6ea9185b10..1dd97bc4ed1 100644
--- a/app/views/shared/issuable/_sort_dropdown.html.haml
+++ b/app/views/shared/issuable/_sort_dropdown.html.haml
@@ -5,17 +5,18 @@
.dropdown.inline.prepend-left-10.issue-sort-dropdown
.btn-group{ role: 'group' }
.btn-group{ role: 'group' }
- %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' }
+ %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' }
= sort_title
= icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
%li
- = sortable_item(sort_title_priority, page_filter_path(sort: sort_value_priority), sort_title)
- = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date), sort_title)
- = sortable_item(sort_title_recently_updated, page_filter_path(sort: sort_value_recently_updated), sort_title)
- = sortable_item(sort_title_milestone, page_filter_path(sort: sort_value_milestone), sort_title)
- = sortable_item(sort_title_due_date, page_filter_path(sort: sort_value_due_date), sort_title) if viewing_issues
- = sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity), sort_title)
- = sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority), sort_title)
+ = sortable_item(sort_title_priority, page_filter_path(sort: sort_value_priority), sort_title)
+ = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date), sort_title)
+ = sortable_item(sort_title_recently_updated, page_filter_path(sort: sort_value_recently_updated), sort_title)
+ = sortable_item(sort_title_milestone, page_filter_path(sort: sort_value_milestone), sort_title)
+ = sortable_item(sort_title_due_date, page_filter_path(sort: sort_value_due_date), sort_title) if viewing_issues
+ = sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity), sort_title)
+ = sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority), sort_title)
+ = sortable_item(sort_title_relative_position, page_filter_path(sort: sort_value_relative_position), sort_title) if viewing_issues && Feature.enabled?(:manual_sorting)
= render_if_exists('shared/ee/issuable/sort_dropdown', viewing_issues: viewing_issues, sort_title: sort_title)
= issuable_sort_direction_button(sort_value)
diff --git a/app/views/shared/issuable/form/_contribution.html.haml b/app/views/shared/issuable/form/_contribution.html.haml
index bc9a1edc39c..a78231b37ce 100644
--- a/app/views/shared/issuable/form/_contribution.html.haml
+++ b/app/views/shared/issuable/form/_contribution.html.haml
@@ -15,6 +15,6 @@
= form.check_box :allow_collaboration, disabled: !issuable.can_allow_collaboration?(current_user), class: 'form-check-input'
= form.label :allow_collaboration, class: 'form-check-label' do
= _('Allow commits from members who can merge to the target branch.')
- = link_to 'About this feature', help_page_path('user/project/merge_requests/allow_collaboration')
+ = link_to 'About this feature', help_page_path('user/project/merge_requests/allow_collaboration'), target: '_blank', rel: 'noopener noreferrer nofollow'
.form-text.text-muted
= allow_collaboration_unavailable_reason(issuable)
diff --git a/app/views/shared/issuable/form/_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_merge_request_assignee.html.haml
deleted file mode 100644
index 05c03dedd91..00000000000
--- a/app/views/shared/issuable/form/_merge_request_assignee.html.haml
+++ /dev/null
@@ -1,31 +0,0 @@
-- merge_request = issuable
-.block.assignee
- .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: sidebar_assignee_tooltip_label(issuable) }
- - if merge_request.assignee
- = link_to_member(@project, merge_request.assignee, size: 24)
- - else
- = icon('user', 'aria-hidden': 'true')
- .title.hide-collapsed
- Assignee
- = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- - if can_edit_issuable
- = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link float-right'
- .value.hide-collapsed
- - if merge_request.assignee
- = link_to_member(@project, merge_request.assignee, size: 32, extra_class: 'bold') do
- - unless merge_request.can_be_merged_by?(merge_request.assignee)
- %span.float-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' }
- = icon('exclamation-triangle', 'aria-hidden': 'true')
- %span.username
- = merge_request.assignee.to_reference
- - else
- %span.assign-yourself.no-value
- No assignee
- - if can_edit_issuable
- \-
- %a.js-assign-yourself{ href: '#' }
- assign yourself
-
- .selectbox.hide-collapsed
- = f.hidden_field 'assignee_id', value: merge_request.assignee_id, id: 'issue_assignee_id'
- = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: @project&.id, author_id: merge_request.author_id, field_name: 'merge_request[assignee_id]', issue_update: issuable_json_path(merge_request), ability_name: 'merge_request', null_user: true } })
diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml
index e370dff9526..1e03440a5dc 100644
--- a/app/views/shared/issuable/form/_metadata.html.haml
+++ b/app/views/shared/issuable/form/_metadata.html.haml
@@ -8,11 +8,8 @@
%hr
.row
%div{ class: (has_due_date ? "col-lg-6" : "col-12") }
- .form-group.row.issue-assignee
- - if issuable.is_a?(Issue)
- = render "shared/issuable/form/metadata_issue_assignee", issuable: issuable, form: form, has_due_date: has_due_date
- - else
- = render "shared/issuable/form/metadata_merge_request_assignee", issuable: issuable, form: form, has_due_date: has_due_date
+ .form-group.row.merge-request-assignee
+ = render "shared/issuable/form/metadata_issuable_assignee", issuable: issuable, form: form, has_due_date: has_due_date
.form-group.row.issue-milestone
= form.label :milestone_id, "Milestone", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}"
.col-sm-10{ class: ("col-md-8" if has_due_date) }
diff --git a/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml
index 6d4f9ccd66f..5336159e762 100644
--- a/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml
+++ b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml
@@ -1,4 +1,4 @@
-= form.label :assignee_ids, "Assignee", class: "col-form-label #{"col-md-2 col-lg-4" if has_due_date}"
+= form.label :assignee_id, "Assignee", class: "col-form-label #{has_due_date ? "col-lg-4" : "col-sm-2"}"
.col-sm-10{ class: ("col-md-8" if has_due_date) }
.issuable-form-select-holder.selectbox
- issuable.assignees.each do |assignee|
@@ -7,5 +7,5 @@
- if issuable.assignees.length === 0
= hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' }
- = dropdown_tag(users_dropdown_label(issuable.assignees), options: issue_assignees_dropdown_options)
- = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}"
+ = dropdown_tag(users_dropdown_label(issuable.assignees), options: assignees_dropdown_options(issuable.to_ability_name))
+ = link_to 'Assign to me', '#', class: "assign-to-me-link qa-assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}"
diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml
index 56c4b021eab..75e9ab547ce 100644
--- a/app/views/shared/issuable/form/_title.html.haml
+++ b/app/views/shared/issuable/form/_title.html.haml
@@ -6,7 +6,7 @@
%div{ class: div_class }
= form.text_field :title, required: true, maxlength: 255, autofocus: true,
- autocomplete: 'off', class: 'form-control pad qa-issuable-form-title', placeholder: _('Title')
+ autocomplete: 'off', class: 'form-control pad qa-issuable-form-title', placeholder: _('Title'), dir: 'auto'
- if issuable.respond_to?(:work_in_progress?)
.form-text.text-muted
diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml
index 7619d0a2e9c..78ff225daad 100644
--- a/app/views/shared/labels/_form.html.haml
+++ b/app/views/shared/labels/_form.html.haml
@@ -2,15 +2,20 @@
= form_errors(@label)
.form-group.row
- = f.label :title, class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :title
.col-sm-10
- = f.text_field :title, class: "form-control qa-label-title", required: true, autofocus: true
+ = f.text_field :title, class: "form-control js-label-title qa-label-title", required: true, autofocus: true
+ = render_if_exists 'shared/labels/create_label_help_text'
+
.form-group.row
- = f.label :description, class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :description
.col-sm-10
= f.text_field :description, class: "form-control js-quick-submit qa-label-description"
.form-group.row
- = f.label :color, "Background color", class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :color, "Background color"
.col-sm-10
.input-group
.input-group-prepend
@@ -20,12 +25,7 @@
Choose any color.
%br
Or you can choose one of the suggested colors below
-
- .suggest-colors
- - suggested_colors.each do |color|
- = link_to '#', style: "background-color: #{color}", data: { color: color } do
- &nbsp;
-
+ = render_suggested_colors
.form-actions
- if @label.persisted?
= f.submit 'Save changes', class: 'btn btn-success js-save-button'
diff --git a/app/views/shared/members/_access_request_links.html.haml b/app/views/shared/members/_access_request_links.html.haml
index f7227b9101e..eac743b5206 100644
--- a/app/views/shared/members/_access_request_links.html.haml
+++ b/app/views/shared/members/_access_request_links.html.haml
@@ -5,7 +5,7 @@
= link_to link_text, polymorphic_path([:leave, source, :members]),
method: :delete,
data: { confirm: leave_confirmation_message(source) },
- class: 'access-request-link'
+ class: 'access-request-link js-leave-link'
- elsif requester = source.requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord
= link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]),
method: :delete,
diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml
index 9ec76d82d18..e83ca5eaab8 100644
--- a/app/views/shared/members/_group.html.haml
+++ b/app/views/shared/members/_group.html.haml
@@ -2,9 +2,12 @@
- group = group_link.group
- can_admin_member = can?(current_user, :admin_project_member, @project)
- dom_id = "group_member_#{group_link.id}"
-%li.member.group_member{ id: dom_id }
- %span.list-item-name
- = group_icon(group, class: "avatar s40", alt: '')
+
+-# Note this is just for groups. For individual members please see shared/members/_member
+
+%li.member.group_member.py-2.px-3.d-flex.flex-column.flex-md-row{ id: dom_id }
+ %span.list-item-name.mb-2.m-md-0
+ = group_icon(group, class: "avatar s40 flex-shrink-0 flex-grow-0", alt: '')
.user-info
= link_to group.full_name, group_path(group), class: 'member'
.cgray
@@ -13,10 +16,10 @@
·
%span{ class: ('text-warning' if group_link.expires_soon?) }
= _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(group_link.expires_at) }
- .controls.member-controls
- = form_tag project_group_link_path(@project, group_link), method: :put, remote: true, class: 'js-edit-member-form form-group row append-right-5' do
+ .controls.member-controls.align-items-center
+ = form_tag project_group_link_path(@project, group_link), method: :put, remote: true, class: 'js-edit-member-form form-group d-sm-flex' do
= hidden_field_tag "group_link[group_access]", group_link.group_access
- .member-form-control.dropdown.append-right-5
+ .member-form-control.dropdown.mr-sm-2.d-sm-inline-block
%button.dropdown-menu-toggle.js-member-permissions-dropdown{ type: "button",
disabled: !can_admin_member,
data: { toggle: "dropdown", field_name: "group_link[group_access]" } }
@@ -32,14 +35,14 @@
= link_to role, "javascript:void(0)",
class: ("is-active" if group_link.group_access == role_id),
data: { id: role_id, el_id: dom_id }
- .prepend-left-5.clearable-input.member-form-control
+ .clearable-input.member-form-control.d-sm-inline-block
= text_field_tag 'group_link[expires_at]', group_link.expires_at, class: 'form-control js-access-expiration-date js-member-update-control', placeholder: _('Expiration date'), id: "member_expires_at_#{group.id}", disabled: !can_admin_member
%i.clear-icon.js-clear-input
- if can_admin_member
= link_to project_group_link_path(@project, group_link),
method: :delete,
data: { confirm: _("Are you sure you want to remove %{group_name}?") % { group_name: group.name } },
- class: 'btn btn-remove prepend-left-10' do
+ class: 'btn btn-remove m-0 ml-sm-2 align-self-center' do
%span.d-block.d-sm-none
= _("Delete")
= icon('trash', class: 'd-none d-sm-block')
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index 2db1f67a793..331283f7eec 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -4,11 +4,14 @@
- member = local_assigns.fetch(:member)
- user = local_assigns.fetch(:user, member.user)
- source = member.source
+- override = member.try(:override)
-%li.member{ class: dom_class(member), id: dom_id(member) }
- %span.list-item-name
+-# Note this is just for individual members. For groups please see shared/members/_group
+
+%li.member.py-2.px-3.d-flex.flex-column{ class: [dom_class(member), ("is-overridden" if override), ("flex-md-row" unless force_mobile_view)], id: dom_id(member) }
+ %span.list-item-name.mb-2.m-md-0
- if user
- = image_tag avatar_icon_for_user(user, 40), class: "avatar s40", alt: ''
+ = image_tag avatar_icon_for_user(user, 40), class: "avatar s40 flex-shrink-0 flex-grow-0", alt: ''
.user-info
= link_to user.name, user_path(user), class: 'member js-user-link', data: { user_id: user.id }
= user_status(user)
@@ -42,7 +45,7 @@
= _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(member.expires_at) }
- else
- = image_tag avatar_icon_for_email(member.invite_email, 40), class: "avatar s40", alt: ''
+ = image_tag avatar_icon_for_email(member.invite_email, 40), class: "avatar s40 flex-shrink-0 flex-grow-0", alt: ''
.user-info
.member= member.invite_email
.cgray
@@ -53,20 +56,22 @@
= time_ago_with_tooltip(member.created_at)
- if show_roles
- current_resource = @project || @group
- .controls.member-controls
+ .controls.member-controls.align-items-center
+ = render_if_exists 'shared/members/ee/ldap_tag', can_override: member.can_override?
- if show_controls && member.source == current_resource
- if member.can_resend_invite?
= link_to icon('paper-plane'), polymorphic_path([:resend_invite, member]),
method: :post,
- class: 'btn btn-default prepend-left-10 d-none d-sm-block',
+ class: 'btn btn-default align-self-center mr-sm-2',
title: _('Resend invite')
- if user != current_user && member.can_update?
- = form_for member, remote: true, html: { class: 'js-edit-member-form form-group row append-right-5' } do |f|
+ = form_for member, remote: true, html: { class: "js-edit-member-form form-group #{'d-sm-flex' unless force_mobile_view}" } do |f|
= f.hidden_field :access_level
- .member-form-control.dropdown.append-right-5
+ .member-form-control.dropdown{ class: [("mr-sm-2 d-sm-inline-block" unless force_mobile_view)] }
%button.dropdown-menu-toggle.js-member-permissions-dropdown{ type: "button",
+ disabled: member.can_override? && !override,
data: { toggle: "dropdown", field_name: "#{f.object_name}[access_level]" } }
%span.dropdown-toggle-text
= member.human_access
@@ -80,20 +85,25 @@
= link_to role, "javascript:void(0)",
class: ("is-active" if member.access_level == role_id),
data: { id: role_id, el_id: dom_id(member) }
- .prepend-left-5.clearable-input.member-form-control
+ = render_if_exists 'shared/members/ee/revert_ldap_group_sync_option',
+ group: @group,
+ member: member,
+ can_override: member.can_override?
+ .clearable-input.member-form-control{ class: [("d-sm-inline-block" unless force_mobile_view)] }
= f.text_field :expires_at,
+ disabled: member.can_override? && !override,
class: 'form-control js-access-expiration-date js-member-update-control',
placeholder: _('Expiration date'),
id: "member_expires_at_#{member.id}",
data: { el_id: dom_id(member) }
%i.clear-icon.js-clear-input
- else
- %span.member-access-text= member.human_access
+ %span.member-access-text.user-access-role= member.human_access
- if member.can_approve?
= link_to polymorphic_path([:approve_access_request, member]),
method: :post,
- class: 'btn btn-success prepend-left-10',
+ class: "btn btn-success align-self-center m-0 mb-2 #{'mb-sm-0 ml-sm-2' unless force_mobile_view}",
title: _('Grant access') do
%span{ class: ('d-block d-sm-none' unless force_mobile_view) }
= _('Grant access')
@@ -105,16 +115,19 @@
= link_to icon('sign-out', text: _('Leave')), polymorphic_path([:leave, member.source, :members]),
method: :delete,
data: { confirm: leave_confirmation_message(member.source) },
- class: 'btn btn-remove prepend-left-10'
+ class: "btn btn-remove align-self-center m-0 #{'ml-sm-2' unless force_mobile_view}"
- else
= link_to member,
method: :delete,
data: { confirm: remove_member_message(member) },
- class: 'btn btn-remove prepend-left-10',
+ class: "btn btn-remove align-self-center m-0 #{'ml-sm-2' unless force_mobile_view}",
title: remove_member_title(member) do
%span{ class: ('d-block d-sm-none' unless force_mobile_view) }
= _("Delete")
- unless force_mobile_view
= icon('trash', class: 'd-none d-sm-block')
+ = render_if_exists 'shared/members/ee/override_member_buttons', group: @group, member: member, user: user, action: :edit, can_override: member.can_override?
- else
- %span.member-access-text= member.human_access
+ %span.member-access-text.user-access-role= member.human_access
+
+= render_if_exists 'shared/members/ee/override_member_buttons', group: @group, member: member, user: user, action: :confirm, can_override: member.can_override?
diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml
index eba64daaadc..ae3ab2adfd0 100644
--- a/app/views/shared/milestones/_issuable.html.haml
+++ b/app/views/shared/milestones/_issuable.html.haml
@@ -21,8 +21,7 @@
%span.issuable-number= issuable.to_reference
- labels.each do |label|
- = link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, label_name: label.title, state: 'all' }) do
- - render_colored_label(label)
+ = render_label(label.present(issuable_subject: project), link: polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, label_name: label.title, state: 'all' }))
%span.assignee-icon
- assignees.each do |assignee|
diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml
index 6797520650d..ecab037e378 100644
--- a/app/views/shared/milestones/_labels_tab.html.haml
+++ b/app/views/shared/milestones/_labels_tab.html.haml
@@ -2,11 +2,10 @@
- labels.each do |label|
- options = { milestone_title: @milestone.title, label_name: label.title }
- %li.is-not-draggable
+ %li.no-border
%span.label-row
%span.label-name
- = link_to milestones_label_path(options) do
- - render_colored_label(label, tooltip: false)
+ = render_label(label, tooltip: false, link: milestones_label_path(options))
%span.prepend-description-left
= markdown_field(label, :description)
diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml
index 40b8374848e..e99aa3f1ee4 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -32,7 +32,7 @@
= milestone_progress_bar(milestone)
= link_to pluralize(milestone.total_issues_count(current_user), 'Issue'), issues_path
&middot;
- = link_to pluralize(milestone.merge_requests.size, 'Merge Request'), merge_requests_path
+ = 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
@@ -52,7 +52,7 @@
= link_to 'Close Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-sm btn-close btn-grouped"
- unless milestone.active?
- = link_to 'Reopen Milestone', project_milestone_path(@project, milestone, {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen"
+ = link_to 'Reopen Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen"
- if @group
- if can?(current_user, :admin_milestone, @group)
- if milestone.closed?
diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml
index 55460acab8f..b877f66c71e 100644
--- a/app/views/shared/milestones/_tabs.html.haml
+++ b/app/views/shared/milestones/_tabs.html.haml
@@ -12,7 +12,7 @@
%li.nav-item
= link_to '#tab-merge-requests', class: 'nav-link', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do
Merge Requests
- %span.badge.badge-pill= milestone.merge_requests.size
+ %span.badge.badge-pill= milestone.merge_requests_visible_to_user(current_user).size
- else
%li.nav-item
= link_to '#tab-merge-requests', class: 'nav-link active', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do
@@ -21,11 +21,11 @@
%li.nav-item
= link_to '#tab-participants', class: 'nav-link', 'data-toggle' => 'tab', 'data-endpoint': milestone_participants_tab_path(milestone) do
Participants
- %span.badge.badge-pill= milestone.participants.count
+ %span.badge.badge-pill= milestone.issue_participants_visible_by_user(current_user).count
%li.nav-item
= link_to '#tab-labels', class: 'nav-link', 'data-toggle' => 'tab', 'data-endpoint': milestone_labels_tab_path(milestone) do
Labels
- %span.badge.badge-pill= milestone.labels.count
+ %span.badge.badge-pill= milestone.issue_labels_visible_by_user(current_user).count
- issues = milestone.sorted_issues(current_user)
- show_project_name = local_assigns.fetch(:show_project_name, false)
diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml
index 55b1c14022f..43503e1d08a 100644
--- a/app/views/shared/milestones/_top.html.haml
+++ b/app/views/shared/milestones/_top.html.haml
@@ -4,10 +4,7 @@
- group = local_assigns[:group]
- is_dynamic_milestone = milestone.legacy_group_milestone? || milestone.dashboard_milestone?
-.detail-page-header
- %a.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ href: "#" }
- = icon('angle-double-left')
-
+.detail-page-header.milestone-page-header
.status-box{ class: "status-box-#{milestone.closed? ? 'closed' : 'open'}" }
- if milestone.closed?
Closed
@@ -15,14 +12,17 @@
Expired
- else
Open
- %span.identifier
- Milestone #{milestone.title}
- - if milestone.due_date || milestone.start_date
- %span.creator
- &nbsp;&middot;
- = milestone_date_range(milestone)
- - if group
- .float-right
+
+ .header-text-content
+ %span.identifier
+ Milestone #{milestone.title}
+ - if milestone.due_date || milestone.start_date
+ %span.creator
+ &nbsp;&middot;
+ = milestone_date_range(milestone)
+
+ .milestone-buttons
+ - if group
- if can?(current_user, :admin_milestone, group)
- if milestone.group_milestone?
= link_to edit_group_milestone_path(group, milestone), class: "btn btn btn-grouped" do
@@ -35,6 +35,9 @@
- unless is_dynamic_milestone
= render 'shared/milestones/delete_button'
+ %a.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ href: "#" }
+ = icon('angle-double-left')
+
= render 'shared/milestones/deprecation_message' if is_dynamic_milestone
.detail-page-description.milestone-detail
@@ -42,9 +45,8 @@
= markdown_field(milestone, :title)
- if milestone.group_milestone? && milestone.description.present?
%div
- .description
- .wiki
- = markdown_field(milestone, :description)
+ .description.md
+ = markdown_field(milestone, :description)
- if milestone.complete?(current_user) && milestone.active?
.alert.alert-success.prepend-top-default
diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml
index 6a1eea85fde..d91bc6e57c9 100644
--- a/app/views/shared/notes/_form.html.haml
+++ b/app/views/shared/notes/_form.html.haml
@@ -1,7 +1,7 @@
- supports_autocomplete = local_assigns.fetch(:supports_autocomplete, true)
- supports_quick_actions = note_supports_quick_actions?(@note)
- if supports_quick_actions
- - preview_url = preview_markdown_path(@project, quick_actions_target_type: @note.noteable_type, quick_actions_target_id: @note.noteable_id)
+ - preview_url = preview_markdown_path(@project, target_type: @note.noteable_type, target_id: @note.noteable_id)
- else
- preview_url = preview_markdown_path(@project)
diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml
index 46f3f8428f1..fae7d6526e8 100644
--- a/app/views/shared/notes/_hints.html.haml
+++ b/app/views/shared/notes/_hints.html.haml
@@ -28,8 +28,9 @@
or
%button.attach-new-file.markdown-selector{ type: 'button' }= _("attach a new file")
- %button.markdown-selector.button-attach-file{ type: 'button', tabindex: '-1' }
+ %button.markdown-selector.button-attach-file.btn-link{ type: 'button', tabindex: '-1' }
= icon('file-image-o', class: 'toolbar-button-icon')
- = _("Attach a file")
+ %span.text-attach-file<>
+ = _("Attach a file")
%button.btn.btn-default.btn-sm.hide.button-cancel-uploading-files{ type: 'button' }= _("Cancel")
diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml
index 41d6ae79c81..5c9dd72418e 100644
--- a/app/views/shared/notes/_note.html.haml
+++ b/app/views/shared/notes/_note.html.haml
@@ -31,19 +31,18 @@
.note-header
.note-header-info
%a{ href: user_path(note.author) }
- %span.note-header-author-name
+ %span.note-header-author-name.bold
= sanitize(note.author.name)
= user_status(note.author)
%span.note-headline-light
= note.author.to_reference
- %span.note-headline-light
- %span.note-headline-meta
- - if note.system
- %span.system-note-message
- = markdown_field(note, :note)
- %span.system-note-separator
- &middot;
- %a.system-note-separator{ href: "##{dom_id(note)}" }= time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago')
+ %span.note-headline-light.note-headline-meta
+ - if note.system
+ %span.system-note-message
+ = markdown_field(note, :note)
+ %span.system-note-separator
+ &middot;
+ %a.system-note-separator{ href: "##{dom_id(note)}" }= time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago')
- unless note.system?
.note-actions
- if note.for_personal_snippet?
diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml
index 2ece7b7f701..749aa258af6 100644
--- a/app/views/shared/notifications/_button.html.haml
+++ b/app/views/shared/notifications/_button.html.haml
@@ -1,24 +1,26 @@
- btn_class = local_assigns.fetch(:btn_class, nil)
- if notification_setting
- .js-notification-dropdown.notification-dropdown.home-panel-action-button.dropdown.inline
+ .js-notification-dropdown.notification-dropdown.mr-md-2.home-panel-action-button.dropdown.inline
= form_for notification_setting, remote: true, html: { class: "inline notification-form" } do |f|
= hidden_setting_source_input(notification_setting)
= f.hidden_field :level, class: "notification_setting_level"
.js-notification-toggle-btns
%div{ class: ("btn-group" if notification_setting.custom?) }
- if notification_setting.custom?
- %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting"), class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } }
+ %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn.text-left#notifications-button{ type: "button", title: _("Notification setting"), class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } }
= icon("bell", class: "js-notification-loading")
= notification_title(notification_setting.level)
%button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
= icon('caret-down')
.sr-only Toggle dropdown
- else
- %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", class: "#{btn_class}", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
- = icon("bell", class: "js-notification-loading")
- = notification_title(notification_setting.level)
- = icon("caret-down")
+ %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting"), class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
+ .float-left
+ = icon("bell", class: "js-notification-loading")
+ = notification_title(notification_setting.level)
+ .float-right
+ = icon("caret-down")
= render "shared/notifications/notification_dropdown", notification_setting: notification_setting
diff --git a/app/views/shared/notifications/_new_button.html.haml b/app/views/shared/notifications/_new_button.html.haml
index 6d26dbebbc8..052e6da5bae 100644
--- a/app/views/shared/notifications/_new_button.html.haml
+++ b/app/views/shared/notifications/_new_button.html.haml
@@ -10,14 +10,14 @@
%div{ class: ("btn-group" if notification_setting.custom?) }
- if notification_setting.custom?
%button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", placement: 'top', toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } }
- = sprite_icon("notifications", css_class: "icon notifications-icon js-notifications-icon")
+ = notification_setting_icon(notification_setting)
%span.js-notification-loading.fa.hidden
%button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" }, class: "#{btn_class}" }
= sprite_icon("arrow-down", css_class: "icon mr-0")
.sr-only Toggle dropdown
- else
- %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting - #{notification_title(notification_setting.level)}", class: "#{btn_class}", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", placement: 'top', toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
- = sprite_icon("notifications", css_class: "icon notifications-icon js-notifications-icon")
+ %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", placement: 'top', toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
+ = notification_setting_icon(notification_setting)
%span.js-notification-loading.fa.hidden
= sprite_icon("arrow-down", css_class: "icon")
diff --git a/app/views/shared/notifications/_notification_dropdown.html.haml b/app/views/shared/notifications/_notification_dropdown.html.haml
index 85ad74f9a39..a6ef2d51171 100644
--- a/app/views/shared/notifications/_notification_dropdown.html.haml
+++ b/app/views/shared/notifications/_notification_dropdown.html.haml
@@ -8,5 +8,5 @@
%li.divider
%li
%a.update-notification{ href: "#", role: "button", class: ("is-active" if notification_setting.custom?), data: { toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), notification_level: "custom", notification_title: "Custom" } }
- %strong.dropdown-menu-inner-title Custom
+ %strong.dropdown-menu-inner-title= s_('NotificationSetting|Custom')
%span.dropdown-menu-inner-content= notification_description("custom")
diff --git a/app/views/shared/projects/_dropdown.html.haml b/app/views/shared/projects/_dropdown.html.haml
index 98b258d9275..88ac03bf9e3 100644
--- a/app/views/shared/projects/_dropdown.html.haml
+++ b/app/views/shared/projects/_dropdown.html.haml
@@ -1,10 +1,9 @@
- @sort ||= sort_value_latest_activity
.dropdown.js-project-filter-dropdown-wrap
- - toggle_text = projects_sort_options_hash[@sort]
- = dropdown_toggle(toggle_text, { toggle: 'dropdown', display: 'static' }, { id: 'sort-projects-dropdown' })
+ = dropdown_toggle(projects_sort_options_hash[@sort], { toggle: 'dropdown', display: 'static' }, { id: 'sort-projects-dropdown' })
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable
%li.dropdown-header
- Sort by
+ = _("Sort by")
- projects_sort_options_hash.each do |value, title|
%li
= link_to filter_projects_path(sort: value), class: ("is-active" if @sort == value) do
@@ -13,29 +12,29 @@
%li.divider
%li
= link_to filter_projects_path(archived: nil), class: ("is-active" unless params[:archived].present?) do
- Hide archived projects
+ = _("Hide archived projects")
%li
= link_to filter_projects_path(archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do
- Show archived projects
+ = _("Show archived projects")
%li
= link_to filter_projects_path(archived: 'only'), class: ("is-active" if params[:archived] == 'only') do
- Show archived projects only
+ = _("Show archived projects only")
- if current_user
%li.divider
%li
= link_to filter_projects_path(personal: nil), class: ("is-active" unless params[:personal].present?) do
- Owned by anyone
+ = _("Owned by anyone")
%li
= link_to filter_projects_path(personal: true), class: ("is-active" if params[:personal].present?) do
- Owned by me
+ = _("Owned by me")
- if @group && @group.shared_projects.present?
%li.divider
%li
= link_to filter_projects_path(shared: nil), class: ("is-active" unless params[:shared].present?) do
- All projects
+ = _("All projects")
%li
= link_to filter_projects_path(shared: 0), class: ("is-active" if params[:shared] == '0') do
- Hide shared projects
+ = _("Hide shared projects")
%li
= link_to filter_projects_path(shared: 1), class: ("is-active" if params[:shared] == '1') do
- Hide group projects
+ = _("Hide group projects")
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index f1a87faa7ac..90fb067e75d 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -10,7 +10,7 @@
- show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && can_show_last_commit_in_list?(project)
- css_class = '' unless local_assigns[:css_class]
- css_class += " no-description" if project.description.blank? && !show_last_commit_as_description
-- cache_key = project_list_cache_key(project)
+- cache_key = project_list_cache_key(project, pipeline_status: pipeline_status)
- updated_tooltip = time_ago_with_tooltip(project.last_activity_date)
- css_controls_class = compact_mode ? "" : "flex-lg-row justify-content-lg-between"
- avatar_container_class = project.creator && use_creator_avatar ? '' : 'rect-avatar'
@@ -85,7 +85,8 @@
= sprite_icon('issues', size: 14, css_class: 'append-right-4')
= number_with_delimiter(project.open_issues_count)
- if pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project)
+ - pipeline_path = pipelines_project_commit_path(project.pipeline_status.project, project.pipeline_status.sha, ref: project.pipeline_status.ref)
%span.icon-wrapper.pipeline-status
- = render_project_pipeline_status(project.pipeline_status, tooltip_placement: 'top')
+ = render 'ci/status/icon', status: project.commit.last_pipeline.detailed_status(current_user), type: 'commit', tooltip_placement: 'top', path: pipeline_path
.updated-note
%span Updated #{updated_tooltip}
diff --git a/app/views/shared/projects/_search_bar.html.haml b/app/views/shared/projects/_search_bar.html.haml
new file mode 100644
index 00000000000..c1f2eaba284
--- /dev/null
+++ b/app/views/shared/projects/_search_bar.html.haml
@@ -0,0 +1,28 @@
+- @sort ||= sort_value_latest_activity
+- project_tab_filter = local_assigns.fetch(:project_tab_filter, "")
+- flex_grow_and_shrink_xs = 'd-flex flex-xs-grow-1 flex-xs-shrink-1 flex-grow-0 flex-shrink-0'
+
+.filtered-search-block.row-content-block.bt-0
+ .filtered-search-wrapper.d-flex.flex-nowrap.flex-column.flex-sm-wrap.flex-sm-row.flex-xl-nowrap
+ - unless project_tab_filter == :starred
+ .filtered-search-nav.mb-2.mb-lg-0{ class: flex_grow_and_shrink_xs }
+ = render 'dashboard/projects/nav', project_tab_filter: project_tab_filter
+ .filtered-search.d-flex.flex-grow-1.flex-shrink-1.w-100.mb-2.mb-lg-0.ml-0{ class: project_tab_filter == :starred ? "extended-filtered-search-box mb-2 mb-lg-0" : "ml-sm-3" }
+ .btn-group.w-100{ role: "group" }
+ .btn-group.w-100{ role: "group" }
+ .filtered-search-box.m-0
+ .filtered-search-box-input-container.pl-2
+ = render 'shared/projects/search_form', admin_view: false, search_form_placeholder: _("Search projects...")
+ %button.btn.btn-secondary{ type: 'submit', form: 'project-filter-form' }
+ = sprite_icon('search', size: 16, css_class: 'search-icon ')
+ .filtered-search-dropdown.flex-row.align-items-center.mb-2.m-sm-0#filtered-search-visibility-dropdown{ class: flex_grow_and_shrink_xs }
+ .filtered-search-dropdown-label.p-0.pl-sm-3.font-weight-bold
+ %span
+ = _("Visibility")
+ = render 'explore/projects/filter', has_label: true
+ .filtered-search-dropdown.flex-row.align-items-center.m-sm-0#filtered-search-sorting-dropdown{ class: flex_grow_and_shrink_xs }
+ .filtered-search-dropdown-label.p-0.pl-sm-3.font-weight-bold
+ %span
+ = _("Sort by")
+ = render 'shared/projects/sort_dropdown'
+
diff --git a/app/views/shared/projects/_search_form.html.haml b/app/views/shared/projects/_search_form.html.haml
index 3b5c13ed93a..7c7c0a363ac 100644
--- a/app/views/shared/projects/_search_form.html.haml
+++ b/app/views/shared/projects/_search_form.html.haml
@@ -1,7 +1,10 @@
+- form_field_classes = local_assigns[:admin_view] || !Feature.enabled?(:project_list_filter_bar) ? 'input-short js-projects-list-filter' : ''
+- placeholder = local_assigns[:search_form_placeholder] ? search_form_placeholder : 'Filter by name...'
+
= form_tag filter_projects_path, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f|
= search_field_tag :name, params[:name],
- placeholder: 'Filter by name...',
- class: 'project-filter-form-field form-control input-short js-projects-list-filter',
+ placeholder: placeholder,
+ class: "project-filter-form-field form-control #{form_field_classes}",
spellcheck: false,
id: 'project-filter-form-field',
tabindex: "2",
diff --git a/app/views/shared/projects/_sort_dropdown.html.haml b/app/views/shared/projects/_sort_dropdown.html.haml
new file mode 100644
index 00000000000..f5f940db189
--- /dev/null
+++ b/app/views/shared/projects/_sort_dropdown.html.haml
@@ -0,0 +1,39 @@
+- @sort ||= sort_value_latest_activity
+- toggle_text = projects_sort_option_titles[@sort]
+
+.btn-group.w-100{ role: "group" }
+ .btn-group.w-100.dropdown.js-project-filter-dropdown-wrap{ role: "group" }
+ %button#sort-projects-dropdown.btn.btn-default.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } }
+ = toggle_text
+ = icon('chevron-down')
+ %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable
+ %li.dropdown-header
+ = _("Sort by")
+ - projects_sort_options_hash.each do |value, title|
+ %li
+ = link_to title, filter_projects_path(sort: value), class: ("is-active" if toggle_text == title)
+
+ %li.divider
+ %li
+ = link_to filter_projects_path(archived: nil), class: ("is-active" unless params[:archived].present?) do
+ = _("Hide archived projects")
+ %li
+ = link_to filter_projects_path(archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do
+ = _("Show archived projects")
+ %li
+ = link_to filter_projects_path(archived: 'only'), class: ("is-active" if params[:archived] == 'only') do
+ = _("Show archived projects only")
+
+ - if current_user && @group && @group.shared_projects.present?
+ %li.divider
+ %li
+ = link_to filter_projects_path(shared: nil), class: ("is-active" unless params[:shared].present?) do
+ = _("All projects")
+ %li
+ = link_to filter_projects_path(shared: 0), class: ("is-active" if params[:shared] == '0') do
+ = _("Hide shared projects")
+ %li
+ = link_to filter_projects_path(shared: 1), class: ("is-active" if params[:shared] == '1') do
+ = _("Hide group projects")
+
+ = project_sort_direction_button(@sort)
diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml
index 3007da0c189..2d2382e469a 100644
--- a/app/views/shared/snippets/_form.html.haml
+++ b/app/views/shared/snippets/_form.html.haml
@@ -7,9 +7,10 @@
= form_errors(@snippet)
.form-group.row
- = f.label :title, class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :title
.col-sm-10
- = f.text_field :title, class: 'form-control', required: true, autofocus: true
+ = f.text_field :title, class: 'form-control qa-snippet-title', required: true, autofocus: true
= render 'shared/form_elements/description', model: @snippet, project: @project, form: f
@@ -17,11 +18,12 @@
.file-editor
.form-group.row
- = f.label :file_name, "File", class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :file_name, "File"
.col-sm-10
.file-holder.snippet
.js-file-title.file-title
- = f.text_field :file_name, placeholder: "Optionally name this file to add code highlighting, e.g. example.rb for Ruby.", class: 'form-control snippet-file-name'
+ = f.text_field :file_name, placeholder: "Optionally name this file to add code highlighting, e.g. example.rb for Ruby.", class: 'form-control snippet-file-name qa-snippet-file-name'
.file-content.code
%pre#editor= @snippet.content
= f.hidden_field :content, class: 'snippet-file-content'
@@ -31,7 +33,7 @@
.form-actions
- if @snippet.new_record?
- = f.submit 'Create snippet', class: "btn-success btn"
+ = f.submit 'Create snippet', class: "btn-success btn qa-create-snippet-button"
- else
= f.submit 'Save changes', class: "btn-success btn"
diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml
index a43296aa806..ebb634fe75f 100644
--- a/app/views/shared/snippets/_header.html.haml
+++ b/app/views/shared/snippets/_header.html.haml
@@ -1,6 +1,6 @@
.detail-page-header
.detail-page-header-body
- .snippet-box.has-tooltip.inline.append-right-5{ title: snippet_visibility_level_description(@snippet.visibility_level, @snippet), data: { container: "body" } }
+ .snippet-box.qa-snippet-box.has-tooltip.inline.append-right-5{ title: snippet_visibility_level_description(@snippet.visibility_level, @snippet), data: { container: "body" } }
%span.sr-only
= visibility_level_label(@snippet.visibility_level)
= visibility_level_icon(@snippet.visibility_level, fw: false)
@@ -17,12 +17,12 @@
= render "snippets/actions"
.snippet-header.limited-header-width
- %h2.snippet-title.prepend-top-0.append-bottom-0
+ %h2.snippet-title.prepend-top-0.append-bottom-0.qa-snippet-title
= markdown_field(@snippet, :title)
- if @snippet.description.present?
- .description
- .wiki
+ .description.qa-snippet-description
+ .md
= markdown_field(@snippet, :description)
%textarea.hidden.js-task-list-field
= @snippet.description
@@ -34,7 +34,7 @@
.embed-snippet
.input-group
.input-group-prepend
- %button.btn.btn-svg.embed-toggle.input-group-text{ 'data-toggle': 'dropdown', type: 'button' }
+ %button.btn.btn-svg.embed-toggle.input-group-text.qa-embed-type{ 'data-toggle': 'dropdown', type: 'button' }
%span.js-embed-action= _("Embed")
= sprite_icon('angle-down', size: 12, css_class: 'caret-down')
%ul.dropdown-menu.dropdown-menu-selectable.embed-toggle-list
diff --git a/app/views/snippets/_actions.html.haml b/app/views/snippets/_actions.html.haml
index ef8664e6f47..9952f373156 100644
--- a/app/views/snippets/_actions.html.haml
+++ b/app/views/snippets/_actions.html.haml
@@ -7,7 +7,7 @@
- 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
= _("Delete")
- = link_to new_snippet_path, class: "btn btn-grouped btn-inverted btn-create", title: _("New snippet") do
+ = 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')
diff --git a/app/views/snippets/new.html.haml b/app/views/snippets/new.html.haml
index 114c777bdc2..9d462865471 100644
--- a/app/views/snippets/new.html.haml
+++ b/app/views/snippets/new.html.haml
@@ -2,7 +2,7 @@
- @hide_breadcrumbs = true
- page_title _("New Snippet")
-.page-title-holder
+.page-title-holder.d-flex.align-items-center
%h1.page-title= _('New Snippet')
.prepend-top-default
diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml
index 01b95145937..6e20890a47f 100644
--- a/app/views/snippets/notes/_actions.html.haml
+++ b/app/views/snippets/notes/_actions.html.haml
@@ -3,9 +3,9 @@
.note-actions-item
= link_to '#', title: _('Add reaction'), class: "note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip", data: { position: 'right' } do
= icon('spinner spin')
- %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face')
- %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley')
- %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile')
+ %span{ class: 'link-highlight award-control-icon-neutral' }= sprite_icon('slight-smile')
+ %span{ class: 'link-highlight award-control-icon-positive' }= sprite_icon('smiley')
+ %span{ class: 'link-highlight award-control-icon-super-positive' }= sprite_icon('smile')
- if note_editable
.note-actions-item
diff --git a/app/views/users/calendar_activities.html.haml b/app/views/users/calendar_activities.html.haml
index 01acbf8eadd..3191eaa1e2c 100644
--- a/app/views/users/calendar_activities.html.haml
+++ b/app/views/users/calendar_activities.html.haml
@@ -9,7 +9,7 @@
%i.fa.fa-clock-o
= event.created_at.to_time.in_time_zone.strftime('%-I:%M%P')
- if event.visible_to_user?(current_user)
- - if event.push?
+ - if event.push_action?
#{event.action_name} #{event.ref_type}
%strong
- commits_path = project_commits_path(event.project, event.ref_name)
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 211e3eafac6..a71bfd624e4 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -9,7 +9,7 @@
= auto_discovery_link_tag(:atom, user_url(@user, format: :atom), title: "#{@user.name} activity")
.user-profile
- .cover-block.user-cover-block.top-area
+ .cover-block.user-cover-block
.cover-controls
- if @user == current_user
= link_to profile_path, class: 'btn btn-default has-tooltip', title: s_('UserProfile|Edit profile'), 'aria-label': 'Edit profile' do
@@ -45,7 +45,7 @@
= emoji_icon(@user.status.emoji)
= markdown_field(@user.status, :message)
- .cover-desc.member-date
+ .cover-desc.member-date.cgray
%p
%span.middle-dot-divider
@#{@user.username}
@@ -53,7 +53,7 @@
%span.middle-dot-divider
= s_('Member since %{date}') % { date: @user.created_at.to_date.to_s(:long) }
- .cover-desc
+ .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'
@@ -82,7 +82,7 @@
= @user.organization
- if @user.bio.present?
- .cover-desc
+ .cover-desc.cgray
%p.profile-user-bio
= @user.bio
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index d0fc130b04f..fd0cc5fb24e 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -1,11 +1,14 @@
---
- auto_devops:auto_devops_disable
+- auto_merge:auto_merge_process
+
- cronjob:admin_email
- cronjob:expire_build_artifacts
- cronjob:gitlab_usage_ping
- cronjob:import_export_project_cleanup
- cronjob:pages_domain_verification_cron
+- cronjob:pages_domain_removal_cron
- cronjob:pipeline_schedule
- cronjob:prune_old_events
- cronjob:remove_expired_group_links
@@ -21,8 +24,10 @@
- cronjob:trending_projects
- cronjob:issue_due_scheduler
- cronjob:prune_web_hook_logs
+- cronjob:schedule_migrate_external_diffs
- gcp_cluster:cluster_install_app
+- gcp_cluster:cluster_patch_app
- gcp_cluster:cluster_upgrade_app
- gcp_cluster:cluster_provision
- gcp_cluster:cluster_wait_for_app_installation
@@ -30,6 +35,8 @@
- gcp_cluster:cluster_wait_for_ingress_ip_address
- gcp_cluster:cluster_configure
- gcp_cluster:cluster_project_configure
+- gcp_cluster:clusters_applications_wait_for_uninstall_app
+- gcp_cluster:clusters_applications_uninstall
- github_import_advance_stage
- github_importer:github_import_import_diff_note
@@ -47,6 +54,9 @@
- github_importer:github_import_stage_import_repository
- hashed_storage:hashed_storage_migrator
+- hashed_storage:hashed_storage_rollbacker
+- hashed_storage:hashed_storage_project_migrate
+- hashed_storage:hashed_storage_project_rollback
- mail_scheduler:mail_scheduler_issue_due
- mail_scheduler:mail_scheduler_notification_service
@@ -67,6 +77,7 @@
- pipeline_hooks:build_hooks
- pipeline_hooks:pipeline_hooks
- pipeline_processing:build_finished
+- pipeline_processing:ci_build_prepare
- pipeline_processing:build_queue
- pipeline_processing:build_success
- pipeline_processing:pipeline_process
@@ -77,6 +88,7 @@
- pipeline_processing:ci_build_schedule
- deployment:deployments_success
+- deployment:deployments_finished
- repository_check:repository_check_clear
- repository_check:repository_check_batch
@@ -114,6 +126,7 @@
- invalid_gpg_signature_update
- irker
- merge
+- migrate_external_diffs
- namespaceless_project_destroy
- new_issue
- new_merge_request
@@ -126,7 +139,6 @@
- project_cache
- project_destroy
- project_export
-- project_migrate_hashed_storage
- project_service
- propagate_service_template
- reactive_caching
@@ -137,6 +149,7 @@
- repository_remove_remote
- system_hook_push
- update_merge_requests
+- update_project_statistics
- upload_checksum
- web_hook
- repository_update_remote_mirror
@@ -146,3 +159,4 @@
- repository_cleanup
- delete_stored_files
- import_issues_csv
+- project_daily_statistics
diff --git a/app/workers/auto_merge_process_worker.rb b/app/workers/auto_merge_process_worker.rb
new file mode 100644
index 00000000000..cd81cdbc60c
--- /dev/null
+++ b/app/workers/auto_merge_process_worker.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class AutoMergeProcessWorker
+ include ApplicationWorker
+
+ queue_namespace :auto_merge
+
+ def perform(merge_request_id)
+ MergeRequest.find_by_id(merge_request_id).try do |merge_request|
+ AutoMergeService.new(merge_request.project, merge_request.merge_user)
+ .process(merge_request)
+ end
+ end
+end
diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb
index adc38226405..8e2a18a8fd8 100644
--- a/app/workers/build_finished_worker.rb
+++ b/app/workers/build_finished_worker.rb
@@ -30,6 +30,7 @@ class BuildFinishedWorker
# We execute these async as these are independent operations.
BuildHooksWorker.perform_async(build.id)
ArchiveTraceWorker.perform_async(build.id)
+ ExpirePipelineCacheWorker.perform_async(build.pipeline_id)
ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat?
end
end
diff --git a/app/workers/ci/build_prepare_worker.rb b/app/workers/ci/build_prepare_worker.rb
new file mode 100644
index 00000000000..1a35a74ae53
--- /dev/null
+++ b/app/workers/ci/build_prepare_worker.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Ci
+ class BuildPrepareWorker
+ include ApplicationWorker
+ include PipelineQueue
+
+ queue_namespace :pipeline_processing
+
+ def perform(build_id)
+ Ci::Build.find_by_id(build_id).try do |build|
+ Ci::PrepareBuildService.new(build).execute
+ end
+ end
+ end
+end
diff --git a/app/workers/cluster_configure_worker.rb b/app/workers/cluster_configure_worker.rb
index 63e6cc147be..6f64b7ea0ab 100644
--- a/app/workers/cluster_configure_worker.rb
+++ b/app/workers/cluster_configure_worker.rb
@@ -5,8 +5,10 @@ class ClusterConfigureWorker
include ClusterQueue
def perform(cluster_id)
- Clusters::Cluster.find_by_id(cluster_id).try do |cluster|
- Clusters::RefreshService.create_or_update_namespaces_for_cluster(cluster)
+ Clusters::Cluster.managed.find_by_id(cluster_id).try do |cluster|
+ if cluster.project_type?
+ Clusters::RefreshService.create_or_update_namespaces_for_cluster(cluster)
+ end
end
end
end
diff --git a/app/workers/cluster_patch_app_worker.rb b/app/workers/cluster_patch_app_worker.rb
new file mode 100644
index 00000000000..0549e81ed05
--- /dev/null
+++ b/app/workers/cluster_patch_app_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class ClusterPatchAppWorker
+ include ApplicationWorker
+ include ClusterQueue
+ include ClusterApplications
+
+ def perform(app_name, app_id)
+ find_application(app_name, app_id) do |app|
+ Clusters::Applications::PatchService.new(app).execute
+ end
+ end
+end
diff --git a/app/workers/clusters/applications/uninstall_worker.rb b/app/workers/clusters/applications/uninstall_worker.rb
new file mode 100644
index 00000000000..85e8ecc4ad5
--- /dev/null
+++ b/app/workers/clusters/applications/uninstall_worker.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class UninstallWorker
+ include ApplicationWorker
+ include ClusterQueue
+ include ClusterApplications
+
+ def perform(app_name, app_id)
+ find_application(app_name, app_id) do |app|
+ Clusters::Applications::UninstallService.new(app).execute
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb b/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb
new file mode 100644
index 00000000000..163c99d3c3c
--- /dev/null
+++ b/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class WaitForUninstallAppWorker
+ include ApplicationWorker
+ include ClusterQueue
+ include ClusterApplications
+
+ INTERVAL = 10.seconds
+ TIMEOUT = 20.minutes
+
+ def perform(app_name, app_id)
+ find_application(app_name, app_id) do |app|
+ Clusters::Applications::CheckUninstallProgressService.new(app).execute
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb
index d64c2f82a09..25c3a945077 100644
--- a/app/workers/concerns/application_worker.rb
+++ b/app/workers/concerns/application_worker.rb
@@ -53,7 +53,7 @@ module ApplicationWorker
schedule = now + delay.to_i
if schedule <= now
- raise ArgumentError, 'The schedule time must be in the future!'
+ raise ArgumentError, _('The schedule time must be in the future!')
end
Sidekiq::Client.push_bulk('class' => self, 'args' => args_list, 'at' => schedule)
diff --git a/app/workers/concerns/waitable_worker.rb b/app/workers/concerns/waitable_worker.rb
index 27b94a82444..17946bbc5ca 100644
--- a/app/workers/concerns/waitable_worker.rb
+++ b/app/workers/concerns/waitable_worker.rb
@@ -25,11 +25,9 @@ module WaitableWorker
failed = []
args_list.each do |args|
- begin
- new.perform(*args)
- rescue
- failed << args
- end
+ new.perform(*args)
+ rescue
+ failed << args
end
bulk_perform_async(failed) if failed.present?
diff --git a/app/workers/create_gpg_signature_worker.rb b/app/workers/create_gpg_signature_worker.rb
index 49c7a403838..7fac7822cf7 100644
--- a/app/workers/create_gpg_signature_worker.rb
+++ b/app/workers/create_gpg_signature_worker.rb
@@ -5,8 +5,8 @@ class CreateGpgSignatureWorker
# rubocop: disable CodeReuse/ActiveRecord
def perform(commit_shas, project_id)
- # Older versions of GitPushService may push a single commit ID on the stack.
- # We need this to be backwards compatible.
+ # Older versions of Git::BranchPushService may push a single commit ID on
+ # the stack. We need this to be backwards compatible.
commit_shas = Array(commit_shas)
return if commit_shas.empty?
@@ -20,11 +20,9 @@ class CreateGpgSignatureWorker
# This calculates and caches the signature in the database
commits.each do |commit|
- begin
- Gitlab::Gpg::Commit.new(commit).signature
- rescue => e
- Rails.logger.error("Failed to create signature for commit #{commit.id}. Error: #{e.message}")
- end
+ Gitlab::Gpg::Commit.new(commit).signature
+ rescue => e
+ Rails.logger.error("Failed to create signature for commit #{commit.id}. Error: #{e.message}")
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/workers/deployments/finished_worker.rb b/app/workers/deployments/finished_worker.rb
new file mode 100644
index 00000000000..c9d448d5d18
--- /dev/null
+++ b/app/workers/deployments/finished_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Deployments
+ class FinishedWorker
+ include ApplicationWorker
+
+ queue_namespace :deployment
+
+ def perform(deployment_id)
+ Deployment.find_by_id(deployment_id).try(:execute_hooks)
+ end
+ end
+end
diff --git a/app/workers/detect_repository_languages_worker.rb b/app/workers/detect_repository_languages_worker.rb
index 64bc9776d48..838c3be78f0 100644
--- a/app/workers/detect_repository_languages_worker.rb
+++ b/app/workers/detect_repository_languages_worker.rb
@@ -12,13 +12,12 @@ class DetectRepositoryLanguagesWorker
attr_reader :project
# rubocop: disable CodeReuse/ActiveRecord
- def perform(project_id, user_id)
+ def perform(project_id, user_id = nil)
@project = Project.find_by(id: project_id)
- user = User.find_by(id: user_id)
- return unless project && user
+ return unless project
try_obtain_lease do
- ::Projects::DetectRepositoryLanguagesService.new(project, user).execute
+ ::Projects::DetectRepositoryLanguagesService.new(project).execute
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb
index bf637f82df2..c4bcda2da16 100644
--- a/app/workers/email_receiver_worker.rb
+++ b/app/workers/email_receiver_worker.rb
@@ -24,22 +24,22 @@ class EmailReceiverWorker
reason =
case error
when Gitlab::Email::UnknownIncomingEmail
- "We couldn't figure out what the email is for. Please create your issue or comment through the web interface."
+ s_("EmailError|We couldn't figure out what the email is for. Please create your issue or comment through the web interface.")
when Gitlab::Email::SentNotificationNotFoundError
- "We couldn't figure out what the email is in reply to. Please create your comment through the web interface."
+ s_("EmailError|We couldn't figure out what the email is in reply to. Please create your comment through the web interface.")
when Gitlab::Email::ProjectNotFound
- "We couldn't find the project. Please check if there's any typo."
+ s_("EmailError|We couldn't find the project. Please check if there's any typo.")
when Gitlab::Email::EmptyEmailError
can_retry = true
- "It appears that the email is blank. Make sure your reply is at the top of the email, we can't process inline replies."
+ s_("EmailError|It appears that the email is blank. Make sure your reply is at the top of the email, we can't process inline replies.")
when Gitlab::Email::UserNotFoundError
- "We couldn't figure out what user corresponds to the email. Please create your comment through the web interface."
+ s_("EmailError|We couldn't figure out what user corresponds to the email. Please create your comment through the web interface.")
when Gitlab::Email::UserBlockedError
- "Your account has been blocked. If you believe this is in error, contact a staff member."
+ s_("EmailError|Your account has been blocked. If you believe this is in error, contact a staff member.")
when Gitlab::Email::UserNotAuthorizedError
- "You are not allowed to perform this action. If you believe this is in error, contact a staff member."
+ s_("EmailError|You are not allowed to perform this action. If you believe this is in error, contact a staff member.")
when Gitlab::Email::NoteableNotFoundError
- "The thread you are replying to no longer exists, perhaps it was deleted? If you believe this is in error, contact a staff member."
+ s_("EmailError|The thread you are replying to no longer exists, perhaps it was deleted? If you believe this is in error, contact a staff member.")
when Gitlab::Email::InvalidAttachment
error.message
when Gitlab::Email::InvalidRecordError
diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb
index 17ad1d5ab88..ed3e354e4c2 100644
--- a/app/workers/emails_on_push_worker.rb
+++ b/app/workers/emails_on_push_worker.rb
@@ -52,24 +52,22 @@ class EmailsOnPushWorker
end
valid_recipients(recipients).each do |recipient|
- begin
- send_email(
- recipient,
- project_id,
- author_id: author_id,
- ref: ref,
- action: action,
- compare: compare,
- reverse_compare: reverse_compare,
- diff_refs: diff_refs,
- send_from_committer_email: send_from_committer_email,
- disable_diffs: disable_diffs
- )
-
- # These are input errors and won't be corrected even if Sidekiq retries
- rescue Net::SMTPFatalError, Net::SMTPSyntaxError => e
- logger.info("Failed to send e-mail for project '#{project.full_name}' to #{recipient}: #{e}")
- end
+ send_email(
+ recipient,
+ project_id,
+ author_id: author_id,
+ ref: ref,
+ action: action,
+ compare: compare,
+ reverse_compare: reverse_compare,
+ diff_refs: diff_refs,
+ send_from_committer_email: send_from_committer_email,
+ disable_diffs: disable_diffs
+ )
+
+ # These are input errors and won't be corrected even if Sidekiq retries
+ rescue Net::SMTPFatalError, Net::SMTPSyntaxError => e
+ logger.info("Failed to send e-mail for project '#{project.full_name}' to #{recipient}: #{e}")
end
ensure
@email = nil
diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb
index 148384600b6..78e68d7bf46 100644
--- a/app/workers/expire_pipeline_cache_worker.rb
+++ b/app/workers/expire_pipeline_cache_worker.rb
@@ -11,56 +11,7 @@ class ExpirePipelineCacheWorker
pipeline = Ci::Pipeline.find_by(id: pipeline_id)
return unless pipeline
- store = Gitlab::EtagCaching::Store.new
-
- update_etag_cache(pipeline, store)
-
- Gitlab::Cache::Ci::ProjectPipelineStatus.update_for_pipeline(pipeline)
+ Ci::ExpirePipelineCacheService.new.execute(pipeline)
end
# rubocop: enable CodeReuse/ActiveRecord
-
- private
-
- def project_pipelines_path(project)
- Gitlab::Routing.url_helpers.project_pipelines_path(project, format: :json)
- end
-
- def project_pipeline_path(project, pipeline)
- Gitlab::Routing.url_helpers.project_pipeline_path(project, pipeline, format: :json)
- end
-
- def commit_pipelines_path(project, commit)
- Gitlab::Routing.url_helpers.pipelines_project_commit_path(project, commit.id, format: :json)
- end
-
- def new_merge_request_pipelines_path(project)
- Gitlab::Routing.url_helpers.project_new_merge_request_path(project, format: :json)
- end
-
- def each_pipelines_merge_request_path(project, pipeline)
- pipeline.all_merge_requests.each do |merge_request|
- path = Gitlab::Routing.url_helpers.pipelines_project_merge_request_path(project, merge_request, format: :json)
-
- yield(path)
- end
- end
-
- # Updates ETag caches of a pipeline.
- #
- # This logic resides in a separate method so that EE can more easily extend
- # it.
- #
- # @param [Ci::Pipeline] pipeline
- # @param [Gitlab::EtagCaching::Store] store
- def update_etag_cache(pipeline, store)
- project = pipeline.project
-
- store.touch(project_pipelines_path(project))
- store.touch(project_pipeline_path(project, pipeline))
- store.touch(commit_pipelines_path(project, pipeline.commit)) unless pipeline.commit.nil?
- store.touch(new_merge_request_pipelines_path(project))
- each_pipelines_merge_request_path(project, pipeline) do |path|
- store.touch(path)
- end
- end
end
diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb
index b33e9b1f718..489d6215774 100644
--- a/app/workers/git_garbage_collect_worker.rb
+++ b/app/workers/git_garbage_collect_worker.rb
@@ -23,13 +23,15 @@ class GitGarbageCollectWorker
end
task = task.to_sym
- project.link_pool_repository
+
+ ::Projects::GitDeduplicationService.new(project).execute
+
gitaly_call(task, project.repository.raw_repository)
# Refresh the branch cache in case garbage collection caused a ref lookup to fail
flush_ref_caches(project) if task == :gc
- project.repository.expire_statistics_caches
+ project.repository.expire_statistics_caches if task != :pack_refs
# In case pack files are deleted, release libgit2 cache and open file
# descriptors ASAP instead of waiting for Ruby garbage collection
@@ -58,7 +60,12 @@ class GitGarbageCollectWorker
## `repository` has to be a Gitlab::Git::Repository
def gitaly_call(task, repository)
- client = Gitlab::GitalyClient::RepositoryService.new(repository)
+ client = if task == :pack_refs
+ Gitlab::GitalyClient::RefService.new(repository)
+ else
+ Gitlab::GitalyClient::RepositoryService.new(repository)
+ end
+
case task
when :gc
client.garbage_collect(bitmaps_enabled?)
@@ -66,6 +73,8 @@ class GitGarbageCollectWorker
client.repack_full(bitmaps_enabled?)
when :incremental_repack
client.repack_incremental
+ when :pack_refs
+ client.pack_refs
end
rescue GRPC::NotFound => e
Gitlab::GitLogger.error("#{__method__} failed:\nRepository not found")
diff --git a/app/workers/hashed_storage/base_worker.rb b/app/workers/hashed_storage/base_worker.rb
new file mode 100644
index 00000000000..816e0504db6
--- /dev/null
+++ b/app/workers/hashed_storage/base_worker.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module HashedStorage
+ class BaseWorker
+ include ExclusiveLeaseGuard
+
+ LEASE_TIMEOUT = 30.seconds.to_i
+ LEASE_KEY_SEGMENT = 'project_migrate_hashed_storage_worker'.freeze
+
+ protected
+
+ def lease_key
+ # we share the same lease key for both migration and rollback so they don't run simultaneously
+ "#{LEASE_KEY_SEGMENT}:#{project_id}"
+ end
+
+ def lease_timeout
+ LEASE_TIMEOUT
+ end
+ end
+end
diff --git a/app/workers/hashed_storage/project_migrate_worker.rb b/app/workers/hashed_storage/project_migrate_worker.rb
new file mode 100644
index 00000000000..f00a459a097
--- /dev/null
+++ b/app/workers/hashed_storage/project_migrate_worker.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module HashedStorage
+ class ProjectMigrateWorker < BaseWorker
+ include ApplicationWorker
+
+ queue_namespace :hashed_storage
+
+ attr_reader :project_id
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def perform(project_id, old_disk_path = nil)
+ @project_id = project_id # we need to set this in order to create the lease_key
+
+ try_obtain_lease do
+ project = Project.without_deleted.find_by(id: project_id)
+ break unless project
+
+ old_disk_path ||= project.disk_path
+
+ ::Projects::HashedStorage::MigrationService.new(project, old_disk_path, logger: logger).execute
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
diff --git a/app/workers/hashed_storage/project_rollback_worker.rb b/app/workers/hashed_storage/project_rollback_worker.rb
new file mode 100644
index 00000000000..55e1d7ab23e
--- /dev/null
+++ b/app/workers/hashed_storage/project_rollback_worker.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module HashedStorage
+ class ProjectRollbackWorker < BaseWorker
+ include ApplicationWorker
+
+ queue_namespace :hashed_storage
+
+ attr_reader :project_id
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def perform(project_id, old_disk_path = nil)
+ @project_id = project_id # we need to set this in order to create the lease_key
+
+ try_obtain_lease do
+ project = Project.without_deleted.find_by(id: project_id)
+ break unless project
+
+ old_disk_path ||= project.disk_path
+
+ ::Projects::HashedStorage::RollbackService.new(project, old_disk_path, logger: logger).execute
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
diff --git a/app/workers/hashed_storage/rollbacker_worker.rb b/app/workers/hashed_storage/rollbacker_worker.rb
new file mode 100644
index 00000000000..a4da8443787
--- /dev/null
+++ b/app/workers/hashed_storage/rollbacker_worker.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module HashedStorage
+ class RollbackerWorker
+ include ApplicationWorker
+
+ queue_namespace :hashed_storage
+
+ # @param [Integer] start initial ID of the batch
+ # @param [Integer] finish last ID of the batch
+ def perform(start, finish)
+ migrator = Gitlab::HashedStorage::Migrator.new
+ migrator.bulk_rollback(start: start, finish: finish)
+ end
+ end
+end
diff --git a/app/workers/migrate_external_diffs_worker.rb b/app/workers/migrate_external_diffs_worker.rb
new file mode 100644
index 00000000000..debac97af2c
--- /dev/null
+++ b/app/workers/migrate_external_diffs_worker.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: false
+
+class MigrateExternalDiffsWorker
+ include ApplicationWorker
+
+ def perform(merge_request_diff_id)
+ diff = MergeRequestDiff.find_by_id(merge_request_diff_id)
+ return unless diff
+
+ MergeRequests::MigrateExternalDiffsService.new(diff).execute
+ end
+end
diff --git a/app/workers/object_storage/migrate_uploads_worker.rb b/app/workers/object_storage/migrate_uploads_worker.rb
index fe5d27b087d..12400d4e025 100644
--- a/app/workers/object_storage/migrate_uploads_worker.rb
+++ b/app/workers/object_storage/migrate_uploads_worker.rb
@@ -20,7 +20,7 @@ module ObjectStorage
end
def to_s
- success? ? "Migration successful." : "Error while migrating #{upload.id}: #{error.message}"
+ success? ? _("Migration successful.") : _("Error while migrating %{upload_id}: %{error_message}") % { upload_id: upload.id, error_message: error.message }
end
end
@@ -47,7 +47,7 @@ module ObjectStorage
end
def header(success, failures)
- "Migrated #{success.count}/#{success.count + failures.count} files."
+ _("Migrated %{success_count}/%{total_count} files.") % { success_count: success.count, total_count: success.count + failures.count }
end
def failures(failures)
@@ -75,9 +75,9 @@ module ObjectStorage
model_types = uploads.map(&:model_type).uniq
model_has_mount = mounted_as.nil? || model_class.uploaders[mounted_as] == uploader_class
- raise(SanityCheckError, "Multiple uploaders found: #{uploader_types}") unless uploader_types.count == 1
- raise(SanityCheckError, "Multiple model types found: #{model_types}") unless model_types.count == 1
- raise(SanityCheckError, "Mount point #{mounted_as} not found in #{model_class}.") unless model_has_mount
+ raise(SanityCheckError, _("Multiple uploaders found: %{uploader_types}") % { uploader_types: uploader_types }) unless uploader_types.count == 1
+ raise(SanityCheckError, _("Multiple model types found: %{model_types}") % { model_types: model_types }) unless model_types.count == 1
+ raise(SanityCheckError, _("Mount point %{mounted_as} not found in %{model_class}.") % { mounted_as: mounted_as, model_class: model_class }) unless model_has_mount
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -110,9 +110,9 @@ module ObjectStorage
return if args.count == 4
case args.count
- when 3 then raise SanityCheckError, "Job is missing the `model_type` argument."
+ when 3 then raise SanityCheckError, _("Job is missing the `model_type` argument.")
else
- raise SanityCheckError, "Job has wrong arguments format."
+ raise SanityCheckError, _("Job has wrong arguments format.")
end
end
@@ -126,11 +126,9 @@ module ObjectStorage
def process_uploader(uploader)
MigrationResult.new(uploader.upload).tap do |result|
- begin
- uploader.migrate!(@to_store)
- rescue => e
- result.error = e
- end
+ uploader.migrate!(@to_store)
+ rescue => e
+ result.error = e
end
end
end
diff --git a/app/workers/pages_domain_removal_cron_worker.rb b/app/workers/pages_domain_removal_cron_worker.rb
new file mode 100644
index 00000000000..79f38e1b89f
--- /dev/null
+++ b/app/workers/pages_domain_removal_cron_worker.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class PagesDomainRemovalCronWorker
+ include ApplicationWorker
+ include CronjobQueue
+
+ def perform
+ PagesDomain.for_removal.find_each do |domain|
+ domain.destroy!
+ rescue => e
+ Raven.capture_exception(e)
+ end
+ end
+end
diff --git a/app/workers/pages_domain_verification_cron_worker.rb b/app/workers/pages_domain_verification_cron_worker.rb
index 92d62a15aee..60703c83e9e 100644
--- a/app/workers/pages_domain_verification_cron_worker.rb
+++ b/app/workers/pages_domain_verification_cron_worker.rb
@@ -5,6 +5,8 @@ class PagesDomainVerificationCronWorker
include CronjobQueue
def perform
+ return if Gitlab::Database.read_only?
+
PagesDomain.needs_verification.find_each do |domain|
PagesDomainVerificationWorker.perform_async(domain.id)
end
diff --git a/app/workers/pages_domain_verification_worker.rb b/app/workers/pages_domain_verification_worker.rb
index b3319ff5a13..7817b2ee5fc 100644
--- a/app/workers/pages_domain_verification_worker.rb
+++ b/app/workers/pages_domain_verification_worker.rb
@@ -5,6 +5,8 @@ class PagesDomainVerificationWorker
# rubocop: disable CodeReuse/ActiveRecord
def perform(domain_id)
+ return if Gitlab::Database.read_only?
+
domain = PagesDomain.find_by(id: domain_id)
return unless domain
diff --git a/app/workers/pipeline_metrics_worker.rb b/app/workers/pipeline_metrics_worker.rb
index c2fbfd2b3a5..0ddad43b8d5 100644
--- a/app/workers/pipeline_metrics_worker.rb
+++ b/app/workers/pipeline_metrics_worker.rb
@@ -30,6 +30,6 @@ class PipelineMetricsWorker
# rubocop: enable CodeReuse/ActiveRecord
def merge_requests(pipeline)
- pipeline.merge_requests.map(&:id)
+ pipeline.merge_requests_as_head_pipeline.map(&:id)
end
end
diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb
index ac4e9710f33..9410fd1a786 100644
--- a/app/workers/pipeline_schedule_worker.rb
+++ b/app/workers/pipeline_schedule_worker.rb
@@ -4,41 +4,11 @@ class PipelineScheduleWorker
include ApplicationWorker
include CronjobQueue
- # rubocop: disable CodeReuse/ActiveRecord
def perform
- Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now)
- .preload(:owner, :project).find_each do |schedule|
- begin
- Ci::CreatePipelineService.new(schedule.project,
- schedule.owner,
- ref: schedule.ref)
- .execute!(:schedule, ignore_skip_ci: true, save_on_errors: true, schedule: schedule)
- rescue => e
- error(schedule, e)
- ensure
- schedule.schedule_next_run!
+ Ci::PipelineSchedule.runnable_schedules.preloaded.find_in_batches do |schedules|
+ schedules.each do |schedule|
+ Ci::PipelineScheduleService.new(schedule.project, schedule.owner).execute(schedule)
end
end
end
- # rubocop: enable CodeReuse/ActiveRecord
-
- private
-
- def error(schedule, error)
- failed_creation_counter.increment
-
- Rails.logger.error "Failed to create a scheduled pipeline. " \
- "schedule_id: #{schedule.id} message: #{error.message}"
-
- Gitlab::Sentry
- .track_exception(error,
- issue_url: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/41231',
- extra: { schedule_id: schedule.id })
- end
-
- def failed_creation_counter
- @failed_creation_counter ||=
- Gitlab::Metrics.counter(:pipeline_schedule_creation_failed_total,
- "Counter of failed attempts of pipeline schedule creation")
- end
end
diff --git a/app/workers/pipeline_success_worker.rb b/app/workers/pipeline_success_worker.rb
index 4f349ed922c..666331e6cd4 100644
--- a/app/workers/pipeline_success_worker.rb
+++ b/app/workers/pipeline_success_worker.rb
@@ -6,13 +6,7 @@ class PipelineSuccessWorker
queue_namespace :pipeline_processing
- # rubocop: disable CodeReuse/ActiveRecord
def perform(pipeline_id)
- Ci::Pipeline.find_by(id: pipeline_id).try do |pipeline|
- MergeRequests::MergeWhenPipelineSucceedsService
- .new(pipeline.project, nil)
- .trigger(pipeline)
- end
+ # no-op
end
- # rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index bbd4ab159e4..3f1639ec2ed 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -3,8 +3,10 @@
class PostReceive
include ApplicationWorker
- def perform(gl_repository, identifier, changes, push_options = [])
- project, is_wiki = Gitlab::GlRepository.parse(gl_repository)
+ PIPELINE_PROCESS_LIMIT = 4
+
+ def perform(gl_repository, identifier, changes, push_options = {})
+ project, repo_type = Gitlab::GlRepository.parse(gl_repository)
if project.nil?
log("Triggered hook for non-existing project with gl_repository \"#{gl_repository}\"")
@@ -17,10 +19,12 @@ class PostReceive
Sidekiq.logger.info "changes: #{changes.inspect}" if ENV['SIDEKIQ_LOG_ARGUMENTS']
post_received = Gitlab::GitPostReceive.new(project, identifier, changes, push_options)
- if is_wiki
+ if repo_type.wiki?
process_wiki_changes(post_received)
- else
+ elsif repo_type.project?
process_project_changes(post_received)
+ else
+ # Other repos don't have hooks for now
end
end
@@ -36,23 +40,24 @@ class PostReceive
return false
end
- post_received.changes_refs do |oldrev, newrev, ref|
- if Gitlab::Git.tag_ref?(ref)
- GitTagPushService.new(
- post_received.project,
- @user,
- oldrev: oldrev,
- newrev: newrev,
- ref: ref,
- push_options: post_received.push_options).execute
- elsif Gitlab::Git.branch_ref?(ref)
- GitPushService.new(
+ post_received.enum_for(:changes_refs).with_index do |(oldrev, newrev, ref), index|
+ service_klass =
+ if Gitlab::Git.tag_ref?(ref)
+ Git::TagPushService
+ elsif Gitlab::Git.branch_ref?(ref)
+ Git::BranchPushService
+ end
+
+ if service_klass
+ service_klass.new(
post_received.project,
@user,
oldrev: oldrev,
newrev: newrev,
ref: ref,
- push_options: post_received.push_options).execute
+ push_options: post_received.push_options,
+ create_pipelines: index < PIPELINE_PROCESS_LIMIT || Feature.enabled?(:git_push_create_all_pipelines, post_received.project)
+ ).execute
end
changes << Gitlab::DataBuilder::Repository.single_change(oldrev, newrev, ref)
@@ -69,6 +74,8 @@ class PostReceive
def process_wiki_changes(post_received)
post_received.project.touch(:last_activity_at, :last_repository_updated_at)
+ post_received.project.wiki.repository.expire_statistics_caches
+ ProjectCacheWorker.perform_async(post_received.project.id, [], [:wiki_size])
end
def log(message)
diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb
index 29a7f8e691a..3efb5343a96 100644
--- a/app/workers/process_commit_worker.rb
+++ b/app/workers/process_commit_worker.rb
@@ -48,7 +48,7 @@ class ProcessCommitWorker
# Issues::CloseService#execute.
IssueCollection.new(issues).updatable_by_user(user).each do |issue|
Issues::CloseService.new(project, author)
- .close_issue(issue, commit: commit)
+ .close_issue(issue, closed_via: commit)
end
end
diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb
index d27b5e62574..4e8ea903139 100644
--- a/app/workers/project_cache_worker.rb
+++ b/app/workers/project_cache_worker.rb
@@ -16,9 +16,11 @@ class ProjectCacheWorker
def perform(project_id, files = [], statistics = [])
project = Project.find_by(id: project_id)
- return unless project && project.repository.exists?
+ return unless project
- update_statistics(project, statistics.map(&:to_sym))
+ update_statistics(project, statistics)
+
+ return unless project.repository.exists?
project.repository.refresh_method_caches(files.map(&:to_sym))
@@ -26,19 +28,28 @@ class ProjectCacheWorker
end
# rubocop: enable CodeReuse/ActiveRecord
+ # NOTE: triggering both an immediate update and one in 15 minutes if we
+ # successfully obtain the lease. That way, we only need to wait for the
+ # statistics to become accurate if they were already updated once in the
+ # last 15 minutes.
def update_statistics(project, statistics = [])
- return unless try_obtain_lease_for(project.id, :update_statistics)
+ return if Gitlab::Database.read_only?
+ return unless try_obtain_lease_for(project.id, statistics)
- Rails.logger.info("Updating statistics for project #{project.id}")
+ Projects::UpdateStatisticsService.new(project, nil, statistics: statistics).execute
- project.statistics.refresh!(only: statistics)
+ UpdateProjectStatisticsWorker.perform_in(LEASE_TIMEOUT, project.id, statistics)
end
private
- def try_obtain_lease_for(project_id, section)
+ def try_obtain_lease_for(project_id, statistics)
Gitlab::ExclusiveLease
- .new("project_cache_worker:#{project_id}:#{section}", timeout: LEASE_TIMEOUT)
+ .new(project_cache_worker_key(project_id, statistics), timeout: LEASE_TIMEOUT)
.try_obtain
end
+
+ def project_cache_worker_key(project_id, statistics)
+ ["project_cache_worker", project_id, *statistics.sort].join(":")
+ end
end
diff --git a/app/workers/project_daily_statistics_worker.rb b/app/workers/project_daily_statistics_worker.rb
new file mode 100644
index 00000000000..101f5c28459
--- /dev/null
+++ b/app/workers/project_daily_statistics_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class ProjectDailyStatisticsWorker
+ include ApplicationWorker
+
+ def perform(project_id)
+ project = Project.find_by_id(project_id)
+
+ return unless project&.repository&.exists?
+
+ Projects::FetchStatisticsIncrementService.new(project).execute
+ end
+end
diff --git a/app/workers/project_migrate_hashed_storage_worker.rb b/app/workers/project_migrate_hashed_storage_worker.rb
deleted file mode 100644
index 1c8f313e6e9..00000000000
--- a/app/workers/project_migrate_hashed_storage_worker.rb
+++ /dev/null
@@ -1,43 +0,0 @@
-# frozen_string_literal: true
-
-class ProjectMigrateHashedStorageWorker
- include ApplicationWorker
-
- LEASE_TIMEOUT = 30.seconds.to_i
- LEASE_KEY_SEGMENT = 'project_migrate_hashed_storage_worker'.freeze
-
- # rubocop: disable CodeReuse/ActiveRecord
- def perform(project_id, old_disk_path = nil)
- uuid = lease_for(project_id).try_obtain
-
- if uuid
- project = Project.find_by(id: project_id)
- return if project.nil? || project.pending_delete?
-
- old_disk_path ||= project.disk_path
-
- ::Projects::HashedStorage::MigrationService.new(project, old_disk_path, logger: logger).execute
- else
- return false
- end
-
- ensure
- cancel_lease_for(project_id, uuid) if uuid
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def lease_for(project_id)
- Gitlab::ExclusiveLease.new(lease_key(project_id), timeout: LEASE_TIMEOUT)
- end
-
- private
-
- def lease_key(project_id)
- # we share the same lease key for both migration and rollback so they don't run simultaneously
- "#{LEASE_KEY_SEGMENT}:#{project_id}"
- end
-
- def cancel_lease_for(project_id, uuid)
- Gitlab::ExclusiveLease.cancel(lease_key(project_id), uuid)
- end
-end
diff --git a/app/workers/reactive_caching_worker.rb b/app/workers/reactive_caching_worker.rb
index 9ec8bcca4f3..b30864db802 100644
--- a/app/workers/reactive_caching_worker.rb
+++ b/app/workers/reactive_caching_worker.rb
@@ -3,7 +3,6 @@
class ReactiveCachingWorker
include ApplicationWorker
- # rubocop: disable CodeReuse/ActiveRecord
def perform(class_name, id, *args)
klass = begin
class_name.constantize
@@ -12,7 +11,9 @@ class ReactiveCachingWorker
end
return unless klass
- klass.find_by(klass.primary_key => id).try(:exclusively_update_reactive_cache!, *args)
+ klass
+ .reactive_cache_worker_finder
+ .call(id, *args)
+ .try(:exclusively_update_reactive_cache!, *args)
end
- # rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb
index 41913900571..3497a1f9280 100644
--- a/app/workers/remove_expired_members_worker.rb
+++ b/app/workers/remove_expired_members_worker.rb
@@ -6,11 +6,9 @@ class RemoveExpiredMembersWorker
def perform
Member.expired.find_each do |member|
- begin
- Members::DestroyService.new.execute(member, skip_authorization: true)
- rescue => ex
- logger.error("Expired Member ID=#{member.id} cannot be removed - #{ex}")
- end
+ Members::DestroyService.new.execute(member, skip_authorization: true)
+ rescue => ex
+ logger.error("Expired Member ID=#{member.id} cannot be removed - #{ex}")
end
end
end
diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb
index f72331c003a..43e0b9db22f 100644
--- a/app/workers/run_pipeline_schedule_worker.rb
+++ b/app/workers/run_pipeline_schedule_worker.rb
@@ -21,6 +21,30 @@ class RunPipelineScheduleWorker
Ci::CreatePipelineService.new(schedule.project,
user,
ref: schedule.ref)
- .execute(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule)
+ .execute!(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule)
+ rescue Ci::CreatePipelineService::CreateError
+ # no-op. This is a user operation error such as corrupted .gitlab-ci.yml.
+ rescue => e
+ error(schedule, e)
+ end
+
+ private
+
+ def error(schedule, error)
+ failed_creation_counter.increment
+
+ Rails.logger.error "Failed to create a scheduled pipeline. " \
+ "schedule_id: #{schedule.id} message: #{error.message}"
+
+ Gitlab::Sentry
+ .track_exception(error,
+ issue_url: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/41231',
+ extra: { schedule_id: schedule.id })
+ end
+
+ def failed_creation_counter
+ @failed_creation_counter ||=
+ Gitlab::Metrics.counter(:pipeline_schedule_creation_failed_total,
+ "Counter of failed attempts of pipeline schedule creation")
end
end
diff --git a/app/workers/schedule_migrate_external_diffs_worker.rb b/app/workers/schedule_migrate_external_diffs_worker.rb
new file mode 100644
index 00000000000..70910f7ca04
--- /dev/null
+++ b/app/workers/schedule_migrate_external_diffs_worker.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: false
+
+class ScheduleMigrateExternalDiffsWorker
+ include ApplicationWorker
+ include CronjobQueue
+ include Gitlab::ExclusiveLeaseHelpers
+
+ def perform
+ in_lock(self.class.name.underscore, ttl: 2.hours, retries: 0) do
+ MergeRequests::MigrateExternalDiffsService.enqueue!
+ end
+ rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError
+ end
+end
diff --git a/app/workers/todos_destroyer/confidential_issue_worker.rb b/app/workers/todos_destroyer/confidential_issue_worker.rb
index 481fde8c83d..240a5f98ad5 100644
--- a/app/workers/todos_destroyer/confidential_issue_worker.rb
+++ b/app/workers/todos_destroyer/confidential_issue_worker.rb
@@ -5,8 +5,8 @@ module TodosDestroyer
include ApplicationWorker
include TodosDestroyerQueue
- def perform(issue_id)
- ::Todos::Destroy::ConfidentialIssueService.new(issue_id).execute
+ def perform(issue_id = nil, project_id = nil)
+ ::Todos::Destroy::ConfidentialIssueService.new(issue_id: issue_id, project_id: project_id).execute
end
end
end
diff --git a/app/workers/update_project_statistics_worker.rb b/app/workers/update_project_statistics_worker.rb
new file mode 100644
index 00000000000..9a29cc12707
--- /dev/null
+++ b/app/workers/update_project_statistics_worker.rb
@@ -0,0 +1,18 @@
+
+# frozen_string_literal: true
+
+# Worker for updating project statistics.
+class UpdateProjectStatisticsWorker
+ include ApplicationWorker
+
+ # project_id - The ID of the project for which to flush the cache.
+ # statistics - An Array containing columns from ProjectStatistics to
+ # refresh, if empty all columns will be refreshed
+ # rubocop: disable CodeReuse/ActiveRecord
+ def perform(project_id, statistics = [])
+ project = Project.find_by(id: project_id)
+
+ Projects::UpdateStatisticsService.new(project, nil, statistics: statistics).execute
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+end